Class: SmsMessage

Inherits:
ApplicationRecord show all
Includes:
Models::Auditable, Models::EventPublishable
Defined in:
app/models/sms_message.rb

Overview

rubocop:disable Metrics/ClassLength
== Schema Information

Table name: sms_messages
Database name: primary

id :integer not null, primary key
autoreplied_at :datetime
body :text
direction :enum default("inbound"), not null
num_media :integer default(0), not null
raw_data :jsonb
recipient :string not null
sender :string not null
state :string default("draft"), not null
status_message :string
transmit_at :datetime
unread :boolean default(TRUE), not null
created_at :datetime not null
updated_at :datetime not null
creator_id :integer
recipient_party_id :integer
sender_party_id :integer
unique_id :string
updater_id :integer

Indexes

idx_direction_recipient (direction,recipient)
index_sms_messages_on_recipient (recipient)
index_sms_messages_on_recipient_party_id (recipient_party_id)
index_sms_messages_on_sender (sender)
index_sms_messages_on_sender_party_id (sender_party_id)
index_sms_messages_on_state (state)
index_sms_messages_on_transmit_at (transmit_at)
sms_messages_unique_id (unique_id) UNIQUE

Foreign Keys

fk_rails_... (recipient_party_id => parties.id)
fk_rails_... (sender_party_id => parties.id)

Constant Summary collapse

SMS_GLOBAL_NUMBERS =

Sms global numbers.

['+18008755285', '+18475407775', '+18473183355', '+18473501641',
'+18473501655', '+18473489826', '+18473507429', '+18473501980',
'+18472629814', '+18473507262', '+18473507212'].freeze

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Schedulable

Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Has one collapse

Has and belongs to many collapse

Delegated Instance Attributes collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::EventPublishable

#publish_event

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, ransortable_attributes, #to_relation

Methods included from Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Instance Attribute Details

#bodyObject (readonly)



86
# File 'app/models/sms_message.rb', line 86

validates :body, length: { maximum: 1600 }, if: :outbound?

#directionObject (readonly)



85
# File 'app/models/sms_message.rb', line 85

validates :sender, :recipient, :direction, presence: true

#recipientObject (readonly)



85
# File 'app/models/sms_message.rb', line 85

validates :sender, :recipient, :direction, presence: true

#senderObject (readonly)



85
# File 'app/models/sms_message.rb', line 85

validates :sender, :recipient, :direction, presence: true

Class Method Details

.between_participantsActiveRecord::Relation<SmsMessage>

A relation of SmsMessages that are between participants. Active Record Scope

Returns:

See Also:



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'app/models/sms_message.rb', line 96

scope :between_participants, ->(*numbers) {
  number1, number2 = [numbers].flatten.filter_map(&:presence).filter_map { |n| PhoneNumber.parse_and_format(n) }
  # Two numbers specified (or more but we ignore anything beyond 2) then let's pull only those
  # conversations
  if number1 && number2
    where('
      (sms_messages.sender = :number1 OR sms_messages.recipient = :number1)
      AND
      (sms_messages.sender = :number2 OR sms_messages.recipient = :number2)
    ', number1:, number2:)
  # One number? a simple match for both
  elsif (number = number1 || number2)
    for_numbers(number)
  else
    SmsMessage.none
  end
}

.for_employee_idsActiveRecord::Relation<SmsMessage>

A relation of SmsMessages that are for employee ids. Active Record Scope

Returns:

See Also:



88
89
90
91
92
# File 'app/models/sms_message.rb', line 88

scope :for_employee_ids, ->(*rep_ids, include_global: true) {
  sms_numbers = Employee.all_active_employees_sms_numbers(rep_ids:)
  sms_numbers += SMS_GLOBAL_NUMBERS if include_global
  for_numbers(sms_numbers)
}

.for_numbersActiveRecord::Relation<SmsMessage>

A relation of SmsMessages that are for numbers. Active Record Scope

Returns:

See Also:



93
94
95
# File 'app/models/sms_message.rb', line 93

scope :for_numbers, ->(*numbers) {
  where('sms_messages.sender IN (:numbers) OR sms_messages.recipient IN (:numbers)', numbers: [numbers].flatten.compact.uniq)
}

.global_numbers_for_selectObject



319
320
321
# File 'app/models/sms_message.rb', line 319

def self.global_numbers_for_select
  SMS_GLOBAL_NUMBERS.map { |n| [n, n] }
end

.match_inbound_unknown_senders(numbers: nil, party: nil, date_range: nil) ⇒ Object

Bulk inbound sender match



467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
# File 'app/models/sms_message.rb', line 467

def self.match_inbound_unknown_senders(numbers: nil, party: nil, date_range: nil)
  messages = SmsMessage.inbound.where(sender_party_id: nil)
  messages = messages.where(created_at: date_range) if date_range
  messages = messages.where(sender: numbers) if numbers.present?
  messages.each do |m|
    # party specified if any
    m.sender_party = party
    # Attempt a general lookup, active contacts first
    m.sender_party ||= parties_matching_number(m.sender).active.find_by(type: 'Contact')
    # Active customer next
    m.sender_party ||= parties_matching_number(m.sender).active.find_by(type: 'Customer')
    # Inactive contact next
    m.sender_party ||= parties_matching_number(m.sender).inactive.find_by(type: 'Contact')
    # Inactive customer finally
    m.sender_party ||= parties_matching_number(m.sender).inactive.find_by(type: 'Customer')
    m.save if m.sender_party
  end
end

.match_outbound_unknown_recipient(numbers: nil, party: nil, date_range: nil) ⇒ Object



486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
# File 'app/models/sms_message.rb', line 486

def self.match_outbound_unknown_recipient(numbers: nil, party: nil, date_range: nil)
  messages = SmsMessage.outbound.where(recipient_party_id: nil)
  messages = messages.where(created_at: date_range) if date_range
  messages = messages.where(recipient: numbers) if numbers.present?
  messages.each do |m|
    # party specified if any
    m.recipient_party = party
    # Attempt a general lookup, active contacts first
    m.recipient_party ||= parties_matching_number(m.recipient).active.where(type: 'Contact').first
    # Active customer next
    m.recipient_party ||= parties_matching_number(m.recipient).active.where(type: 'Customer').first
    # Inactive contact next
    m.recipient_party ||= parties_matching_number(m.recipient).inactive.where(type: 'Contact').first
    # Inactive customer finally
    m.recipient_party ||= parties_matching_number(m.recipient).inactive.where(type: 'Customer').first
    m.save if m.recipient_party
  end
end

.match_unknown(**args) ⇒ Object



461
462
463
464
# File 'app/models/sms_message.rb', line 461

def self.match_unknown(**args)
  match_inbound_unknown_senders(**args)
  match_outbound_unknown_recipient(**args)
end

.parties_matching_number(number) ⇒ Object



505
506
507
508
509
# File 'app/models/sms_message.rb', line 505

def self.parties_matching_number(number)
  return Party.none if number.blank?

  Party.joins(:contact_points).where(ContactPoint[:detail].eq(number))
end

.ransackable_scopes(_auth_object = nil) ⇒ Object



122
123
124
# File 'app/models/sms_message.rb', line 122

def self.ransackable_scopes(_auth_object = nil)
  [:between_participants]
end

.unreadActiveRecord::Relation<SmsMessage>

A relation of SmsMessages that are unread. Active Record Scope

Returns:

See Also:



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

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

.update_status(params) ⇒ Object

Processes events coming in from the twilio gateway basically discover
the message that was sent and update its status



392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
# File 'app/models/sms_message.rb', line 392

def self.update_status(params)
  return false if params.blank?
  return false unless (sid = params['SmsSid'].presence)
  return false unless (msg = SmsMessage.outbound.where(unique_id: sid).first)

  msg.raw_data = params.except(:action, :controller)
  case params['SmsStatus']
  when 'delivered'
    msg.trigger_delivered
  when 'failed', 'undelivered'
    msg.trigger_exception
  else
    msg.save
  end
  true
end

Instance Method Details



378
379
380
# File 'app/models/sms_message.rb', line 378

def all_related_sms_messages
  SmsMessage.where('sms_messages.sender IN (:participants) OR sms_messages.recipient IN (:participants)', participants: [sender, recipient]).order('sms_messages.created_at DESC')
end

#auto_reply_outside_business_hoursObject



280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'app/models/sms_message.rb', line 280

def auto_reply_outside_business_hours
  # Only send this auto reply if we are outside business hours, we have not responded yet, and there's been no conversation within the last 24 hours
  return unless received_outside_business_hours? && autoreplied_at.nil? && !messages_in_conversation.where(transmit_at: 1.hour.ago..).exists?

  # Skip auto-reply to shortcodes (5-6 digit numbers used by businesses like Amazon)
  # We cannot send SMS to shortcodes, only receive from them
  return if PhoneNumber.shortcode?(sender)

  body = 'Thank you for your message. We will get back to you during business hours, Mon-Fri from 7:30am to 5:30pm CST'
  begin
    TwilioClient.send_message(from: recipient, to: sender, body:)
    update(autoreplied_at: Time.current)
  rescue Twilio::REST::RestError => e
    # Error 21610: Recipient has unsubscribed (opted out of SMS)
    # This is expected behavior - they texted us but have opted out of receiving replies
    # Log it but don't raise an exception
    raise unless e.code == 21_610

    Rails.logger.info "[SmsMessage] Auto-reply skipped - recipient #{sender} has unsubscribed from SMS"
  end
end

#block_senderObject



228
229
230
231
232
233
# File 'app/models/sms_message.rb', line 228

def block_sender
  return false unless inbound?
  return true if sender_sms_block

  build_sender_sms_block.save
end

#conversation_idObject



268
269
270
# File 'app/models/sms_message.rb', line 268

def conversation_id
  [sender, recipient].sort
end

#create_media_urlsObject



257
258
259
260
261
262
263
264
265
266
# File 'app/models/sms_message.rb', line 257

def create_media_urls
  twilio_config = Heatwave::Configuration.fetch(:twilio)
  base_url = twilio_config[:sms_base_wehook_url]
  raise 'Unable to configure base URL for media download' unless base_url

  uploads.map do |upload|
    token = upload.active_download_token
    "#{base_url}/media_download/#{token}"
  end.presence
end

#deep_dupObject



126
127
128
129
130
131
132
133
# File 'app/models/sms_message.rb', line 126

def deep_dup
  deep_clone(
    include: :uploads,
    except: %i[unique_id transmit_at created_at updated_at]
  ) do |_original, copy|
    copy.state = 'draft' if copy.is_a?(SmsMessage)
  end
end

#editable?Boolean

Returns:

  • (Boolean)


386
387
388
# File 'app/models/sms_message.rb', line 386

def editable?
  draft? && outbound?
end

#enqueue_crm_navbar_refreshObject

Only enqueue when the change can affect the navbar badge — i.e. the
inbound/unread tuple. Skips chatty outbound updates (state machine churn,
delivery callbacks) and any inbound update that doesn't move unread.



518
519
520
521
522
523
524
525
526
527
528
# File 'app/models/sms_message.rb', line 518

def enqueue_crm_navbar_refresh
  return unless inbound?

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

  CrmNavbarFanoutWorker.perform_async('sms')
end

#fetch_mediaObject



339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'app/models/sms_message.rb', line 339

def fetch_media
  return true unless num_media.positive?

  begin
    self.raw_data ||= {}
    (0..(num_media - 1)).to_a.each do |i|
      media_content_type = raw_data["MediaContentType#{i}"]
      media_url = raw_data["MediaUrl#{i}"]
      next unless media_url

      uri = Addressable::URI.parse(media_url)
      uri.user = Heatwave::Configuration.fetch(:twilio, :account_sid)
      uri.password = Heatwave::Configuration.fetch(:twilio, :auth_token)

      ext = Mime::Type.lookup(media_content_type).symbol.to_s
      upload = Upload.uploadify_from_url(file_name: "attachment_#{i}.#{ext}",
                                         url: uri.to_s,
                                         category: nil,
                                         resource: self)
      uploads << upload
    end
    true
  rescue Down::NotFound => e
    # Twilio media URLs are temporary and may expire - this is expected behavior
    self.status_message = e.to_s
    ErrorReporting.warning(e, reason: 'twilio_media_expired', sms_message_id: id)
    false
  rescue Down::ClientError => e
    # Handle 401/403 auth errors - media may have been deleted or access revoked
    self.status_message = e.to_s
    ErrorReporting.warning(e, reason: 'twilio_media_access_denied', sms_message_id: id)
    false
  rescue StandardError => e
    self.status_message = e.to_s
    ErrorReporting.error(e)
    false
  end
end

#from_country_isoObject



335
336
337
# File 'app/models/sms_message.rb', line 335

def from_country_iso
  raw_data['FromCountry'].presence || PhoneNumber.parse(sender).country
end

#increment_publication_countersObject

Increment the counter of our publications, sort of a way to keep track of popularity



551
552
553
# File 'app/models/sms_message.rb', line 551

def increment_publication_counters
  publications.each { |p| p.increment!(:requested_counter) }
end

#literaturesObject

Alias for Uploads#literatures

Returns:

  • (Object)

    Uploads#literatures

See Also:



544
# File 'app/models/sms_message.rb', line 544

delegate :literatures, to: :uploads

#mark_external_numbers_as_sms_enabledObject



511
512
513
# File 'app/models/sms_message.rb', line 511

def mark_external_numbers_as_sms_enabled
  ContactPoint.dialable.where('contact_points.detail = :sender OR contact_points.detail = :recipient', sender:, recipient:).sms_none.each(&:sms_enabled!)
end

#match_inbound_recipientObject



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

def match_inbound_recipient
  return unless inbound? && recipient_party.nil?

  # We match by employee phone status which is an absolute unique number
  employee = Employee.joins(:contact_points, :employee_phone_status).active_employees.find_by(ContactPoint[:detail].eq(recipient))
  return unless employee

  self.recipient_party = employee
end

#match_inbound_senderObject



445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
# File 'app/models/sms_message.rb', line 445

def match_inbound_sender
  return unless inbound?

  # To attempt an accurate match, we will first see if we find a party to an outbound conversation
  prev_message = SmsMessage.outbound.order(created_at: :desc).where.not(recipient_party_id: nil).find_by(recipient: sender)
  self.sender_party ||= prev_message.recipient_party if prev_message
  # Attempt a general lookup, active contacts first
  self.sender_party ||= self.class.parties_matching_number(sender).active.order(created_at: :desc).find_by(type: 'Contact')
  # Active customer next
  self.sender_party ||= self.class.parties_matching_number(sender).active.order(created_at: :desc).find_by(type: 'Customer')
  # Inactive contact next
  self.sender_party ||= self.class.parties_matching_number(sender).inactive.order(created_at: :desc).find_by(type: 'Contact')
  # Inactive customer finally
  self.sender_party ||= self.class.parties_matching_number(sender).inactive.order(created_at: :desc).find_by(type: 'Customer')
end

#match_outbound_recipientObject



432
433
434
435
436
437
438
439
440
441
442
443
# File 'app/models/sms_message.rb', line 432

def match_outbound_recipient
  return unless outbound?

  # Attempt a general lookup, active contacts first
  self.recipient_party ||= self.class.parties_matching_number(recipient).order(created_at: :desc).active.find_by(type: 'Contact')
  # Active customer next
  self.recipient_party ||= self.class.parties_matching_number(recipient).order(created_at: :desc).active.find_by(type: 'Customer')
  # Inactive contact next
  self.recipient_party ||= self.class.parties_matching_number(recipient).order(created_at: :desc).inactive.find_by(type: 'Contact')
  # Inactive customer finally
  self.recipient_party ||= self.class.parties_matching_number(recipient).order(created_at: :desc).inactive.find_by(type: 'Customer')
end

#match_outbound_senderObject



423
424
425
426
427
428
429
430
# File 'app/models/sms_message.rb', line 423

def match_outbound_sender
  return unless outbound? && sender_party.nil?

  employee = Employee.joins(:contact_points, :employee_phone_status).active_employees.find_by(ContactPoint[:detail].eq(sender))
  return unless employee

  self.sender_party = employee
end

#messages_in_conversationObject



272
273
274
# File 'app/models/sms_message.rb', line 272

def messages_in_conversation
  self.class.between_participants(sender, recipient)
end

#no_media?Boolean

Returns:

  • (Boolean)


409
410
411
# File 'app/models/sms_message.rb', line 409

def no_media?
  uploads.blank?
end

#publicationsObject



546
547
548
# File 'app/models/sms_message.rb', line 546

def publications
  Item.joins(:literature).merge(literatures)
end

#receive_messageObject



302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
# File 'app/models/sms_message.rb', line 302

def receive_message
  return false unless can_trigger_received?

  # Since we received an SMS from a number, we can therefore mark those sms as enabled
  mark_external_numbers_as_sms_enabled
  auto_reply_outside_business_hours
  # We do a recipient party match on the local number, basically an employee
  match_inbound_recipient
  # Try a match
  match_inbound_sender
  if fetch_media
    trigger_received
  else
    trigger_exception
  end
end

#received_outside_business_hours?Boolean

Returns:

  • (Boolean)


276
277
278
# File 'app/models/sms_message.rb', line 276

def received_outside_business_hours?
  !WorkingHours.in_working_hours?(created_at)
end

#recipient_do_not_textDoNotCall

Returns:

See Also:



69
# File 'app/models/sms_message.rb', line 69

has_one :recipient_do_not_text, -> { where(do_not_text: true) }, class_name: 'DoNotCall', primary_key: :recipient, foreign_key: :number

#recipient_partyParty

Returns:

See Also:



66
# File 'app/models/sms_message.rb', line 66

belongs_to :recipient_party, class_name: 'Party', inverse_of: :recipient_sms_messages, optional: true

#recipient_sms_blockSmsBlock

Returns:

See Also:



68
# File 'app/models/sms_message.rb', line 68

has_one :recipient_sms_block, class_name: 'SmsBlock', primary_key: :recipient, foreign_key: :number

#recipient_to_sObject



329
330
331
332
333
# File 'app/models/sms_message.rb', line 329

def recipient_to_s
  r = PhoneNumber.parse_and_format(recipient).to_s
  r << " (#{recipient_party.full_name})" if recipient_party
  r
end

#send_messageObject



243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'app/models/sms_message.rb', line 243

def send_message
  return false unless valid? && can_trigger_send?

  match_outbound_sender
  match_outbound_recipient
  mark_external_numbers_as_sms_enabled
  # Build media urls
  # Create a public url for these files
  media_urls = create_media_urls
  res = TwilioClient.send_message(from: sender, to: recipient, body:, media_urls:)
  self.unique_id = res.sid
  trigger_send
end

#sender_blocked?Boolean

Returns:

  • (Boolean)


221
222
223
224
225
226
# File 'app/models/sms_message.rb', line 221

def sender_blocked?
  return false unless inbound?

  # sender_sms_block.present?  Until Rails 7.1 there is no way to reset_ on has-one/belongs to so this would be cached and undesirable
  SmsBlock.where(number: sender).exists?
end

#sender_do_not_textDoNotCall

Returns:

See Also:



70
# File 'app/models/sms_message.rb', line 70

has_one :sender_do_not_text, -> { where(do_not_text: true) }, class_name: 'DoNotCall', primary_key: :sender, foreign_key: :number

#sender_partyParty

Returns:

See Also:



65
# File 'app/models/sms_message.rb', line 65

belongs_to :sender_party, class_name: 'Party', inverse_of: :sender_sms_messages, optional: true

#sender_sms_blockSmsBlock

Returns:

See Also:



67
# File 'app/models/sms_message.rb', line 67

has_one :sender_sms_block, class_name: 'SmsBlock', primary_key: :sender, foreign_key: :number

#sender_to_sObject



323
324
325
326
327
# File 'app/models/sms_message.rb', line 323

def sender_to_s
  r = PhoneNumber.parse_and_format(sender).to_s
  r << " (#{sender_party.full_name})" if sender_party
  r
end

#set_defaultsObject



530
531
532
533
534
535
536
537
538
539
540
541
# File 'app/models/sms_message.rb', line 530

def set_defaults
  self.unread = false if outbound?
  # Try to cleanup sender and recipient numbers
  if (sn = PhoneNumber.parse_and_format(sender)).present?
    self.sender = sn
  end
  if (rn = PhoneNumber.parse_and_format(recipient)).present?
    self.recipient = rn
  end

  :continue
end

#to_partial_pathObject

The partial for this model lives under app/views/crm/sms_messages/,
not the conventional app/views/sms_messages/. Without this override
render @sms_messages from any non-crm/sms_messages controller
(CustomersController#tab_sms, OrdersController#tab_sms, …) resolves
the derived sms_messages/sms_message path against the calling
controller's view dir and raises ActionView::MissingTemplate.
See AppSignal #5422 / #5532.



56
57
58
# File 'app/models/sms_message.rb', line 56

def to_partial_path
  'crm/sms_messages/sms_message'
end

#to_sObject



382
383
384
# File 'app/models/sms_message.rb', line 382

def to_s
  "SMS #{id}"
end

#unblock_senderObject



235
236
237
238
239
240
241
# File 'app/models/sms_message.rb', line 235

def unblock_sender
  return false unless inbound?
  return true unless sender_sms_block

  sender_sms_block.destroy
  true
end

#uploadsActiveRecord::Relation<Upload>

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



72
# File 'app/models/sms_message.rb', line 72

has_and_belongs_to_many :uploads