bikeindex/bike_index

View on GitHub
app/models/theft_alert.rb

Summary

Maintainability
A
2 hrs
Test Coverage
B
86%
# Note: Called "Promoted alert" on the frontend
class TheftAlert < ApplicationRecord
  STATUS_ENUM = {pending: 0, active: 1, inactive: 2}.freeze
  # Timestamp 1s before first alert was automated
  AUTOMATION_START = 1625586988 # 2021-7-6

  enum status: STATUS_ENUM

  validates :theft_alert_plan,
    :status,
    :user_id,
    presence: true

  validate :alert_cannot_begin_in_past_or_after_ends

  belongs_to :stolen_record
  belongs_to :theft_alert_plan
  belongs_to :payment
  belongs_to :user

  has_many :notifications, as: :notifiable

  before_validation :set_calculated_attributes

  scope :should_expire, -> { active.where('"theft_alerts"."end_at" <= ?', Time.current) }
  scope :paid, -> { joins(:payment).where.not(payments: {first_payment_date: nil}) }
  scope :admin, -> { where(admin: true) }
  scope :paid_or_admin, -> { paid.or(admin) }
  scope :posted, -> { where.not(start_at: nil) }
  scope :creation_ordered_desc, -> { order(created_at: :desc) }
  scope :facebook_updateable, -> { where("(facebook_data -> 'campaign_id') IS NOT NULL") }
  scope :should_update_facebook, -> { facebook_updateable.where("theft_alerts.end_at > ?", update_end_buffer) }

  delegate :duration_days, :duration_days_facebook, :amount_cents, to: :theft_alert_plan
  delegate :country, :city, :state, :zipcode, :street, to: :stolen_record, allow_nil: true

  geocoded_by nil

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

  def self.update_end_buffer
    Time.current - 2.days
  end

  def self.flatten_city(counted)
    @countries ||= Country.pluck(:id, :name).to_h
    @states ||= State.pluck(:id, :name).to_h

    [@countries[counted[0][0]], counted[0][1], @states[counted[0][2]], counted[1]]
  end

  def self.cities_count
    joins(:stolen_record)
      .group("stolen_records.country_id", "stolen_records.city", "stolen_records.state_id")
      .count
      .map { |c| flatten_city(c) }
      .sort_by { |c| -c[3] }
  end

  def self.paid_cents
    paid.sum("payments.amount_cents")
  end

  def self.facebook_integration
    "Facebook::AdsIntegration".constantize
  rescue
    nil
  end

  # Override because of recovered bikes not being in default scope
  def stolen_record
    return nil unless stolen_record_id.present?
    StolenRecord.current_and_not.find_by_id(stolen_record_id)
  end

  def bike
    stolen_record&.bike
  end

  # never geocode, use calculated lat/long
  def should_be_geocoded?
    false
  end

  def before_automation?
    (created_at || Time.current).to_i < AUTOMATION_START
  end

  def notify?
    return false if admin? || facebook_data.blank? || facebook_data&.dig("no_notify").present?
    stolen_record.present? && stolen_record.receive_notifications?
  end

  def paid?
    payment&.paid? || false
  end

  def live?
    posted? && facebook_data&.dig("effective_object_story_id").present? &&
      end_at > Time.current
  end

  # Active or has been active
  def posted?
    start_at.present?
  end

  def facebook_updateable?
    campaign_id.present?
  end

  def should_update_facebook?
    return false unless facebook_updateable?
    return false if end_at < self.class.update_end_buffer
    facebook_updated_at.blank? || facebook_updated_at < Time.current - 6.hours
  end

  # literally CAN NOT activate
  def activateable_except_approval?
    return false if missing_photo? || missing_location?
    admin ? true : paid?
  end

  # Probably don't want to activate
  def activateable?
    activateable_except_approval? && stolen_record_approved?
  end

  def activating?
    pending? && activating_at.present?
  end

  # Simplistic, can be improved
  def failed_to_activate?
    return false unless activating?
    activating_at < Time.current - 5.minutes
  end

  def recovered?
    stolen_record&.recovered?
  end

  def missing_location?
    latitude.blank? && longitude.blank?
  end

  def paid_at
    payment&.first_payment_date
  end

  def address_string
    stolen_record&.address(force_show_address: true, country: [:iso])
  end

  def stolen_record_approved?
    stolen_record&.approved? || false
  end

  def missing_photo?
    !stolen_record&.current_alert_image.present?
  end

  def facebook_name(kind = "campaign")
    n = "Theft Alert #{id} - #{amount_facebook}"
    return n if kind == "campaign"
    "#{n} - #{kind}"
  end

  def amount_cents_facebook
    return facebook_data["amount_cents"] if facebook_data&.dig("amount_cents").present?
    theft_alert_plan&.amount_cents_facebook
  end

  def amount_facebook
    MoneyFormater.money_format(amount_cents_facebook)
  end

  def amount_facebook_spent
    MoneyFormater.money_format(amount_cents_facebook_spent)
  end

  def activating_at
    t = facebook_data&.dig("activating_at")
    t.present? ? TimeParser.parse(t) : nil
  end

  def facebook_post_url
    return nil unless facebook_data&.dig("effective_object_story_id").present?
    "https://facebook.com/#{facebook_data&.dig("effective_object_story_id")}"
  end

  def campaign_id
    facebook_data&.dig("campaign_id")
  end

  def adset_id
    facebook_data&.dig("adset_id")
  end

  def ad_id
    facebook_data&.dig("ad_id")
  end

  def engagement
    facebook_data&.dig("engagement") || {}
  end

  def objective_campaign
    return nil if campaign_id.blank?
    facebook_data&.dig("objective_campaign") || default_objective("campaign")
  end

  def objective_adset
    return nil if campaign_id.blank?
    facebook_data&.dig("objective_campaign") || default_objective("adset")
  end

  def message
    "#{stolen_record&.city}: Keep an eye out for this stolen #{bike.mnfg_name}. If you see it, let the owner know on Bike Index!"
  end

  def calculated_start_at
    start_at.present? ? start_at : Time.current
  end

  # Default to 3 days, because something
  def calculated_end_at
    calculated_start_at + (duration_days_facebook || 3).days
  end

  def set_calculated_attributes
    if stolen_record&.latitude.present?
      self.latitude = stolen_record.latitude
      self.longitude = stolen_record.longitude
    end
    self.bike_id = stolen_record&.bike_id
    self.ad_radius_miles = theft_alert_plan&.ad_radius_miles unless admin
    self.amount_cents_facebook_spent = calculated_cents_facebook_spent
  end

  private

  def alert_cannot_begin_in_past_or_after_ends
    return if start_at.blank? && end_at.blank?

    if start_at.blank?
      errors.add(:start_at, :must_be_present)
    elsif end_at.blank?
      errors.add(:end_at, :must_be_present)
    elsif start_at >= end_at
      errors.add(:end_at, :must_be_later_than_start_time)
    end
  end

  def calculated_cents_facebook_spent
    facebook_data&.dig("spend_cents")
  end

  def default_objective(target)
    return nil if self.class.facebook_integration.blank?
    if target == "campaign"
      Facebook::AdsIntegration::OBJECTIVE_DEFAULT
    else
      Facebook::AdsIntegration::ADSET_OBJECTIVE_DEFAULT
    end
  end
end