Class: EmployeePhoneStatus

Inherits:
ApplicationRecord show all
Includes:
Models::Auditable
Defined in:
app/models/employee_phone_status.rb

Overview

Model to keep track of a user's phone system settings and presence
== Schema Information

Table name: employee_phone_statuses
Database name: primary

id :integer not null, primary key
auto_away_after :time
click_to_call_integration :enum default("api")
date_set :datetime
dnd_alert_threshold_minutes :integer
extension :integer
last_alert :datetime
message :string
pbx_integration :enum default("none")
presence :string(20)
queue_statuses :jsonb
status_options :jsonb
sub_presence :string
created_at :datetime
updated_at :datetime
contact_point_id :integer
employee_id :integer
switchvox_account_id :integer

Indexes

by_pres_spres (presence,sub_presence)
employee_phone_statuses_employee_id_idx (employee_id)
idx_eps_contact_point_id (contact_point_id)
idx_eps_switchvox_account_id (switchvox_account_id)

Foreign Keys

fk_rails_... (contact_point_id => contact_points.id)
fk_rails_... (employee_id => parties.id) ON DELETE => cascade

Defined Under Namespace

Classes: BroadcastSwitchboard, QueueCoverageImpact, QueueStatusPuller, StatusAlerter

Constant Summary collapse

QUEUE_LOGIN_STATES =

Queue login states.

{ 'logged_in' => true, 'permanent' => true, 'logged_out' => false }.freeze

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Has many collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Models::Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#switchvox_account_idObject (readonly)

Validates switchvox account id, pbx integration switchvox.

Validations (if => #pbx_integration_switchvox? ):

Validations (if => #switchvox_account_id ):



61
# File 'app/models/employee_phone_status.rb', line 61

validates :switchvox_account_id, presence: true, numericality: { greater_than: 0 }, if: :pbx_integration_switchvox?

Class Method Details

.broadcast_agents_status(employee_phone_statuses = nil, _options = {}) ⇒ Object

Retrieve the current database state of agent statuses and broadcast

Parameters:

  • employee_phone_statuses (Object) (defaults to: nil)

    the employee phone statuses

  • _options (Object) (defaults to: {})

    the options



249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'app/models/employee_phone_status.rb', line 249

def self.broadcast_agents_status(employee_phone_statuses = nil, _options = {})
  employee_phone_statuses ||= EmployeePhoneStatus.all
  state_hsh = {}
  [employee_phone_statuses].flatten.each do |eps|
    state_hsh[eps.employee_id] = {
      presence: eps.presence,
      sub_presence: eps.sub_presence,
      message: eps.message,
      date_set: eps.date_set,
      timestamp: (eps.date_set.to_f * 1000).to_i # Browser timestamps are in ms since epoch (not seconds)
    }
  end
  # Todo Replace with SSE
  # ActionCable.server.broadcast('agent_statuses', state_hsh) if state_hsh.present?
end

.dnd_alertsActiveRecord::Relation<EmployeePhoneStatus>

A relation of EmployeePhoneStatuses that are dnd alerts. Active Record Scope

Returns:

See Also:



75
76
77
78
79
# File 'app/models/employee_phone_status.rb', line 75

scope :dnd_alerts, -> {
  where.not(dnd_alert_threshold_minutes: nil).where.not(date_set: nil)
       .where(presence: 'dnd')
       .where("date_set + dnd_alert_threshold_minutes * INTERVAL '1 minutes' > ?", Time.current)
}

.extension_to_employee_id_hashObject

Extension to employee id hash.



170
171
172
173
174
175
176
# File 'app/models/employee_phone_status.rb', line 170

def self.extension_to_employee_id_hash
  Rails.cache.fetch(:pbx_extension_to_employee_id_index, expires_in: 1.day) do
    Employee.active_employees.includes(:employee_phone_status, :contact_points)
            .reject { |emp| emp.pbx_extension.nil? }
            .to_h { |emp| [emp.pbx_extension, emp.id] }
  end
end

.garnish_employees(employees = nil, only_with_phone_record = false) ⇒ Object

Takes an employee relation and filter on it and associate all records
for performance in phone operation

Parameters:

  • employees (Object) (defaults to: nil)

    the employees

  • only_with_phone_record (Object) (defaults to: false)

    the only with phone record



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'app/models/employee_phone_status.rb', line 93

def self.garnish_employees(employees = nil, only_with_phone_record = false)
  # Handle single id
  employees = [employees] if employees.is_a? Integer

  # Handle array of ids
  employees = Employee.where(id: employees) if employees &&
                                               employees.try(:[], 0).try(:is_a?, Integer)

  # Defaults to all
  employees ||= Employee.all
  # Includes common records and filter by active and phone enabled employees
  employees = employees.active_employees.phone_enabled.joins(:employee_record).includes(:employee_record, :employee_phone_status)
  # Enforce presence of employee phone status if only with phone record option specified
  employees = employees.joins(:employee_phone_status) if only_with_phone_record
  employees
end

.initialize_and_push_presence(employees, presence, sub_presence = nil, options = {}) ⇒ void

This method returns an undefined value.

This method will set the status on one or multiple employees then sync statuses to the phone system

Parameters:

  • employees (Employee, Array<Employee>)

    the employees to update

  • presence (String)

    the new presence

  • sub_presence (String, nil) (defaults to: nil)

    the new sub-presence

  • options (Hash) (defaults to: {})

    push options, forwarded to #push_presence

Options Hash (options):

  • broadcast (Boolean)

    broadcast the agents status after the push



149
150
151
152
153
154
155
# File 'app/models/employee_phone_status.rb', line 149

def self.initialize_and_push_presence(employees, presence, sub_presence = nil, options = {})
  garnish_employees(employees, false).flatten.each do |employee|
    # Find out the status id we should be using
    eps = employee.employee_phone_status || employee.build_employee_phone_status
    eps.push_presence presence, sub_presence, options
  end
end

.migrate_extensionsObject

Migrate extensions.



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'app/models/employee_phone_status.rb', line 111

def self.migrate_extensions
  Employee.find_each do |employee|
    next unless (cp = employee.contact_points.where("detail ILIKE '+1847550%'").first)

    ext = "8#{cp.detail.last(2)}"
    puts "Employee id #{employee.id} #{employee.full_name} Ext: #{ext}"
    if (eps = employee.employee_phone_status)
      eps.update_attribute!(:extension, ext)
    end
    # Find 800 #
    if (cp800 = employee.contact_points.where("detail ILIKE '+18008755285'").first)
      cp800.detail = cp800.detail + " x#{ext}"
      cp800.save
    end
  end
end

.pull_presence(employees = nil, _options = {}) ⇒ Object

Pull all current presence statuses from the phone system

Parameters:

  • employees (Object) (defaults to: nil)

    the employees

  • _options (Object) (defaults to: {})

    the options



160
161
162
163
164
165
166
167
# File 'app/models/employee_phone_status.rb', line 160

def self.pull_presence(employees = nil, _options = {})
  result = {}
  garnish_employees(employees).each do |employee|
    employee_phone_status = employee.employee_phone_status || employee.build_employee_phone_status
    result[employee.id] = employee_phone_status.pull_presence
  end
  result
end

.pull_queue_status(queue_status_hsh = nil, observed_at: nil) ⇒ Object

Retrieves switchvox queue statuses and record them in our database
You can force a full pull rather than targetted to only queues
retrieved previously by passing ignore_queue_filter: true to the option hash

Parameters:

  • queue_status_hsh (Hash, nil) (defaults to: nil)

    pre-pulled statuses in
    Phone::Pbx#get_queues_status shape. PullPhoneQueueStatusWorker passes
    the statuses it already derived from its roster pull so the PBX isn't
    asked for the same queues twice; nil pulls them here as before.

  • observed_at (Time, nil) (defaults to: nil)

    when the PBX snapshot started



273
274
275
# File 'app/models/employee_phone_status.rb', line 273

def self.pull_queue_status(queue_status_hsh = nil, observed_at: nil)
  QueueStatusPuller.call(queue_status_hsh:, observed_at:)
end

.push_presence(employees = nil, _options = {}) ⇒ Object

Pushes the presence of an employee to the phone system

Parameters:

  • employees (Object) (defaults to: nil)

    the employees

  • _options (Object) (defaults to: {})

    the options



131
132
133
134
135
136
137
138
139
# File 'app/models/employee_phone_status.rb', line 131

def self.push_presence(employees = nil, _options = {})
  employees = garnish_employees(employees, true)
  employees.each do |employee|
    employee.employee_phone_status.push_presence
  end
  # Retrieve call status

  broadcast_agents_status employees.map(&:employee_phone_status)
end

.refresh_all_status_optionsObject

Refresh all status options.



435
436
437
# File 'app/models/employee_phone_status.rb', line 435

def self.refresh_all_status_options
  find_each(&:refresh_status_options)
end

.switchboard_stream_name(employee_id, can_manage:) ⇒ String

Builds the internal Action Cable stream for one watched employee and
permission tier.

Parameters:

  • employee_id (Integer)

    watched employee ID

  • can_manage (Boolean)

    whether the subscriber can manage phone status

Returns:

  • (String)

    scoped stream name



202
203
204
# File 'app/models/employee_phone_status.rb', line 202

def self.switchboard_stream_name(employee_id, can_manage:)
  EmployeePhoneStatus::BroadcastSwitchboard.stream_name(employee_id, can_manage:)
end

Instance Method Details

#auto_away_time_checkObject

Applies an automatic away transition in memory. The caller persists it
only after the PBX accepts the corresponding presence and queue updates,
leaving the transition due for the next scheduled pull when either fails.



365
366
367
368
369
370
371
372
373
374
375
376
# File 'app/models/employee_phone_status.rb', line 365

def auto_away_time_check
  if time_to_set_away? && (presence != 'away') && !current_status_set_after_auto_away?
    logger.info "[EmployeePhoneStatus:auto_away_time_check] Time check on employee id #{employee_id}, auto away at #{auto_away_after}, current presence: #{presence}. Changing status to away"
    self.date_set = Time.current
    self.presence = 'away'
    self.sub_presence = nil
    true
  else
    logger.info "[EmployeePhoneStatus:auto_away_time_check] Time check on employee id #{employee_id}, auto away at #{auto_away_after}, current status #{presence} set at #{date_set}. Skipping"
    false
  end
end

#broadcast_agent_statusObject

Broadcast agent status.



179
180
181
# File 'app/models/employee_phone_status.rb', line 179

def broadcast_agent_status
  self.class.broadcast_agents_status(self)
end

#broadcast_switchboard_cardvoid

This method returns an undefined value.

Delegates switchboard card updates after relevant commits.



209
210
211
212
213
214
# File 'app/models/employee_phone_status.rb', line 209

def broadcast_switchboard_card
  EmployeePhoneStatus::BroadcastSwitchboard.call(
    employee_phone_status: self,
    operation: :update
  )
end

#contact_pointContactPoint?

Returns the contact point this record belongs to.

Returns:

  • (ContactPoint, nil)

    the contact point this record belongs to



53
# File 'app/models/employee_phone_status.rb', line 53

belongs_to :contact_point, optional: true

#curated_presence_listObject

Retrives a list of status options, business rules dictate we only care
about your available options, there's only one away and one dnd



467
468
469
470
471
472
473
474
475
476
477
# File 'app/models/employee_phone_status.rb', line 467

def curated_presence_list
  # Retrieve 'available' statuses followed by away and dnd status
  valid_presence_list.map do |opt|
    {
      active: (opt['active'] == '1'),
      id: opt['id'].to_i,
      sub_presence: opt['sub_presence'].presence,
      presence: opt['presence']
    }
  end
end

#current_status_set_after_auto_away?Boolean

Returns whether the record current status set after auto away.

Returns:

  • (Boolean)

    whether the record current status set after auto away



358
359
360
# File 'app/models/employee_phone_status.rb', line 358

def current_status_set_after_auto_away?
  date_set.today? && date_set.to_time_of_day > auto_away_after
end

#dnd_alert_threshold_secondsObject

Dnd alert threshold seconds.



342
343
344
# File 'app/models/employee_phone_status.rb', line 342

def dnd_alert_threshold_seconds
  dnd_alert_threshold_minutes * 60 if dnd_alert_threshold_minutes
end

#dnd_presence_alert?Boolean

Returns whether the record dnd presence alert.

Returns:

  • (Boolean)

    whether the record dnd presence alert



347
348
349
# File 'app/models/employee_phone_status.rb', line 347

def dnd_presence_alert?
  minutes_in_current_status && dnd_alert_threshold_minutes && minutes_in_current_status >= dnd_alert_threshold_minutes
end

#employeeEmployee

Returns the employee this record belongs to.

Returns:

  • (Employee)

    the employee this record belongs to



51
# File 'app/models/employee_phone_status.rb', line 51

belongs_to :employee, inverse_of: :employee_phone_status

#employee_phone_status_changesActiveRecord::Relation<EmployeePhoneStatusChange>

Returns the associated employee phone status changes.

Returns:



56
# File 'app/models/employee_phone_status.rb', line 56

has_many :employee_phone_status_changes

#enqueue_crm_navbar_presence_refreshObject

Targeted (single-user) refresh of the navbar presence dot. Only enqueues
when something the dot reflects actually changed (presence, sub_presence,
or message). Coalescing happens inside CrmNavbarRefreshWorker.



186
187
188
189
190
191
192
193
194
# File 'app/models/employee_phone_status.rb', line 186

def enqueue_crm_navbar_presence_refresh
  return if employee_id.blank?
  return unless previously_new_record? ||
                saved_change_to_presence? ||
                saved_change_to_sub_presence? ||
                saved_change_to_message?

  CrmNavbarRefreshWorker.schedule(user_id: employee_id, badge: :presence_dot)
end

#logged_in_queue?Boolean

Is the user logged in queue? we detect in the last retrieved queue status
if user is logged in all queue. It's an all or nothing, logged in all return true
otherwise false

Returns:

  • (Boolean)


401
402
403
404
# File 'app/models/employee_phone_status.rb', line 401

def logged_in_queue?
  queue_ids = (queue_statuses || {}).keys
  queue_ids.present? && queue_ids.all? { |queue_id| queue_logged_in?(queue_id) }
end

#minutes_in_current_statusObject

Minutes in current status.



335
336
337
338
339
# File 'app/models/employee_phone_status.rb', line 335

def minutes_in_current_status
  return unless date_set

  ((Time.current - date_set) / 60).ceil
end

#pull_presence(_force = false) ⇒ Object

Pulls the pbx presence and update locally

Parameters:

  • _force (Object) (defaults to: false)

    the force



307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
# File 'app/models/employee_phone_status.rb', line 307

def pull_presence(_force = false)
  return push_current_presence(auto_away_applied: true) if auto_away_time_check

  if (status_info = Phone::Pbx.instance.get_presence_status())
    self.presence = status_info[:presence].presence
    self.sub_presence = status_info[:sub_presence].presence
    logger.debug("[EmployeePhoneStatus:pull_presence] pulled", employee_phone_status_id: id, switchvox_account_id: )
    return :no_status_change unless presence_changed? || sub_presence_changed?

    self.message = status_info[:message].presence
    # Switchvox omits date_set for some statuses (e.g. plain "available"), so
    # fall back to now — the change we just observed happened by this moment.
    # Without this, the NOT NULL employee_phone_status_changes.date_set that
    # the after_save record_change writes blows up and the pulled presence is
    # never persisted, so the CRM never reflects the Sangoma-side change
    # (AppSignal #6175).
    self.date_set = status_info[:date_set].presence || Time.current
    return :status_changed if save

    :error_saving_record

  else
    logger.error "[EmployeePhoneStatus:pull_presence:#{id}] no presence status could be retrieved for switchvox_account_id #{}"
    :api_call_failure
  end
end

#push_presence(new_presence = nil, new_sub_presence = nil, options = {}) ⇒ Boolean

Pushes the local presence settings to the pbx api

Parameters:

  • new_presence (String, nil) (defaults to: nil)

    the new presence, nil pushes the stored presence

  • new_sub_presence (String, nil) (defaults to: nil)

    the new sub-presence

  • options (Hash) (defaults to: {})

    push options

Options Hash (options):

  • broadcast (Boolean)

    broadcast the agents status after the push

Returns:

  • (Boolean)

    whether the push succeeded



284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'app/models/employee_phone_status.rb', line 284

def push_presence(new_presence = nil, new_sub_presence = nil, options = {})
  # First save new presence if specified
  if new_presence
    self.presence = new_presence
    self.sub_presence = new_sub_presence
    self.date_set = Time.current
    auto_away_applied = false
  else
    # No explicit presence: push whatever the CRM currently holds. This path
    # also runs right after a user change (set_presence saves the new value,
    # then enqueues SyncPhoneStatusWorker -> push_presence with no args), so it
    # MUST NOT reconcile from the PBX first — doing so reads the PBX's stale
    # value and clobbers the just-set status, breaking CRM -> Sangoma changes.
    # Inbound Sangoma -> CRM sync is owned by PullPhonePresenceWorker, which
    # keeps the stored presence fresh so this re-assert never pushes stale data.
    auto_away_applied = auto_away_time_check
  end

  push_current_presence(auto_away_applied:, options:)
end

#queue_ids_requiring_status(log_in_queue:) ⇒ Array<String>

Limits queue API writes to states that differ from the desired presence.
Unknown states are included so reconciliation remains fail-safe.

Parameters:

  • log_in_queue (Boolean)

    desired login state

Returns:

  • (Array<String>)

    queue account ids that need a PBX transition



430
431
432
# File 'app/models/employee_phone_status.rb', line 430

def queue_ids_requiring_status(log_in_queue:)
  (queue_statuses || {}).keys.reject { |queue_id| (queue_id) ==  }
end

#queue_logged_in?(queue_id) ⇒ Boolean

Returns whether the latest observed PBX state has this queue actively
routing calls to the employee. Switchvox has emitted both the current
status-string shape and a legacy nested-hash shape over time.

Parameters:

  • queue_id (Integer, String)

    Switchvox queue account id

Returns:

  • (Boolean)


412
# File 'app/models/employee_phone_status.rb', line 412

def queue_logged_in?(queue_id) = (queue_id) == true

#queue_login_status(queue_id) ⇒ String?

Normalizes the latest observed PBX queue state.

Parameters:

  • queue_id (Integer, String)

    Switchvox queue account id

Returns:

  • (String, nil)

    e.g. +logged_in+, +logged_out+, or +permanent+



418
419
420
421
422
423
# File 'app/models/employee_phone_status.rb', line 418

def (queue_id)
  raw_status = (queue_statuses || {})[queue_id.to_s]
  raw_status = raw_status['logged_in_status'] if raw_status.is_a?(Hash)
  raw_status = raw_status.logged_in_status if raw_status.respond_to?(:logged_in_status)
  raw_status.to_s.presence
end

#refresh_status_optionsObject

Update presence status list



440
441
442
443
444
445
446
# File 'app/models/employee_phone_status.rb', line 440

def refresh_status_options
  return unless  &&  > 0

  option_list = Phone::Pbx.instance.get_presence_options_list 
  update_attribute :status_options, option_list
  option_list
end

#refresh_switchboardvoid

This method returns an undefined value.

Delegates refresh when this employee enters the switchboard roster.



239
240
241
242
243
244
# File 'app/models/employee_phone_status.rb', line 239

def refresh_switchboard
  EmployeePhoneStatus::BroadcastSwitchboard.call(
    employee_phone_status: self,
    operation: :refresh
  )
end

#remove_switchboard_cardvoid

This method returns an undefined value.

Delegates removal when this employee leaves the switchboard roster.



219
220
221
222
223
224
# File 'app/models/employee_phone_status.rb', line 219

def remove_switchboard_card
  EmployeePhoneStatus::BroadcastSwitchboard.call(
    employee_phone_status: self,
    operation: :remove
  )
end

#replace_switchboard_cardvoid

This method returns an undefined value.

Delegates an unconditional card replacement for related visible changes.



229
230
231
232
233
234
# File 'app/models/employee_phone_status.rb', line 229

def replace_switchboard_card
  EmployeePhoneStatus::BroadcastSwitchboard.call(
    employee_phone_status: self,
    operation: :replace
  )
end

#should_be_in_queue?(presence: self.presence, sub_presence: self.sub_presence) ⇒ Boolean

Checks whether a presence accepts phone-queue calls.

Optional values let callers evaluate a proposed status without mutating
the record first.

Parameters:

  • presence (String, Symbol, nil) (defaults to: self.presence)

    primary presence to evaluate

  • sub_presence (String, Symbol, nil) (defaults to: self.sub_presence)

    secondary presence to evaluate

Returns:

  • (Boolean)

    true when the presence should be logged into queues



391
392
393
394
395
396
# File 'app/models/employee_phone_status.rb', line 391

def should_be_in_queue?(presence: self.presence, sub_presence: self.sub_presence)
  normalized_presence = presence.to_s.downcase.squish
  normalized_sub_presence = sub_presence.to_s.downcase.tr('_-', ' ').squish

  normalized_presence == 'available' && normalized_sub_presence != 'non queue'
end

#status_id_for_presenceObject

Retrieve the presence status id for a given presence and sub status
Using the cached status_options previously stored by refresh_status_options
WIll attempt to retrieve status options if attribute is blank



451
452
453
454
# File 'app/models/employee_phone_status.rb', line 451

def status_id_for_presence
  refresh_status_options if status_options.blank?
  curated_presence_list.find { |so| so[:presence].presence == presence.to_s.presence && so[:sub_presence].presence == sub_presence.to_s.presence }.try(:[], :id)
end

#sub_presence_optionsObject

Sub presence options.



379
380
381
# File 'app/models/employee_phone_status.rb', line 379

def sub_presence_options
  curated_presence_list.pluck(:sub_presence).compact
end

#time_to_set_away?Boolean

Checks if it's time to go on auto away

Returns:

  • (Boolean)


352
353
354
355
# File 'app/models/employee_phone_status.rb', line 352

def time_to_set_away?
  tod_time_now = Time.current.to_time_of_day
  auto_away_after and tod_time_now >= auto_away_after
end

#valid_presence_listObject

Filters out status options



457
458
459
460
461
462
463
# File 'app/models/employee_phone_status.rb', line 457

def valid_presence_list
  status_options.select do |so|
    so['presence'] == 'available' ||
      (so['presence'] == 'away' && so['sub_presence'].blank?) ||
      so['presence'] == 'dnd'
  end
end