Class: SmsMessage

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

Overview

== Schema Information

Table name: sms_messages
Database name: primary

id :integer not null, primary key
autoreplied_at :datetime
body :text
direction :integer 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 =
['+18008755285', '+18475407775', '+18473183355', '+18473501641',
'+18473501655', '+18473489826', '+18473507429', '+18473501980',
'+18472629814', '+18473507262', '+18473507212']

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

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::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 Models::EventPublishable

#publish_event

Instance Attribute Details

#bodyObject (readonly)



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

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

#directionObject (readonly)



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

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

#recipientObject (readonly)



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

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

#senderObject (readonly)



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

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:



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'app/models/sms_message.rb', line 79

scope :between_participants, ->(*numbers) {
  number1, number2 = [numbers].flatten.map(&:presence).compact.map { |n| PhoneNumber.parse_and_format(n) }.compact
  # 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:



71
72
73
74
75
# File 'app/models/sms_message.rb', line 71

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:



76
77
78
# File 'app/models/sms_message.rb', line 76

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



285
286
287
# File 'app/models/sms_message.rb', line 285

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



433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
# File 'app/models/sms_message.rb', line 433

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



452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
# File 'app/models/sms_message.rb', line 452

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



427
428
429
430
# File 'app/models/sms_message.rb', line 427

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

.parties_matching_number(number) ⇒ Object



471
472
473
474
475
# File 'app/models/sms_message.rb', line 471

def self.parties_matching_number(number)
  return Party.none unless number.present?

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

.ransackable_scopes(auth_object = nil) ⇒ Object



105
106
107
# File 'app/models/sms_message.rb', line 105

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:



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

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



358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# File 'app/models/sms_message.rb', line 358

def self.update_status(params)
  return false unless params.present?
  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



344
345
346
# File 'app/models/sms_message.rb', line 344

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



246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'app/models/sms_message.rb', line 246

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



194
195
196
197
198
199
# File 'app/models/sms_message.rb', line 194

def block_sender
  return false unless inbound?
  return true if sender_sms_block

  build_sender_sms_block.save
end

#conversation_idObject



234
235
236
# File 'app/models/sms_message.rb', line 234

def conversation_id
  [sender, recipient].sort
end

#create_media_urlsObject



223
224
225
226
227
228
229
230
231
232
# File 'app/models/sms_message.rb', line 223

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



109
110
111
112
113
114
115
116
# File 'app/models/sms_message.rb', line 109

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)


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

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.



484
485
486
487
488
489
490
491
492
493
494
# File 'app/models/sms_message.rb', line 484

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



305
306
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
333
334
335
336
337
338
339
340
341
342
# File 'app/models/sms_message.rb', line 305

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



301
302
303
# File 'app/models/sms_message.rb', line 301

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



517
518
519
# File 'app/models/sms_message.rb', line 517

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

#literaturesObject

Alias for Uploads#literatures

Returns:

  • (Object)

    Uploads#literatures

See Also:



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

delegate :literatures, to: :uploads

#mark_external_numbers_as_sms_enabledObject



477
478
479
# File 'app/models/sms_message.rb', line 477

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



379
380
381
382
383
384
385
386
387
# File 'app/models/sms_message.rb', line 379

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



411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
# File 'app/models/sms_message.rb', line 411

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



398
399
400
401
402
403
404
405
406
407
408
409
# File 'app/models/sms_message.rb', line 398

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



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

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



238
239
240
# File 'app/models/sms_message.rb', line 238

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

#no_media?Boolean

Returns:

  • (Boolean)


375
376
377
# File 'app/models/sms_message.rb', line 375

def no_media?
  uploads.blank?
end

#publicationsObject



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

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

#receive_messageObject



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
# File 'app/models/sms_message.rb', line 268

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)


242
243
244
# File 'app/models/sms_message.rb', line 242

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

#recipient_do_not_textDoNotCall

Returns:

See Also:



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

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:



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

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

#recipient_sms_blockSmsBlock

Returns:

See Also:



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

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

#recipient_to_sObject



295
296
297
298
299
# File 'app/models/sms_message.rb', line 295

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

#send_messageObject



209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'app/models/sms_message.rb', line 209

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)


187
188
189
190
191
192
# File 'app/models/sms_message.rb', line 187

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:



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

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:



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

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

#sender_sms_blockSmsBlock

Returns:

See Also:



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

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

#sender_to_sObject



289
290
291
292
293
# File 'app/models/sms_message.rb', line 289

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

#set_defaultsObject



496
497
498
499
500
501
502
503
504
505
506
507
# File 'app/models/sms_message.rb', line 496

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_sObject



348
349
350
# File 'app/models/sms_message.rb', line 348

def to_s
  "SMS #{id}"
end

#unblock_senderObject



201
202
203
204
205
206
207
# File 'app/models/sms_message.rb', line 201

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:



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

has_and_belongs_to_many :uploads