bikeindex/bike_index

View on GitHub
app/models/graduated_notification.rb

Summary

Maintainability
B
4 hrs
Test Coverage
A
90%
class GraduatedNotification < ApplicationRecord
  STATUS_ENUM = {pending: 0, bike_graduated: 1, marked_remaining: 2}.freeze
  PENDING_PERIOD = 24.hours.freeze

  belongs_to :bike
  belongs_to :bike_organization
  belongs_to :user
  belongs_to :organization
  belongs_to :primary_bike, class_name: "Bike"
  belongs_to :primary_notification, class_name: "GraduatedNotification"
  belongs_to :marked_remaining_by, class_name: "User"

  has_many :secondary_notifications, class_name: "GraduatedNotification", foreign_key: :primary_notification_id

  validates_presence_of :bike_id, :organization_id, :bike_organization_id

  before_validation :set_calculated_attributes
  after_commit :update_associated_notifications, if: :persisted?

  enum status: STATUS_ENUM

  attr_accessor :skip_update

  scope :not_most_recent, -> { where(not_most_recent: true) }
  scope :most_recent, -> { where(not_most_recent: false) }
  scope :current, -> { where(status: current_statuses) }
  scope :processed, -> { where(status: processed_statuses) }
  scope :unprocessed, -> { where(status: unprocessed_statuses) }
  scope :primary_notification, -> { where("primary_notification_id = id") }
  scope :secondary_notification, -> { where.not("primary_notification_id  = id") }
  scope :email_success, -> { where(delivery_status: "email_success") }

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

  def self.current_statuses
    statuses - ["marked_remaining"]
  end

  def self.processed_statuses
    %w[bike_graduated marked_remaining]
  end

  def self.unprocessed_statuses
    statuses - processed_statuses
  end

  def self.status_humanized(str)
    return nil unless str.present?
    str = str.to_s
    return "marked not graduated" if str == "marked_remaining"
    str.humanize.downcase
  end

  def self.user_or_email_query(graduated_notification)
    if graduated_notification.user_id.present?
      {user_id: graduated_notification.user_id}
    else
      {email: graduated_notification.email}
    end
  end

  def self.associated_notifications_including_self(graduated_notification)
    notification_matches = where(organization_id: graduated_notification.organization_id,
      primary_bike_id: graduated_notification.primary_bike_id)
      .where(GraduatedNotification.user_or_email_query(graduated_notification))
      .where(created_at: graduated_notification.associated_interval)
    # Don't match all graduated_notifications with blank primary_notification_id
    return notification_matches if graduated_notification.primary_notification_id.blank?
    notification_matches.or(where(primary_notification_id: graduated_notification.primary_notification_id))
  end

  def self.associated_notifications(graduated_notification)
    associated_notifications_including_self(graduated_notification)
      .where.not(id: graduated_notification.id)
  end

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

  def self.bikes_to_notify_without_notifications(organization)
    organization.bikes_not_member.includes(:graduated_notifications, :ownerships)
      .where(graduated_notifications: {id: nil})
      .where("ownerships.created_at < ?", Time.current - organization.graduated_notification_interval)
      .reorder("ownerships.created_at ASC") # Use ascending so older are processed first
  end

  def self.bikes_to_notify_expired_notifications(organization)
    organization.bikes.includes(:graduated_notifications, :ownerships)
      .where.not(graduated_notifications: {marked_remaining_at: nil})
      .where("graduated_notifications.marked_remaining_at < ?", Time.current - organization.graduated_notification_interval)
      .where(graduated_notifications: {id: pluck(:id), not_most_recent: false})
      .reorder("ownerships.created_at ASC") # Use ascending so older are processed first
  end

  def self.bike_ids_to_notify(organization)
    return Bike.nil unless organization&.graduated_notification_interval&.present?
    bikes_to_notify_without_notifications(organization).pluck(:id) +
      bikes_to_notify_expired_notifications(organization).pluck(:id)
  end

  def self.marked_remaining_by_recording_started_at
    Time.at(1681847660) # 2023-04-18 14:54 - adding this to make it easier to check whether it's pre recording or not
  end

  def message
    nil # for parity with parking_notifications
  end

  def status_humanized
    self.class.status_humanized(status)
  end

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

  def primary_bike?
    bike_id == primary_bike_id
  end

  def email_success?
    delivery_status == "email_success"
  end

  # Get it unscoped, because we delete it
  def bike_organization
    bike_organization_id.present? ? BikeOrganization.unscoped.find_by_id(bike_organization_id) : nil
  end

  # Get it unscoped, because we delete it
  def user_registration_organization
    return nil if user.blank?
    UserRegistrationOrganization.unscoped.where(user_id: user_id,
      organization_id: organization_id).first
  end

  def user_display_name
    user&.display_name || email
  end

  def current?
    self.class.current_statuses.include?(status)
  end

  def processed?
    self.class.processed_statuses.include?(status)
  end

  def unprocessed?
    !processed?
  end

  def most_recent?
    !not_most_recent?
  end

  # Necessary to match parking_notification method
  def resolved?
    marked_remaining?
  end

  def primary_notification?
    id.present? && id == primary_notification_id
  end

  def secondary_notification?
    !primary_notification?
  end

  def expired?
    return false if marked_remaining_at.blank? ||
      organization.graduated_notification_interval.blank?
    marked_remaining_at < (Time.current - organization.graduated_notification_interval)
  end

  def most_recent_graduated_notification
    most_recent? ? self : matching_notifications_including_self.most_recent.last
  end

  def associated_interval
    t = created_at || Time.current
    interval = organization.graduated_notification_interval || 1.year
    (t - interval)..(t + interval)
  end

  def associated_notifications
    self.class.associated_notifications(self)
  end

  def associated_notifications_including_self
    self.class.associated_notifications_including_self(self)
  end

  # associated_notifications are notifications from the same notification period,
  # matching_notifications_including_self are notifications for the same bike, regardless of period
  def matching_notifications_including_self
    GraduatedNotification.where(bike_id: bike_id, organization_id: organization_id)
      .where(GraduatedNotification.user_or_email_query(self))
  end

  def mail_snippet
    MailSnippet.where(kind: "graduated_notification", organization_id: organization_id).first
  end

  def associated_bikes
    return Bike.none unless user.present? || bike.present?
    # We want to order the bikes by when the ownership was created, so perform that on either result
    (processed? ? bikes_from_associated_notifications : user_or_email_bikes).reorder("ownerships.created_at DESC")
  end

  def sent_at
    return nil unless email_success?
    created_at + PENDING_PERIOD
  end

  def send_email?
    organization.deliver_graduated_notifications? && primary_notification?
  end

  def pending_period_ends_at
    # provide a consistent answer for all associated notifications
    return primary_notification.pending_period_ends_at if primary_notification.present? && !primary_notification?
    (created_at || Time.current) + PENDING_PERIOD
  end

  # At least for now, don't email immediately - but maybe drop this in the future
  def in_pending_period?
    pending_period_ends_at > Time.current
  end

  def mark_remaining!(marked_remaining_by_id: nil, skip_async: false)
    unless skip_async
      MarkGraduatedNotificationRemainingWorker.perform_in(5, id, marked_remaining_by_id)
    end
    MarkGraduatedNotificationRemainingWorker.new.perform(id, marked_remaining_by_id)
  end

  def set_calculated_attributes
    self.bike_organization_id ||= calculated_bike_organization&.id
    self.user ||= bike.user
    self.email ||= calculated_email
    self.primary_bike_id ||= associated_bikes.last&.id
    self.primary_notification ||= calculated_primary_notification
    self.marked_remaining_link_token ||= SecurityTokenizer.new_token
    self.status = calculated_status
  end

  def update_associated_notifications
    return true if skip_update
    mark_previous_notifications_not_most_recent if most_recent?
    return unless primary_notification?
    self.class.associated_notifications(self)
      .each { |n| n.update(updated_at: Time.current, skip_update: true) }
  end

  def processable?
    return true if processed?
    return false unless organization.deliver_graduated_notifications?
    # The primary notification should be the first one to process, so skip processing if it isn't
    return false unless primary_notification? || (primary_notification.present? && primary_notification.processed?)
    if primary_notification? && associated_bike_ids_missing_notifications.any?
      # We haven't created all the relevant graduated notifications, create them before processing
      associated_bike_ids_missing_notifications.each do |b_id|
        CreateGraduatedNotificationWorker.perform_async(organization_id, b_id)
      end
      return false
    end
    # Also, skip running immediately - at least for now
    !in_pending_period?
  end

  # This is here because we're calling it from the creation job, and I like it here more than in the job
  def process_notification
    return true if email_success?
    return false unless processable?

    user_registration_organization&.destroy_for_graduated_notification!
    bike_organization&.destroy!

    # deliver email before everything, so if fails, we send when we try again
    OrganizedMailer.graduated_notification(self).deliver_now if send_email?

    @skip_update = true
    update(processed_at: Time.current, delivery_status: "email_success", skip_update: true)
    return true unless primary_notification?
    # Update the associated notifications after updating the primary notification, so if we fail, they can be updated by the worker
    associated_notifications.each do |notification|
      notification.process_notification
    end
    true
  end

  def subject
    return mail_snippet.subject if mail_snippet&.subject.present?
    "Renew your #{bike&.type || "Bike"} registration with #{organization&.short_name}"
  end

  private

  def calculated_status
    # Because prior to commit, the value for the current notification isn't set
    return "marked_remaining" if marked_remaining_at.present?
    # Similar - if this is the primary_notification, we want to make sure it's marked processed during save
    email_success? || primary_notification.present? && primary_notification.email_success? ? "bike_graduated" : "pending"
  end

  def calculated_email
    user&.email || bike.owner_email
  end

  def calculated_bike_organization
    BikeOrganization.where(bike_id: bike_id, organization_id: organization_id)
      .where("created_at < ?", created_at || Time.current)
      .reorder(:id).last
  end

  # THIS CAN BE NIL! - the primary_notification might not exist yet
  def calculated_primary_notification
    # If an associated notification was already emailed out, use that notification
    return existing_sent_notification if existing_sent_notification.present?
    return self if primary_bike? && primary_notification_id.blank? # This is the primary notification
    notifications = GraduatedNotification.where(organization_id: organization_id, bike_id: primary_bike_id)
      .where(GraduatedNotification.user_or_email_query(self))
    # If there aren't any notifications, return nil
    # Also - if the organization doesn't have an interval set, we can't do anything, so skip it
    return notifications&.first if organization.graduated_notification_interval.blank?
    # Otherwise, only match on notifications from the same period
    notifications.where(created_at: potential_matching_period)
      .or(notifications.where(marked_remaining_at: potential_matching_period)).first
  end

  def potential_matching_period
    c_at = created_at || Time.current
    (c_at - organization.graduated_notification_interval)..(c_at + organization.graduated_notification_interval)
  end

  def existing_sent_notification
    existing_notification = if organization.graduated_notification_interval.present?
      GraduatedNotification.where(created_at: potential_matching_period)
        .where(GraduatedNotification.user_or_email_query(self))
        .where(organization_id: organization_id)
        .email_success.primary_notification.first
    end
    existing_notification ||
      associated_notifications_including_self.email_success.primary_notification.first
  end

  def user_or_email_bikes
    if user_id.present?
      Bike.unscoped.current.includes(:ownerships).where(ownerships: {current: true, user_id: user_id})
        .organization(organization_id)
    else
      organization.bikes.includes(:ownerships).where(owner_email: bike.owner_email)
    end
  end

  def bikes_from_associated_notifications
    # If the notifications have been processed, the bikes are removed from the organization - using associated_notifications will get them
    Bike.unscoped.where(id: associated_notifications.pluck(:bike_id) + [bike_id]).includes(:ownerships)
  end

  def associated_bike_ids_missing_notifications
    associated_bike_ids = associated_bikes.pluck(:id)
    associated_notification_bike_ids = associated_notifications_including_self.pluck(:bike_id)
    associated_bike_ids - associated_notification_bike_ids
  end

  def previous_notifications
    matching_notifications_including_self.where("id < ?", id)
  end

  def mark_previous_notifications_not_most_recent
    previous_notifications.most_recent.update_all(not_most_recent: true)
  end
end