Class: CallRecord

Inherits:
ApplicationRecord show all
Includes:
Models::Auditable, Models::Embeddable, PgSearch::Model
Defined in:
app/models/call_record.rb

Overview

Phone call records from the Switchvox phone system.
Stores call metadata, transcriptions, and AI analysis results.
rubocop:disable Metrics/ClassLength
== Schema Information

Table name: call_records
Database name: primary

id :integer not null, primary key
action_items :jsonb
agent_performance_score :integer
agent_speaker_label :string
ai_summary :text
audio_channels :integer
call_direction :enum
call_outcome :enum
call_phases :jsonb
customer_satisfaction :string
destination_name :string(255)
destination_number :string(255)
duration_secs :integer default(0)
imported :boolean
key_topics :jsonb
lemur_analyzed_at :datetime
note :string(255)
notes :text
origin_name :string(255)
origin_number :string(255)
recording_source :string
reference1 :string(255)
reference2 :string(255)
structured_transcript_json :jsonb
summarized_at :datetime
transcribed_at :datetime
transcript :text
transcription_state :enum default("pending")
tsvector_search_tsearch :tsvector
tsvector_transcript_tsearch :tsvector
twilio_call_details :jsonb
twilio_call_sid :string
twilio_recording_sid :string
unread :boolean default(FALSE), not null
unrestricted :boolean default(FALSE), not null
created_at :datetime not null
assemblyai_transcript_id :string
destination_party_id :integer
origin_party_id :integer
switchvox_from_account_id :integer
switchvox_recorded_call_id :integer
switchvox_to_account_id :integer

Indexes

idx_call_records_id_where_dp_id (id) WHERE (destination_party_id IS NULL)
idx_call_records_id_where_op_id (id) WHERE (origin_party_id IS NULL)
idx_origin_id_dest_id_created_at (origin_party_id,destination_party_id,created_at)
index_call_records_on_assemblyai_transcript_id (assemblyai_transcript_id) UNIQUE
index_call_records_on_created_at (created_at)
index_call_records_on_destination_party_id (destination_party_id)
index_call_records_on_switchvox_recorded_call_id (switchvox_recorded_call_id) UNIQUE
index_call_records_on_transcribed_at (transcribed_at)
index_call_records_on_transcription_state (transcription_state)
index_call_records_on_tsvector_search_tsearch (tsvector_search_tsearch) USING gin
index_call_records_on_tsvector_transcript_tsearch (tsvector_transcript_tsearch) USING gin
index_call_records_on_twilio_recording_sid (twilio_recording_sid) UNIQUE
index_call_records_on_unread (unread) WHERE (unread = true)

Constant Summary collapse

VOICEMAIL_EXTENSIONS =

Voicemail extensions.

%w[402].freeze
MIN_TRANSCRIPTION_DURATION =

Minimum transcription duration.

30
MIN_TRANSCRIPTION_DURATION_VOICEMAIL =

Minimum transcription duration voicemail.

5
SATISFACTION_LEVELS =

Customer satisfaction levels (stored as string, set by LeMUR analysis)

%w[very_satisfied satisfied neutral frustrated angry].freeze
SATISFACTION_DISPLAY =

Satisfaction display.

{
  'very_satisfied' => '😊 Very Satisfied',
  'satisfied' => '🙂 Satisfied',
  'neutral' => '😐 Neutral',
  'frustrated' => '😤 Frustrated',
  'angry' => '😠 Angry'
}.freeze
COMPANY_MAIN_NUMBERS =

Company main line numbers that should NOT be matched to parties
These are toll-free/main numbers that route to the IVR/queue, not individual employees

%w[
  +18008755285
  +18664361444
  +18475502400
].freeze

Constants included from Models::Embeddable

Models::Embeddable::MAX_CONTENT_LENGTH

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Has one collapse

Has many collapse

Methods included from Models::Embeddable

#content_embeddings

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::Embeddable

#embeddable_locales, #embedding_content_hash, #embedding_current?, #embedding_eligible?, embedding_partition_class, #embedding_stale?, #embedding_type_name, #embedding_vector, #find_content_embedding, #find_similar, #generate_all_embeddings!, #generate_chunked_embeddings!, #generate_embedding!, #has_embedding?, #locale_for_embedding, #needs_chunking?, regenerate_all_embeddings, semantic_search, with_shape_lock

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

Methods inherited from ApplicationRecord

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

#customer_satisfactionObject (readonly)

Validates customer satisfaction.

Validations:

  • Inclusion ({ in: SATISFACTION_LEVELS, allow_nil: true })


114
# File 'app/models/call_record.rb', line 114

validates :customer_satisfaction, inclusion: { in: SATISFACTION_LEVELS, allow_nil: true }

Class Method Details

.analyzedActiveRecord::Relation<CallRecord>

A relation of CallRecords that are analyzed. Active Record Scope

Returns:

See Also:



311
312
313
# File 'app/models/call_record.rb', line 311

scope :analyzed, ->(value = true) {
  value.to_s == 'true' ? where.not(lemur_analyzed_at: nil) : where(lemur_analyzed_at: nil)
}

.bpoActiveRecord::Relation<CallRecord>

A relation of CallRecords that are bpo. Active Record Scope

Returns:

See Also:



302
# File 'app/models/call_record.rb', line 302

scope :bpo, -> { where(recording_source: 'twilio').where("twilio_call_details @> ?", { bpo: true }.to_json) }

.by_recording_sourceActiveRecord::Relation<CallRecord>

A relation of CallRecords that are by recording source. Active Record Scope

Returns:

See Also:



287
288
289
290
291
292
293
294
295
296
297
# File 'app/models/call_record.rb', line 287

scope :by_recording_source, ->(source) {
  if source == 'switchvox'
    where(recording_source: [nil, 'switchvox'])
  elsif source == 'bpo'
    bpo
  elsif source.present?
    where(recording_source: source)
  else
    all
  end
}

.company_main_number?(number) ⇒ Boolean

Check if number is a company main line (should not be matched to a party)

Parameters:

  • number (Object)

    the number

Returns:

  • (Boolean)


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

def self.company_main_number?(number)
  return false if number.blank?

  COMPANY_MAIN_NUMBERS.include?(number)
end

.destination_filterActiveRecord::Relation<CallRecord>

A relation of CallRecords that are destination filter. Active Record Scope

Returns:

See Also:



239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'app/models/call_record.rb', line 239

scope :destination_filter, ->(*args) {
  ids = args.flatten.map(&:to_s).compact_blank
  include_null = ids.delete('null').present?
  integer_ids = ids.map(&:to_i).select(&:positive?)

  if include_null && integer_ids.any?
    where(destination_party_id: integer_ids).or(where(destination_party_id: nil))
  elsif include_null
    where(destination_party_id: nil)
  elsif integer_ids.any?
    where(destination_party_id: integer_ids)
  end
}

.distinct_key_topics(limit: 50) ⇒ Object

Get distinct key topics for filter dropdown

Parameters:

  • limit (Object) (defaults to: 50)

    the limit



326
327
328
329
330
331
332
333
# File 'app/models/call_record.rb', line 326

def self.distinct_key_topics(limit: 50)
  where.not(key_topics: nil)
       .pluck(Arel.sql('jsonb_array_elements_text(key_topics)'))
       .tally
       .sort_by { |_, count| -count }
       .first(limit)
       .map(&:first)
end

.embeddable_content_typesObject

Define content types for embedding



609
610
611
# File 'app/models/call_record.rb', line 609

def self.embeddable_content_types
  [:primary]
end

.find_party(number) ⇒ Object

Find party.

Parameters:

  • number (Object)

    the number



359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
# File 'app/models/call_record.rb', line 359

def self.find_party(number)
  return unless number
  return if company_main_number?(number) # Don't match main company lines

  parties = Party
            .joins(:contact_points)
            .active
            .select('parties.id,parties.full_name')
            .order('parties.updated_at DESC')
            .limit(1)

  parties = if internal_call?(number)
              parties.where(type: 'Employee')
                     .where('(contact_points.detail = :number or contact_points.extension = :number)', number: number)
            else
              parties.where(type: %w[Customer Contact])
                     .where(ContactPoint[:detail].eq(number))
            end
  party_type, party_id, party_full_name = parties.pick(:type, :id, :full_name)
  return unless party_id

  {
    party_type: party_type,
    party_id: party_id,
    party_full_name: party_full_name
  }
end

.for_destination_employeesActiveRecord::Relation<CallRecord>

A relation of CallRecords that are for destination employees. Active Record Scope

Returns:

See Also:



180
# File 'app/models/call_record.rb', line 180

scope :for_destination_employees, ->(employee_ids) { where(destination_party_id: employee_ids) }

.for_destination_employees_or_unlinkedActiveRecord::Relation<CallRecord>

A relation of CallRecords that are for destination employees or unlinked. Active Record Scope

Returns:

See Also:



194
195
196
# File 'app/models/call_record.rb', line 194

scope :for_destination_employees_or_unlinked, ->(employee_ids) {
  where(destination_party_id: employee_ids).or(where(destination_party_id: nil))
}

.for_partyActiveRecord::Relation<CallRecord>

A relation of CallRecords that are for party. Active Record Scope

Returns:

See Also:



184
185
186
187
188
189
190
191
192
# File 'app/models/call_record.rb', line 184

scope :for_party, ->(party_or_ids) {
  # Guard against nil/blank input — without this, Rails turns the OR
  # branches into `IS NULL` predicates, matching every unlinked call
  # record (calls where the origin or destination party is unknown).
  ids = Array.wrap(party_or_ids).compact.uniq
  next none if ids.empty?

  where.any_of({ origin_party_id: ids }, { destination_party_id: ids })
}

.for_unlinked_extensionsActiveRecord::Relation<CallRecord>

A relation of CallRecords that are for unlinked extensions. Active Record Scope

Returns:

See Also:



193
# File 'app/models/call_record.rb', line 193

scope :for_unlinked_extensions, -> { where(destination_party_id: nil) }

.internal_call?(number) ⇒ Boolean

Returns whether the record internal call.

Parameters:

  • number (Object)

    the number

Returns:

  • (Boolean)

    whether the record internal call



345
346
347
# File 'app/models/call_record.rb', line 345

def self.internal_call?(number)
  number && (number.length == 3 || number.match(/^\+184755024/).present?)
end

.mcp_searchable?Boolean

Privacy: Call records are NOT exposed via MCP (sensitive customer data)

Returns:

  • (Boolean)


614
615
616
# File 'app/models/call_record.rb', line 614

def self.mcp_searchable?
  false
end

.parse_phone_number(number) ⇒ Object

Parse phone number.

Parameters:

  • number (Object)

    the number



442
443
444
445
446
447
# File 'app/models/call_record.rb', line 442

def self.parse_phone_number(number)
  return if number.blank?

  n = number.match(/\d+/).to_s.match(/^9?1?(\d{10})$/).try(:[], 1) || number
  PhoneNumber.parse_and_format(n)
end

.party_recordsActiveRecord::Relation<CallRecord>

A relation of CallRecords that are party records. Active Record Scope

Returns:

See Also:



223
224
225
# File 'app/models/call_record.rb', line 223

scope :party_records, ->(party_id) {
  for_party([party_id] + Contact.where(customer_id: party_id).ids)
}

.ransackable_associations(_auth_object = nil) ⇒ Array<String>

Returns Ransack-allowlisted associations.

Parameters:

  • _auth_object (Object, nil) (defaults to: nil)

    the object being authorized (unused)

Returns:

  • (Array<String>)

    Ransack-allowlisted associations



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

def self.ransackable_associations(_auth_object = nil)
  %w[origin_party destination_party upload]
end

.ransackable_attributes(_auth_object = nil) ⇒ Array<String>

Returns Ransack-allowlisted attributes.

Parameters:

  • _auth_object (Object, nil) (defaults to: nil)

    the object being authorized (unused)

Returns:

  • (Array<String>)

    Ransack-allowlisted attributes



261
262
263
264
265
266
267
268
269
# File 'app/models/call_record.rb', line 261

def self.ransackable_attributes(_auth_object = nil)
  %w[
    id created_at origin_party_id destination_party_id origin_number destination_number
    origin_name destination_name duration_secs unrestricted unread
    transcription_state call_direction call_outcome customer_satisfaction
    agent_performance_score transcribed_at summarized_at lemur_analyzed_at
    recording_source audio_channels
  ]
end

.ransackable_scopes(_auth_object = nil) ⇒ Object

Ransack configuration for filtering

Parameters:

  • _auth_object (Object, nil) (defaults to: nil)

    the object being authorized (unused)



255
256
257
# File 'app/models/call_record.rb', line 255

def self.ransackable_scopes(_auth_object = nil)
  %i[search transcript_search with_key_topic analyzed with_action_items by_recording_source destination_filter]
end

.readActiveRecord::Relation<CallRecord>

A relation of CallRecords that are read. Active Record Scope

Returns:

See Also:



198
# File 'app/models/call_record.rb', line 198

scope :read, -> { where(unread: false) }

.recentActiveRecord::Relation<CallRecord>

A relation of CallRecords that are recent. Active Record Scope

Returns:

See Also:



178
# File 'app/models/call_record.rb', line 178

scope :recent, ->(days = 90) { where(call_records: { created_at: days.days.ago.. }) }

.transcription_eligibleActiveRecord::Relation<CallRecord>

A relation of CallRecords that are transcription eligible. Active Record Scope

Returns:

See Also:



142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'app/models/call_record.rb', line 142

scope :transcription_eligible, -> {
  joins(:upload)
    .where(transcription_state: %i[pending error])
    .where(
      'call_records.duration_secs >= :standard OR ' \
      '((call_records.call_outcome = :vm_outcome OR call_records.destination_number IN (:vm_exts)) ' \
      'AND call_records.duration_secs >= :vm_min)',
      standard: MIN_TRANSCRIPTION_DURATION,
      vm_outcome: call_outcomes[:voicemail],
      vm_exts: VOICEMAIL_EXTENSIONS,
      vm_min: MIN_TRANSCRIPTION_DURATION_VOICEMAIL
    )
}

.transcription_too_shortActiveRecord::Relation<CallRecord>

A relation of CallRecords that are transcription too short. Active Record Scope

Returns:

See Also:



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'app/models/call_record.rb', line 162

scope :transcription_too_short, -> {
  # COALESCE(..., FALSE) keeps a NULL destination_number/call_outcome from
  # poisoning the NOT (three-valued logic) and dropping the row — the inverse of
  # the OR-shaped eligible test, which tolerates that NULL.
  joins(:upload)
    .where(transcription_state: :pending)
    .where(
      'call_records.duration_secs < :standard AND ' \
      'NOT COALESCE((call_records.call_outcome = :vm_outcome OR call_records.destination_number IN (:vm_exts)) ' \
      'AND call_records.duration_secs >= :vm_min, FALSE)',
      standard: MIN_TRANSCRIPTION_DURATION,
      vm_outcome: call_outcomes[:voicemail],
      vm_exts: VOICEMAIL_EXTENSIONS,
      vm_min: MIN_TRANSCRIPTION_DURATION_VOICEMAIL
    )
}

.unreadActiveRecord::Relation<CallRecord>

A relation of CallRecords that are unread. Active Record Scope

Returns:

See Also:



197
# File 'app/models/call_record.rb', line 197

scope :unread, -> { where(unread: true) }

.voicemailsActiveRecord::Relation<CallRecord>

A relation of CallRecords that are voicemails. Active Record Scope

Returns:

See Also:



179
# File 'app/models/call_record.rb', line 179

scope :voicemails, -> { where(call_outcome: :voicemail) }

.with_action_itemsActiveRecord::Relation<CallRecord>

A relation of CallRecords that are with action items. Active Record Scope

Returns:

See Also:



316
317
318
319
320
321
322
# File 'app/models/call_record.rb', line 316

scope :with_action_items, ->(value = true) {
  if value.to_s == 'true'
    where('action_items IS NOT NULL AND jsonb_array_length(action_items) > 0')
  else
    where('action_items IS NULL OR jsonb_array_length(action_items) = 0')
  end
}

.with_key_topicActiveRecord::Relation<CallRecord>

A relation of CallRecords that are with key topic. Active Record Scope

Returns:

See Also:



278
# File 'app/models/call_record.rb', line 278

scope :with_key_topic, ->(topic) { where('key_topics @> ?', [topic].to_json) }

.with_transcriptActiveRecord::Relation<CallRecord>

A relation of CallRecords that are with transcript. Active Record Scope

Returns:

See Also:



141
# File 'app/models/call_record.rb', line 141

scope :with_transcript, -> { where.not(transcript: nil).where.not(transcript: '') }

Instance Method Details

#action_items?Boolean

Check if there are action items

Returns:

  • (Boolean)


480
481
482
# File 'app/models/call_record.rb', line 480

def action_items?
  action_items.present?
end

#action_items_task_summaryObject

Single-line task text for prompts / embeddings. Handles JSON where task is a String,
an Array of strings, or other nested shapes (avoids calling String#truncate on an Array).



493
494
495
496
497
498
499
500
501
502
503
504
505
506
# File 'app/models/call_record.rb', line 493

def action_items_task_summary
  return +'' if action_items.blank?

  Array(action_items).flat_map do |item|
    next [] unless item.is_a?(Hash)

    task_val = if item.key?('task')
                 item['task']
               elsif item.key?(:task)
                 item[:task]
               end
    extract_action_item_task_strings(task_val)
  end.compact_blank.join('; ')
end

#activitiesActiveRecord::Relation<Activity>

Returns the associated activities.

Returns:

  • (ActiveRecord::Relation<Activity>)

    the associated activities



121
# File 'app/models/call_record.rb', line 121

has_many :activities, dependent: :nullify, inverse_of: :call_record

#agent_improvementsObject

Get agent's improvement areas from analysis



514
515
516
# File 'app/models/call_record.rb', line 514

def agent_improvements
  structured_transcript_json&.dig('agent_performance', 'improvements') || []
end

#agent_strengthsObject

Get agent's strengths from analysis



509
510
511
# File 'app/models/call_record.rb', line 509

def agent_strengths
  structured_transcript_json&.dig('agent_performance', 'strengths') || []
end

#bpo?Boolean

Cheap per-record check for badges: the import flag, or (legacy unflagged
records) the terminating leg to the BPO cascade number.

Returns:

  • (Boolean)


306
307
308
# File 'app/models/call_record.rb', line 306

def bpo?
  recording_source == 'twilio' && (twilio_bpo? || Phone::Bpo.bpo_number?(destination_number))
end

#call_logObject

private



540
541
542
# File 'app/models/call_record.rb', line 540

def call_log
  call_log_event.try(:call_log)
end

#call_log_eventCallLogEvent?

Returns the associated call log event.

Returns:



119
# File 'app/models/call_record.rb', line 119

has_one :call_log_event, dependent: :nullify

#call_record_embeddingsActiveRecord::Relation<ContentEmbedding::CallRecordEmbedding>

Direct association to partitioned embeddings table (more efficient than polymorphic)

Returns:

See Also:



124
125
126
127
128
# File 'app/models/call_record.rb', line 124

has_many :call_record_embeddings,
class_name: 'ContentEmbedding::CallRecordEmbedding',
foreign_key: :embeddable_id,
inverse_of: :embeddable,
dependent: false

#content_for_embedding(_content_type = :primary) ⇒ Object

Generate content for embedding - includes call metadata, transcript, and summary

Parameters:

  • _content_type (Object) (defaults to: :primary)

    the content type



620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
# File 'app/models/call_record.rb', line 620

def content_for_embedding(_content_type = :primary)
  parts = []

  # Call metadata
  parts << "Phone call from #{origin_name.presence || origin_number} to #{destination_name.presence || destination_number}"
  parts << "Duration: #{duration_friendly}"
  parts << "Date: #{created_at.strftime('%B %d, %Y at %I:%M %p')}"
  parts << "Direction: #{call_direction}" if call_direction.present?
  parts << "Outcome: #{call_outcome}" if call_outcome.present? && call_outcome != 'unknown'
  parts << "Customer satisfaction: #{customer_satisfaction}" if customer_satisfaction.present?

  # Customer context if available
  if origin_party.is_a?(Customer)
    parts << "Customer: #{origin_party.full_name}"
  elsif destination_party.is_a?(Customer)
    parts << "Customer: #{destination_party.full_name}"
  end

  # Key topics (from LeMUR analysis)
  parts << "Topics discussed: #{key_topics.join(', ')}" if key_topics.present?

  # AI Summary
  parts << "\nSummary: #{ai_summary}" if ai_summary.present?

  # Action items (for searchability)
  summary = action_items_task_summary
  parts << "\nAction items: #{summary}" if summary.present?

  # Transcript (main searchable content - truncate for embedding efficiency)
  if transcript.present?
    # Limit transcript to ~8000 chars to keep embedding focused on key content
    parts << "\nTranscript:\n#{transcript.truncate(8000, omission: '...[transcript continues]')}"
  end

  parts.compact.join("\n")
end

#destination_partyParty?

Returns the destination party this record belongs to.

Returns:

  • (Party, nil)

    the destination party this record belongs to

Validations:

  • Presence ({ if: -> { destination_party_id.present? } })


133
# File 'app/models/call_record.rb', line 133

belongs_to :destination_party, class_name: 'Party', inverse_of: :destination_call_records, optional: true

#duration_friendlyObject

Duration friendly.



450
451
452
# File 'app/models/call_record.rb', line 450

def duration_friendly
  Heatwave::Duration.humanize(duration_secs || 0)
end

#enqueue_crm_navbar_voicemail_refreshObject

Only enqueue when the change can affect the navbar badge — i.e. a
voicemail's unread state flipped, or a brand-new unread voicemail
arrived. Non-voicemail calls (sales, support, etc.) are noisy but not
badged.



413
414
415
416
417
418
419
420
421
422
423
# File 'app/models/call_record.rb', line 413

def enqueue_crm_navbar_voicemail_refresh
  return unless voicemail?

  if previously_new_record?
    return unless unread?
  elsif !saved_change_to_unread?
    return
  end

  CrmNavbarFanoutWorker.perform_async('voicemail')
end

#high_priority_action_itemsObject

Get high priority action items



485
486
487
488
489
# File 'app/models/call_record.rb', line 485

def high_priority_action_items
  return [] if action_items.blank?

  action_items.select { |item| item['priority'] == 'high' }
end

#lemur_analyzed?Boolean

Check if LeMUR analysis has been run

Returns:

  • (Boolean)


459
460
461
# File 'app/models/call_record.rb', line 459

def lemur_analyzed?
  lemur_analyzed_at.present?
end

#mark_as_read!Object

Mark as read, raising on failure.



426
427
428
# File 'app/models/call_record.rb', line 426

def mark_as_read!
  update_column(:unread, false) if unread?
end

#mark_as_unread!Object

Mark as unread, raising on failure.



431
432
433
# File 'app/models/call_record.rb', line 431

def mark_as_unread!
  update_column(:unread, true) unless unread?
end

#match_destination_party(force: false) ⇒ Object

Method to match destination party based on switchvox acount id if specified or lookup on destination number

Parameters:

  • force (Object) (defaults to: false)

    the force



591
592
593
594
595
596
597
598
599
600
601
602
603
# File 'app/models/call_record.rb', line 591

def match_destination_party(force: false)
  return :already_matched if destination_party_id && !force # already matched

  if .present? && (eps = EmployeePhoneStatus.find_by(switchvox_account_id: ))
    self.destination_name = eps.employee.full_name
    self.destination_party = eps.employee
  elsif destination_number && (match = self.class.find_party(destination_number))
    self.destination_name = match[:party_full_name]
    self.destination_party_id = match[:party_id]
  end
  logger&.info "Looking for destination party for call record #{id} for #{destination_number}, found #{destination_party_id || 'None'} #{destination_party.try(:full_name)}"
  destination_party_id
end

#match_origin_party(force: false) ⇒ Object

Method to match origin party based on switchvox acount id if specified or lookup on origin number

Parameters:

  • force (Object) (defaults to: false)

    the force



575
576
577
578
579
580
581
582
583
584
585
586
587
# File 'app/models/call_record.rb', line 575

def match_origin_party(force: false)
  return :already_matched if origin_party_id && !force # already matched

  if .present? && (eps = EmployeePhoneStatus.find_by(switchvox_account_id: ))
    self.origin_name = eps.employee.full_name if origin_name.blank?
    self.origin_party = eps.employee
  elsif origin_number && (match = self.class.find_party(origin_number))
    self.origin_name = match[:party_full_name] if origin_name.blank?
    self.origin_party_id = match[:party_id]
  end
  logger&.info "Looking for originating party for call record #{id} for #{origin_number}, found #{origin_party_id || 'None'} #{origin_party.try(:full_name)}"
  origin_party_id
end

#match_parties(force: false) ⇒ Object

Match parties.

Parameters:

  • force (Object) (defaults to: false)

    the force



568
569
570
571
# File 'app/models/call_record.rb', line 568

def match_parties(force: false)
  match_origin_party(force: force)
  match_destination_party(force: force)
end

#origin_partyParty?

Returns the origin party this record belongs to.

Returns:

  • (Party, nil)

    the origin party this record belongs to

Validations:

  • Presence ({ if: -> { origin_party_id.present? } })


131
# File 'app/models/call_record.rb', line 131

belongs_to :origin_party, class_name: 'Party', inverse_of: :origin_call_records, optional: true

#performance_score_displayObject

Get performance score display with color



519
520
521
522
523
524
525
526
527
528
529
530
531
532
# File 'app/models/call_record.rb', line 519

def performance_score_display
  return nil if agent_performance_score.blank?

  score = agent_performance_score
  if score >= 80
    { score: score, class: 'text-success', label: 'Excellent' }
  elsif score >= 60
    { score: score, class: 'text-primary', label: 'Good' }
  elsif score >= 40
    { score: score, class: 'text-warning', label: 'Needs Improvement' }
  else
    { score: score, class: 'text-danger', label: 'Poor' }
  end
end

#satisfaction_badge_classObject

Get satisfaction CSS class for styling



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

def satisfaction_badge_class
  case customer_satisfaction
  when 'very_satisfied', 'satisfied' then 'bg-success'
  when 'neutral' then 'bg-secondary'
  when 'frustrated', 'angry' then 'bg-danger'
  else
    'bg-light text-dark'
  end
end

#satisfaction_displayObject

Get formatted satisfaction with emoji



464
465
466
# File 'app/models/call_record.rb', line 464

def satisfaction_display
  SATISFACTION_DISPLAY[customer_satisfaction]
end

#to_sString

Returns string representation of the record.

Returns:

  • (String)

    string representation of the record



535
536
537
# File 'app/models/call_record.rb', line 535

def to_s
  "CallRecord: #{id}, From #{origin_number} to #{destination_number} at #{created_at}"
end

#transcription_min_durationObject

Transcription min duration.



436
437
438
# File 'app/models/call_record.rb', line 436

def transcription_min_duration
  voicemail? ? MIN_TRANSCRIPTION_DURATION_VOICEMAIL : MIN_TRANSCRIPTION_DURATION
end

#trim_numbersObject

Trim numbers.



545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
# File 'app/models/call_record.rb', line 545

def trim_numbers
  # North American Match 10 digits, strip 1 strip 9
  if (n = self.class.parse_phone_number(origin_number))
    self.origin_number = n
  end

  if (n = self.class.parse_phone_number(destination_number))
    self.destination_number = n
  end

  # Drop blank/zero Switchvox account ids. Each side is checked against ITSELF:
  # the `to` line used to test `from` (typo), which nulled the agent's
  # to_account_id on every inbound call (external caller => blank from_account_id)
  # — ~29% of recordings — breaking account-id-based matching and agent
  # attribution for those calls.
  self. = nil if .to_i.zero?
  self. = nil if .to_i.zero?
  true
end

#uploadUpload?

Returns the associated upload.

Returns:

  • (Upload, nil)

    the associated upload



117
# File 'app/models/call_record.rb', line 117

has_one :upload, as: :resource, dependent: :destroy

#voicemail?Boolean

Returns whether the record voicemail.

Returns:

  • (Boolean)

    whether the record voicemail



388
389
390
# File 'app/models/call_record.rb', line 388

def voicemail?
  call_outcome_voicemail? || destination_number.to_s.in?(VOICEMAIL_EXTENSIONS)
end

#voicemail_processing_enabled?Boolean

Whether automatic voicemail follow-up should run for this call — the
matched-caller follow-up Activity and the transcript-driven contact
enrichment in CallRecordProcessing::TranscriptionService. Honors the
destination employee's +process_voicemails+ opt-out (default on); defaults
to enabled when the destination is not a preference-bearing employee.

The manual "Create Customer" button is intentionally not gated by this —
an employee acting on a voicemail by hand always logs the activity.

Returns:

  • (Boolean)


402
403
404
405
406
407
# File 'app/models/call_record.rb', line 402

def voicemail_processing_enabled?
  employee = destination_party
  return true unless employee.is_a?(Employee)

  employee.process_voicemails? != false
end