class SmsMessage < ApplicationRecord include NtfyDispatchable # Normalize metadata to always be a Hash attribute :metadata, :jsonb, default: {} belongs_to :gateway, optional: true validates :phone_number, presence: true validates :message_body, presence: true, length: { maximum: 1600 } validates :direction, presence: true, inclusion: { in: %w[inbound outbound] } validates :message_id, presence: true, uniqueness: true validates :status, inclusion: { in: %w[pending queued sent delivered failed] } validate :phone_number_format before_validation :ensure_metadata_is_hash before_validation :generate_message_id, on: :create before_validation :normalize_phone_number after_create_commit :enqueue_sending, if: :outbound? scope :pending, -> { where(status: "pending") } scope :queued, -> { where(status: "queued") } scope :sent, -> { where(status: "sent") } scope :delivered, -> { where(status: "delivered") } scope :failed, -> { where(status: "failed") } scope :inbound, -> { where(direction: "inbound") } scope :outbound, -> { where(direction: "outbound") } scope :recent, -> { order(created_at: :desc) } # Check message direction def outbound? direction == "outbound" end def inbound? direction == "inbound" end # Check if message can be retried def can_retry? failed? && retry_count < 3 end # Mark message as sent def mark_sent!(gateway) update!( status: "sent", gateway: gateway, sent_at: Time.current ) gateway.increment_sent_count! end # Mark message as delivered def mark_delivered! update!(status: "delivered", delivered_at: Time.current) self.class.dispatch_ntfy("sms_delivered", title: "SMS delivered", message: "Message to #{phone_number} (#{message_id}) was delivered", priority: 2, tags: [ "white_check_mark" ]) end # Mark message as failed def mark_failed!(error_msg = nil) update!(status: "failed", failed_at: Time.current, error_message: error_msg) self.class.dispatch_ntfy("sms_failed", title: "SMS failed", message: "Message to #{phone_number} (#{message_id}) failed#{error_msg ? ": #{error_msg}" : ''}", priority: 5, tags: [ "x", "rotating_light" ]) end # Increment retry counter def increment_retry! increment!(:retry_count) end # Check if message status is failed def failed? status == "failed" end private def generate_message_id self.message_id ||= "msg_#{SecureRandom.hex(16)}" end def normalize_phone_number return unless phone_number.present? # Remove spaces and special characters self.phone_number = phone_number.gsub(/[^\d+]/, "") end def phone_number_format return unless phone_number.present? phone = Phonelib.parse(phone_number) unless phone.valid? errors.add(:phone_number, "is not a valid phone number") end end def enqueue_sending SendSmsJob.perform_later(id) end def ensure_metadata_is_hash self.metadata = {} if metadata.nil? self.metadata = {} unless metadata.is_a?(Hash) end end