feat(voice): embed Call in voice_call message payload

Adds a nested `call` object to the voice_call message JSON / push
payload so the frontend can read call state directly from message.call
instead of duplicating it in content_attributes.data.

- Message gains has_one :call (inverse of existing Call#belongs_to :message)
  via enterprise module prepend.
- Message serializer (_message.json.jbuilder) emits a call block for
  voice_call messages when the Call is linked.
- Message#push_event_data mirrors the payload into socket broadcasts.
- MessageFinder eager-loads the call (+ contact + inbox.channel) to
  avoid N+1 when listing conversation messages.
- Call gains helper methods: display_status, from_number, to_number,
  recording_url, push_event_data. These centralize the direction- and
  status-aware derivations so jbuilder and push_event_data stay in sync.

Frontend continues to read content_attributes.data — this commit only
adds the new shape; a follow-up swaps readers to message.call.
This commit is contained in:
Muhsin
2026-04-20 13:33:43 +04:00
parent 811d50043c
commit 5ce77eedbe
5 changed files with 74 additions and 2 deletions
+3 -1
View File
@@ -11,7 +11,9 @@ class MessageFinder
private
def conversation_messages
@conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] })
scope = @conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] })
scope = scope.includes(call: [:contact, { inbox: :channel }]) if Message.reflect_on_association(:call)
scope
end
def messages
+5
View File
@@ -152,9 +152,14 @@ class Message < ApplicationRecord
)
data[:echo_id] = echo_id if echo_id.present?
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
data[:call] = call.push_event_data if voice_call? && respond_to?(:call) && call.present?
merge_sender_attributes(data)
end
def voice_call?
content_type == 'voice_call'
end
def conversation_push_event_data
{
assignee_id: conversation.assignee_id,
@@ -12,3 +12,23 @@ json.private message.private
json.source_id message.source_id
json.sender message.sender.push_event_data if message.sender
json.attachments message.attachments.map(&:push_event_data) if message.attachments.present?
if message.content_type == 'voice_call' && message.respond_to?(:call) && message.call.present?
call = message.call
json.call do
json.id call.id
json.provider_call_id call.provider_call_id
json.provider call.provider
json.direction call.direction
json.status call.display_status
json.duration_seconds call.duration_seconds
json.conference_sid call.conference_sid
json.accepted_by_agent_id call.accepted_by_agent_id
json.started_at call.started_at&.to_i
json.ended_at call.ended_at
json.from_number call.from_number
json.to_number call.to_number
json.recording_url call.recording_url
json.transcript call.transcript
end
end
+38 -1
View File
@@ -45,7 +45,7 @@ class Call < ApplicationRecord
belongs_to :inbox
belongs_to :conversation
belongs_to :contact
belongs_to :message, optional: true
belongs_to :message, optional: true, inverse_of: :call
belongs_to :accepted_by_agent, class_name: 'User', optional: true
has_one_attached :recording
@@ -70,4 +70,41 @@ class Call < ApplicationRecord
def display_direction
DISPLAY_DIRECTION[direction]
end
def display_status
status.to_s.tr('_', '-')
end
def from_number
incoming? ? contact.phone_number : inbox.channel&.phone_number
end
def to_number
incoming? ? inbox.channel&.phone_number : contact.phone_number
end
def recording_url
return nil unless recording.attached?
Rails.application.routes.url_helpers.rails_blob_url(recording)
end
def push_event_data
{
id: id,
provider_call_id: provider_call_id,
provider: provider,
direction: direction,
status: display_status,
duration_seconds: duration_seconds,
conference_sid: conference_sid,
accepted_by_agent_id: accepted_by_agent_id,
started_at: started_at&.to_i,
ended_at: ended_at,
from_number: from_number,
to_number: to_number,
recording_url: recording_url,
transcript: transcript
}
end
end
@@ -1,4 +1,12 @@
module Enterprise::Message
def self.prepended(base)
base.class_eval do
has_one :call, class_name: 'Call', foreign_key: :message_id, dependent: :nullify, inverse_of: :message
scope :with_call, -> { includes(call: [:contact, { inbox: :channel }]) }
end
end
private
def mark_pending_conversation_as_open_for_human_response