module Alarmable

A reusable alarm extension to Active Record models. It adds support for the maintenance of Active Job's (create, update (cancel)) which are schedules for the given alarms. We check for changes on the alarms hash and perform updates accordingly.

This concern requires the persistence (and availability) of two properties.

Furthermore a Active Record model which uses this concern must define the Active Job class which will be scheduled. (alarm_job) The user must also define the base date property of the owning side. (alarm_base_date_property) This base date is mandatory to calculate the correct alarm date/time. When the base date is not set (nil) no new notification job will be enqueued. When the base date is unset on an update, the previously enqueued job will be canceled.

The alarms hash needs to be an array in the following format:

[
  {
    "channel": "email",   # email, push, web_notification, etc..
    "before_minutes": 15  # start_at - before_minutes, >= 1
  }
]

The given alarm job class will be scheduled with the following two arguments.

A suitable alarm job perform method should look like this:

# @param id [String] The entity id
# @param alarm [Hash] The alarm object
def perform(id, alarm)
  # Do something special for +alarm.channel+ ..
end

Constants

VERSION

Public Instance Methods

alarm_base_date_property() click to toggle source

Getter for the alarm base date property.

@return [Symbol] The user defined base date property

# File lib/alarmable/concern.rb, line 81
def alarm_base_date_property
  self.class.alarm_base_date_property
end
alarm_defaults() click to toggle source

Set some defaults on the relevant alarm properties.

# File lib/alarmable/concern.rb, line 86
def alarm_defaults
  self.alarms ||= []
  self.alarm_jobs ||= {}
end
alarm_id(channel, before_minutes) click to toggle source

Generate a unique and recalculatable identifier for a given alarm object. We build a hash of the primary keys (before_minutes and channel) to achive this. Afterwards, this alarm id is used to reference dedicated scheduled jobs and track their updates. (Or cancel them accordingly)

@param channel [String] The alarm channel @param before_minutes [Integer] The minutes before the alarm starts @return [String] The unique alarm id

:reek: UtilityFunction because its a utility, for sure

# File lib/alarmable/concern.rb, line 121
def alarm_id(channel, before_minutes)
  (Digest::MD5.new << "#{channel}#{before_minutes}").to_s
end
alarm_job() click to toggle source

Getter for the alarm job class.

@return [Class] The alarm job class

# File lib/alarmable/concern.rb, line 74
def alarm_job
  self.class.alarm_job
end
alarms_destroy_callback() click to toggle source

Cancel all alarm notification jobs on parent destroy.

# File lib/alarmable/concern.rb, line 200
def alarms_destroy_callback
  alarm_jobs.each_value { |job_id| alarm_job.cancel(job_id) }
end
alarms_update_callback() click to toggle source

Reschedule only on updates when the alarm settings are changed.

# File lib/alarmable/concern.rb, line 195
def alarms_update_callback
  reschedule_alarm_jobs if alarms_changed?
end
reschedule_alarm_job(alarm) click to toggle source

Schedule a new Active Job for the alarm notification. This method takes care of the notification time (+date) and will not touch anything when the desired time already passed. It cancels the correct job for the given combination, when it is present. In the end it schedules a new (renewed) job for the given alarm settings.

@param alarm [Hash] The alarm object @return [Object] The new alarm_jobs instance (partial)

Example: { "alarm id": "job id" }

rubocop:disable Metrics/AbcSize because its already broken down :reek: TooManyStatements because see above :reek: NilCheck because we dont want to cancel 'nil' job id :reek: DuplicateMethodCall because hash access is fast

# File lib/alarmable/concern.rb, line 139
def reschedule_alarm_job(alarm)
  # Symbolize the hash keys (just to be sure).
  alarm = alarm.symbolize_keys

  # Calculate the alarm id for job canceling and cancel a found job.
  id = alarm_id(alarm[:channel], alarm[:before_minutes])
  previous_job_id = alarm_jobs.try(:[], id)
  alarm_job.cancel(previous_job_id) unless previous_job_id.nil?

  base_date = self[alarm_base_date_property]

  # When the base date is not set, we schedule not a new notification job.
  return {} if base_date.nil?

  # Calculate the time when the job should run.
  notify_at = base_date - alarm[:before_minutes].minutes

  # Do nothing when the notification date already passed.
  return {} if Time.current >= notify_at

  # Put a new job to the queue with the new (current) job execution date.
  job = alarm_job.set(wait_until: notify_at).perform_later(self.id, alarm)

  # Construct a new alarm_jobs partial instance for this job
  Hash[id, job.job_id]
end
reschedule_alarm_jobs() click to toggle source

Initiate a reschedule for each alarm in the alarm settings and cancel all left-overs.

rubocop:disable Rails/SkipsModelValidations because we need to skip them :reek: TooManyStatements because its already broken down

# File lib/alarmable/concern.rb, line 172
def reschedule_alarm_jobs
  # Perform the reschedule of all the current alarms.
  new_alarm_jobs = alarms.each_with_object({}) do |alarm, memo|
    memo.merge!(reschedule_alarm_job(alarm))
  end

  # Detect the differences from the original alarm_jobs hash to the new
  # built (by partials) alarm_jobs hash. The jobs from negative differences
  # must be canceled.
  diff = HashDiff.diff(alarm_jobs, new_alarm_jobs)

  diff.select { |prop| prop.first == '-' }.each do |prop|
    alarm_job.cancel(prop.last)
  end

  # Update the alarm_jobs reference pool with our fresh hash.  Bypass the
  # regular validation and callbacks here, this is required to not stuck in
  # endless create-update loops.
  update_columns(alarm_jobs: new_alarm_jobs)
end
validate_alarm_settings() click to toggle source

Validate the presence of the alarm_job property and the accessibility of the specified class. Also validate the alarm_base_date_property setting.

rubocop:disable Style/GuardClause because its fine like this :reek: NilCheck because we validate concern usage

# File lib/alarmable/concern.rb, line 97
def validate_alarm_settings
  raise 'Alarmable +alarm_job+ is not configured' if alarm_job.nil?
  unless alarm_job.is_a? Class
    raise 'Alarmable +alarm_job+ is not instantiable'
  end
  if alarm_base_date_property.nil?
    raise 'Alarmable +alarm_base_date_property+ is not configured'
  end
  unless has_attribute? alarm_base_date_property
    raise 'Alarmable +alarm_base_date_property+ is not usable'
  end
end