bikeindex/bike_index

View on GitHub
app/models/parking_notification.rb

Summary

Maintainability
A
3 hrs
Test Coverage
A
97%
# frozen_string_literal: true

class ParkingNotification < ActiveRecord::Base
  include Geocodeable
  KIND_ENUM = {appears_abandoned_notification: 0, parked_incorrectly_notification: 1, impound_notification: 2}.freeze
  STATUS_ENUM = {current: 0, replaced: 1, impounded: 2, retrieved: 3, impounded_retrieved: 5, resolved_otherwise: 4}.freeze
  RETRIEVED_KIND_ENUM = {organization_recovery: 0, link_token_recovery: 1, user_recovery: 2}.freeze
  MAX_PER_PAGE = 250

  mount_uploader :image, ImageUploaderBackgrounded
  process_in_background :image

  belongs_to :bike
  belongs_to :user
  belongs_to :organization
  belongs_to :impound_record
  belongs_to :initial_record, class_name: "ParkingNotification"
  belongs_to :retrieved_by, class_name: "User"

  has_many :repeat_records, class_name: "ParkingNotification", foreign_key: :initial_record_id

  validates_presence_of :bike_id, :user_id
  validate :location_present, on: :create

  before_validation :set_calculated_attributes
  after_commit :process_notification

  enum kind: KIND_ENUM
  enum status: STATUS_ENUM
  enum retrieved_kind: RETRIEVED_KIND_ENUM

  attr_accessor :is_repeat, :use_entered_address, :image_cache, :skip_update

  scope :active, -> { where(resolved_at: nil) }
  scope :resolved, -> { where.not(resolved_at: nil) }
  scope :initial_records, -> { where(initial_record_id: nil) }
  scope :repeat_records, -> { where.not(initial_record_id: nil) }
  scope :with_impound_record, -> { where.not(impound_record_id: nil) }
  scope :email_success, -> { where(delivery_status: "email_success") }
  scope :send_email, -> { where.not(unregistered_bike: true) }
  scope :unregistered_bike, -> { where(unregistered_bike: true) }
  scope :not_unregistered_bike, -> { where(unregistered_bike: false) }
  scope :first_notification, -> { where(repeat_number: 0) }
  scope :not_replaced, -> { where.not(status: "replaced") }

  def self.kinds
    KIND_ENUM.keys.map(&:to_s)
  end

  def self.statuses
    STATUS_ENUM.keys.map(&:to_s)
  end

  def self.kinds_humanized
    {
      appears_abandoned_notification: "Appears abandoned",
      parked_incorrectly_notification: "Parked incorrectly",
      impound_notification: "Impounded"
    }
  end

  def self.associated_notifications_including_self(id, initial_record_id)
    potential_id_matches = [id, initial_record_id].compact
    where(initial_record_id: potential_id_matches).or(where(id: potential_id_matches))
  end

  def self.associated_notifications(id, initial_record_id)
    potential_id_matches = [id, initial_record_id].compact
    where(initial_record_id: potential_id_matches).where.not(id: id)
      .or(where(id: initial_record_id))
  end

  def self.bikes
    Bike.unscoped.includes(:parking_notifications)
      .where(parking_notifications: {id: pluck(:id)})
  end

  # Passing in an already formed bounding_box - added method to explicitly document required args
  def self.search_bounding_box(sw_lat, sw_lng, ne_lat, ne_lng)
    within_bounding_box(sw_lat, sw_lng, ne_lat, ne_lng)
  end

  # geocoding is managed by set_calculated_attributes
  def should_be_geocoded?
    false
  end

  def sent_at
    email_success? ? created_at : nil
  end

  # Get it unscoped, because unregistered_bike notifications
  def bike
    @bike ||= bike_id.present? ? Bike.unscoped.find_by_id(bike_id) : nil
  end

  def active?
    resolved_at.blank?
  end

  def resolved?
    !active?
  end

  def email_success?
    delivery_status == "email_success"
  end

  def initial_record?
    initial_record_id.blank?
  end

  def repeat_record?
    initial_record_id.present?
  end

  def owner_known?
    !unregistered_bike? && bike&.owner_email.present?
  end

  def send_email?
    owner_known?
  end

  def show_address
    !hide_address
  end

  def kind_humanized
    self.class.kinds_humanized[kind.to_sym]
  end

  def can_be_repeat?
    potential_initial_record.present?
  end

  def email
    bike.owner_email
  end

  def reply_to_email
    if impound_notification? && organization.present?
      impound_email = organization.fetch_impound_configuration.email
      return impound_email if impound_email.present?
    end
    organization&.auto_user&.email || user&.email
  end

  def bike_notifications
    ParkingNotification.where(organization_id: organization_id, bike_id: bike_id)
  end

  def current_associated_notification
    current? ? self : associated_notifications_including_self.order(:id).last
  end

  def mail_snippet
    organization.blank? ? nil : MailSnippet.where(kind: kind, organization_id: organization_id).first
  end

  # Only initial_record and repeated records - does not include any resolved parking notifications
  def associated_notifications
    self.class.associated_notifications(id, initial_record_id)
  end

  def associated_notifications_including_self
    self.class.associated_notifications_including_self(id, initial_record_id)
  end

  def associated_retrieved_notification
    return nil unless resolved_at.present? # Used by calculated_state, so we can't use the status
    retrieved_kind.present? ? self : associated_notifications_including_self.where.not(retrieved_kind: nil).first
  end

  # Not just associated notifications - any notifications for the bike, from the organization, closed/open in the same period
  def notifications_from_period
    # Give a 1 window for resolution matching, in case things happen at slightly different times
    period_end = (resolved_at || Time.current) + 1.minute
    notifications = bike_notifications.where("created_at < ?", period_end)
    period_start = bike_notifications.resolved.where("resolved_at < ?", (resolved_at || Time.current) - 1.minute)
      .reorder(:resolved_at).last&.resolved_at
    period_start.present? ? notifications.where("created_at > ?", period_start) : notifications
  end

  def earlier_bike_notifications
    id.present? ? bike_notifications.where("id < ?", id) : bike_notifications
  end

  def potential_initial_record
    return earlier_bike_notifications.initial_records.order(:id).last unless id.blank?
    # If this is a new record, we the record needs to be current
    earlier_bike_notifications.current.initial_records.order(:id).last
  end

  def likely_repeat?
    return false unless can_be_repeat?
    # We know there has to be a potential initial record if can_be_repeat,
    # so it doesn't matter if we scope to current on new records or not
    earlier_bike_notifications.maximum(:created_at) > (created_at || Time.current) - 1.month
  end

  def notification_number
    (repeat_number || 0) + 1
  end

  # Doesn't require a user because email recovery doesn't require a user
  # args are (retrieved_kind:, retrieved_by_id: nil, resolved_at: nil) - using passed args to avoid overriding methods
  def mark_retrieved!(passed_args = {})
    return self if retrieved?
    # If replaced, mark the current notification retrieved instead, if a current notification exists
    if replaced? && calculated_later_notifications.current.last.present?
      return calculated_later_notifications.current.last.mark_retrieved!(passed_args)
    end
    self.retrieved_kind = passed_args[:retrieved_kind]
    # Assign status here because of calculated_status
    update!(status: "retrieved",
      retrieved_by_id: passed_args[:retrieved_by_id],
      resolved_at: passed_args[:resolved_at] || Time.current)
    self
  end

  # force_show_address, just like stolen_record - but this has a hide_address attr, so by default we show addresses
  def address(force_show_address: false, country: [:iso, :optional, :skip_default])
    Geocodeable.address(
      self,
      street: (force_show_address || show_address),
      country: country
    ).presence
  end

  def set_location_from_organization
    self.country_id = organization&.country&.id
    self.city = organization&.city
    self.zipcode = organization&.zipcode
    self.state_id = organization&.state&.id
  end

  def set_calculated_attributes
    self.initial_record_id ||= potential_initial_record&.id if is_repeat
    self.repeat_number ||= calculated_repeat_number
    self.resolved_at ||= calculated_resolved_at # Used by by calculated_status, so must come first
    self.status = calculated_status
    # Only set unregistered_bike on creation
    self.unregistered_bike = calculated_unregistered_parking_notification if id.blank?
    # generate retrieval token after checking if unregistered_bike
    self.retrieval_link_token ||= SecurityTokenizer.new_token if current? && send_email?
    # We need to geocode on creation, unless all the attributes are present
    return true if id.present? && street.present? && latitude.present? && longitude.present?
    if !use_entered_address && latitude.present? && longitude.present?
      self.attributes = Geohelper.assignable_address_hash(Geohelper.reverse_geocode(latitude, longitude))
        .except(:latitude, :longitude) # Don't re-overwrite coordinates
    else
      coordinates = Geohelper.coordinates_for(address)
      self.attributes = coordinates if coordinates.present?
      self.location_from_address = true
    end
  end

  def location_present
    # in case geocoder is failing (which happens sometimes), permit if either is present
    return true if latitude.present? && longitude.present? || address.present?
    errors.add(:address, :address_required)
  end

  def subject
    return mail_snippet.subject if mail_snippet&.subject.present?
    if appears_abandoned_notification?
      "Your #{bike&.type || "Bike"} appears to be abandoned"
    elsif parked_incorrectly_notification?
      "Your #{bike&.type || "Bike"} is parked incorrectly"
    elsif impounded?
      "Your #{bike&.type || "Bike"} was impounded"
    end
  end

  def process_notification
    return true if skip_update
    # Update the bike immediately, inline
    bike&.update(updated_at: Time.current)
    ProcessParkingNotificationWorker.perform_async(id)
  end

  # new_attrs needs to include kind and user_id. It can include additional attrs if they matter
  def retrieve_or_repeat_notification!(new_attrs)
    new_attrs = new_attrs.with_indifferent_access
    if new_attrs.with_indifferent_access[:kind] == "mark_retrieved"
      mark_retrieved!(retrieved_by_id: new_attrs[:user_id], retrieved_kind: "organization_recovery")
    else
      return self unless active?
      attrs = attributes.except("id", "internal_notes", "created_at", "updated_at", "message",
        "location_from_address", "retrieval_link_token", "delivery_status")
        .merge(new_attrs)
      attrs["initial_record_id"] ||= id
      ParkingNotification.create!(attrs)
    end
  end

  private

  def calculated_repeat_number
    return 0 unless repeat_record?
    other_records = ParkingNotification.where(initial_record_id: initial_record_id)
    # Generally this will be called on create, so id won't be present
    other_records = other_records.where("id < ?", id) if id.present?
    other_records.count + 1
  end

  def calculated_unregistered_parking_notification
    bike&.unregistered_parking_notification? || initial_record&.unregistered_bike? || false
  end

  def calculated_later_notifications
    return ParkingNotification.none if id.blank?
    associated_notifications.where("id > ?", id)
  end

  def calculated_resolved_at
    # # If there is a resolved notification, use it for resolved_at
    resolved_notification = associated_notifications_including_self.resolved.first
    # Also set the resolved_at if this is an impound_notification
    return nil unless impound_notification? || resolved_notification.present?
    resolved_notification&.resolved_at || Time.current
  end

  def calculated_status
    return "replaced" if calculated_later_notifications.any?
    if impound_notification? || impound_record_id.present?
      impound_record&.resolved? ? "impounded_retrieved" : "impounded"
    elsif resolved_at.present?
      return "retrieved" if associated_retrieved_notification.present?
      associated_notifications.resolved.last&.status || "resolved_otherwise"
    else
      "current"
    end
  end
end