feat(voice): wire Twilio voice flow through unified Call model
Moves call state off conversation.additional_attributes and
conversation.identifier onto first-class Call records, one per call.
- Call is the source of truth for status, direction, duration, started_at,
accepted_by_agent, and conference_sid (in meta).
- Conference names key off Call.id (conf_account_{aid}_call_{cid}), so
multiple calls on one conversation no longer collide. lock_to_single_conversation
inboxes append each call as a new voice_call bubble in the existing thread.
- Agent identity on conference join webhooks is parsed from the Twilio
ParticipantLabel (agent-{user_id}-account-{account_id}); stateless and
independent of the /conference#create API call order.
- ConversationCard.vue derives the call badge from the latest voice_call
message, not a denormalized cache on the conversation. Removes the stale
"Call ended" state that lingered after subsequent text messages.
This commit is contained in:
@@ -95,10 +95,13 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
const voiceCallData = computed(() => {
|
||||
const last = lastMessageInChat.value;
|
||||
if (last?.content_type !== 'voice_call')
|
||||
return { status: null, direction: null };
|
||||
const data = last.content_attributes?.data ?? {};
|
||||
return { status: data.status, direction: data.call_direction };
|
||||
});
|
||||
|
||||
const inboxId = computed(() => props.chat.inbox_id);
|
||||
|
||||
|
||||
@@ -60,9 +60,10 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
|
||||
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
|
||||
|
||||
const callInfo = { conversationId, callStatus: status };
|
||||
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
|
||||
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
|
||||
commit(types.UPDATE_MESSAGE_CALL_STATUS, {
|
||||
conversationId,
|
||||
callStatus: status,
|
||||
});
|
||||
|
||||
const isNewCall =
|
||||
status === 'ringing' &&
|
||||
|
||||
@@ -307,19 +307,6 @@ export const mutations = {
|
||||
}
|
||||
},
|
||||
|
||||
[types.UPDATE_CONVERSATION_CALL_STATUS](
|
||||
_state,
|
||||
{ conversationId, callStatus }
|
||||
) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (!chat) return;
|
||||
|
||||
chat.additional_attributes = {
|
||||
...chat.additional_attributes,
|
||||
call_status: callStatus,
|
||||
};
|
||||
},
|
||||
|
||||
[types.UPDATE_MESSAGE_CALL_STATUS](_state, { conversationId, callStatus }) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (!chat) return;
|
||||
|
||||
@@ -2,44 +2,6 @@ import { mutations } from '../index';
|
||||
import types from '../../../mutation-types';
|
||||
|
||||
describe('#mutations', () => {
|
||||
describe('#UPDATE_CONVERSATION_CALL_STATUS', () => {
|
||||
it('does nothing if conversation is not found', () => {
|
||||
const state = { allConversations: [] };
|
||||
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'ringing',
|
||||
});
|
||||
expect(state.allConversations).toEqual([]);
|
||||
});
|
||||
|
||||
it('updates call_status preserving existing additional_attributes', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{ id: 1, additional_attributes: { other_attr: 'value' } },
|
||||
],
|
||||
};
|
||||
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'in-progress',
|
||||
});
|
||||
expect(state.allConversations[0].additional_attributes).toEqual({
|
||||
other_attr: 'value',
|
||||
call_status: 'in-progress',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates additional_attributes if it does not exist', () => {
|
||||
const state = { allConversations: [{ id: 1 }] };
|
||||
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'completed',
|
||||
});
|
||||
expect(state.allConversations[0].additional_attributes).toEqual({
|
||||
call_status: 'completed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#UPDATE_MESSAGE_CALL_STATUS', () => {
|
||||
it('does nothing if conversation is not found', () => {
|
||||
const state = { allConversations: [] };
|
||||
|
||||
@@ -51,7 +51,6 @@ export default {
|
||||
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
|
||||
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
|
||||
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
|
||||
UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS',
|
||||
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
|
||||
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
|
||||
|
||||
|
||||
@@ -10,36 +10,34 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
|
||||
end
|
||||
|
||||
def create
|
||||
conversation = fetch_conversation_by_display_id
|
||||
ensure_call_sid!(conversation)
|
||||
call = resolve_call!
|
||||
|
||||
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
|
||||
conference_service = Voice::Provider::Twilio::ConferenceService.new(call: call)
|
||||
conference_sid = conference_service.ensure_conference_sid
|
||||
conference_service.mark_agent_joined(user: current_user)
|
||||
|
||||
render json: {
|
||||
status: 'success',
|
||||
id: conversation.display_id,
|
||||
id: call.conversation.display_id,
|
||||
conference_sid: conference_sid,
|
||||
using_webrtc: true
|
||||
}
|
||||
end
|
||||
|
||||
def destroy
|
||||
conversation = fetch_conversation_by_display_id
|
||||
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
|
||||
render json: { status: 'success', id: conversation.display_id }
|
||||
call = resolve_call!
|
||||
Voice::Provider::Twilio::ConferenceService.new(call: call).end_conference
|
||||
render json: { status: 'success', id: call.conversation.display_id }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_call_sid!(conversation)
|
||||
return conversation.identifier if conversation.identifier.present?
|
||||
def resolve_call!
|
||||
call_sid = params[:call_sid]
|
||||
return Call.find_by!(provider: :twilio, provider_call_id: call_sid) if call_sid.present?
|
||||
|
||||
incoming_sid = params.require(:call_sid)
|
||||
|
||||
conversation.update!(identifier: incoming_sid)
|
||||
incoming_sid
|
||||
conversation = fetch_conversation_by_display_id
|
||||
Call.where(conversation_id: conversation.id).active.order(created_at: :desc).first!
|
||||
end
|
||||
|
||||
def set_voice_inbox_for_conference
|
||||
|
||||
@@ -6,20 +6,18 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
|
||||
authorize contact, :show?
|
||||
authorize voice_inbox, :show?
|
||||
|
||||
result = Voice::OutboundCallBuilder.perform!(
|
||||
call = Voice::OutboundCallBuilder.perform!(
|
||||
account: Current.account,
|
||||
inbox: voice_inbox,
|
||||
user: Current.user,
|
||||
contact: contact
|
||||
)
|
||||
|
||||
conversation = result[:conversation]
|
||||
|
||||
render json: {
|
||||
conversation_id: conversation.display_id,
|
||||
conversation_id: call.conversation.display_id,
|
||||
inbox_id: voice_inbox.id,
|
||||
call_sid: result[:call_sid],
|
||||
conference_sid: conversation.additional_attributes['conference_sid']
|
||||
call_sid: call.provider_call_id,
|
||||
conference_sid: call.conference_sid
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -20,30 +20,26 @@ class Twilio::VoiceController < ApplicationController
|
||||
end
|
||||
|
||||
def call_twiml
|
||||
account = current_account
|
||||
Rails.logger.info(
|
||||
"TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
|
||||
"TWILIO_VOICE_TWIML account=#{current_account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
|
||||
)
|
||||
|
||||
conversation = resolve_conversation
|
||||
conference_sid = ensure_conference_sid!(conversation)
|
||||
|
||||
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
|
||||
call = resolve_call
|
||||
render xml: conference_twiml(call)
|
||||
end
|
||||
|
||||
def conference_status
|
||||
event = mapped_conference_event
|
||||
return head :no_content unless event
|
||||
|
||||
conversation = find_conversation_for_conference!(
|
||||
call = find_call_for_conference!(
|
||||
friendly_name: params[:FriendlyName],
|
||||
call_sid: twilio_call_sid
|
||||
)
|
||||
|
||||
Voice::Conference::Manager.new(
|
||||
conversation: conversation,
|
||||
call: call,
|
||||
event: event,
|
||||
call_sid: twilio_call_sid,
|
||||
participant_label: participant_label
|
||||
).process
|
||||
|
||||
@@ -80,8 +76,8 @@ class Twilio::VoiceController < ApplicationController
|
||||
from_number.start_with?('client:')
|
||||
end
|
||||
|
||||
def resolve_conversation
|
||||
return find_conversation_for_agent if agent_leg?(twilio_from)
|
||||
def resolve_call
|
||||
return find_call_for_agent if agent_leg?(twilio_from)
|
||||
|
||||
case twilio_direction
|
||||
when 'inbound'
|
||||
@@ -92,79 +88,62 @@ class Twilio::VoiceController < ApplicationController
|
||||
call_sid: twilio_call_sid
|
||||
)
|
||||
when 'outbound-api', 'outbound-dial'
|
||||
sync_outbound_leg(
|
||||
call_sid: twilio_call_sid,
|
||||
from_number: twilio_from,
|
||||
direction: twilio_direction
|
||||
)
|
||||
sync_outbound_leg(call_sid: twilio_call_sid, direction: twilio_direction)
|
||||
else
|
||||
raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}"
|
||||
end
|
||||
end
|
||||
|
||||
def find_conversation_for_agent
|
||||
if params[:conversation_id].present?
|
||||
current_account.conversations.find_by!(display_id: params[:conversation_id])
|
||||
def find_call_for_agent
|
||||
if params[:call_sid].present?
|
||||
Call.find_by!(provider: :twilio, provider_call_id: params[:call_sid])
|
||||
elsif params[:conversation_id].present?
|
||||
conversation = current_account.conversations.find_by!(display_id: params[:conversation_id])
|
||||
Call.where(conversation_id: conversation.id).active.order(created_at: :desc).first!
|
||||
else
|
||||
current_account.conversations.find_by!(identifier: twilio_call_sid)
|
||||
Call.find_by!(provider: :twilio, provider_call_id: twilio_call_sid)
|
||||
end
|
||||
end
|
||||
|
||||
def sync_outbound_leg(call_sid:, from_number:, direction:)
|
||||
def sync_outbound_leg(call_sid:, direction:)
|
||||
parent_sid = params['ParentCallSid'].presence
|
||||
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
|
||||
conversation = current_account.conversations.find_by!(identifier: lookup_sid)
|
||||
call = Call.find_by!(provider: :twilio, provider_call_id: lookup_sid)
|
||||
|
||||
Voice::CallSessionSyncService.new(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid,
|
||||
message_call_sid: conversation.identifier,
|
||||
leg: {
|
||||
from_number: from_number,
|
||||
to_number: twilio_to,
|
||||
direction: 'outbound'
|
||||
}
|
||||
).perform
|
||||
Voice::CallSessionSyncService.new(call: call, parent_call_sid: parent_sid).perform
|
||||
end
|
||||
|
||||
def ensure_conference_sid!(conversation)
|
||||
attrs = conversation.additional_attributes || {}
|
||||
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
attrs['conference_sid']
|
||||
end
|
||||
|
||||
def conference_twiml(conference_sid, agent_leg)
|
||||
def conference_twiml(call)
|
||||
Twilio::TwiML::VoiceResponse.new.tap do |response|
|
||||
response.dial do |dial|
|
||||
dial.conference(
|
||||
conference_sid,
|
||||
start_conference_on_enter: agent_leg,
|
||||
call.conference_sid,
|
||||
start_conference_on_enter: agent_leg?(twilio_from),
|
||||
end_conference_on_exit: false,
|
||||
status_callback: conference_status_callback_url,
|
||||
status_callback_event: 'start end join leave',
|
||||
status_callback_method: 'POST',
|
||||
participant_label: agent_leg ? 'agent' : 'contact'
|
||||
participant_label: participant_label_for(twilio_from)
|
||||
)
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
def participant_label_for(from_number)
|
||||
return from_number.delete_prefix('client:') if from_number.start_with?('client:')
|
||||
|
||||
'contact'
|
||||
end
|
||||
|
||||
def conference_status_callback_url
|
||||
phone_digits = inbox_channel.phone_number.delete_prefix('+')
|
||||
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
|
||||
end
|
||||
|
||||
def find_conversation_for_conference!(friendly_name:, call_sid:)
|
||||
def find_call_for_conference!(friendly_name:, call_sid:)
|
||||
name = friendly_name.to_s
|
||||
scope = current_account.conversations
|
||||
|
||||
if name.present?
|
||||
conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
|
||||
return conversation if conversation
|
||||
end
|
||||
|
||||
scope.find_by!(identifier: call_sid)
|
||||
call = Call.twilio.by_conference_sid(name).first if name.present?
|
||||
call || Call.find_by!(provider: :twilio, provider_call_id: call_sid)
|
||||
end
|
||||
|
||||
def set_inbox!
|
||||
|
||||
@@ -34,6 +34,10 @@ class Call < ApplicationRecord
|
||||
# Statuses where the call is finished and won't change again
|
||||
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
|
||||
|
||||
META_ACCESSORS = %i[conference_sid recording_sid parent_call_sid initiated_at ended_at].freeze
|
||||
# Frontend voice bubbles/stores expect inbound/outbound string values
|
||||
DISPLAY_DIRECTION = { 'incoming' => 'inbound', 'outgoing' => 'outbound' }.freeze
|
||||
|
||||
enum :provider, { twilio: 0, whatsapp: 1 }
|
||||
enum :direction, { incoming: 0, outgoing: 1 }
|
||||
|
||||
@@ -52,4 +56,18 @@ class Call < ApplicationRecord
|
||||
validates :status, presence: true, inclusion: { in: STATUSES }
|
||||
|
||||
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
|
||||
scope :by_conference_sid, ->(sid) { where("meta->>'conference_sid' = ?", sid) }
|
||||
|
||||
META_ACCESSORS.each do |key|
|
||||
define_method(key) { (meta || {})[key.to_s] }
|
||||
define_method("#{key}=") { |value| self.meta = (meta || {}).merge(key.to_s => value) }
|
||||
end
|
||||
|
||||
def self.find_by_provider_call_id(provider, sid)
|
||||
find_by(provider: provider, provider_call_id: sid)
|
||||
end
|
||||
|
||||
def display_direction
|
||||
DISPLAY_DIRECTION[direction]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,17 +25,6 @@ module Enterprise::Conversation
|
||||
self.captain_activity_reason_type = previous_reason_type
|
||||
end
|
||||
|
||||
# Include select additional_attributes keys (call related) for update events
|
||||
def allowed_keys?
|
||||
return true if super
|
||||
|
||||
attrs_change = previous_changes['additional_attributes']
|
||||
return false unless attrs_change.is_a?(Array) && attrs_change[1].is_a?(Hash)
|
||||
|
||||
changed_attr_keys = attrs_change[1].keys
|
||||
changed_attr_keys.intersect?(%w[call_status])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dispatch_captain_inference_event(event_name)
|
||||
|
||||
@@ -1,42 +1,31 @@
|
||||
class Voice::CallMessageBuilder
|
||||
def self.perform!(conversation:, direction:, payload:, user: nil, timestamps: {})
|
||||
new(
|
||||
conversation: conversation,
|
||||
direction: direction,
|
||||
payload: payload,
|
||||
user: user,
|
||||
timestamps: timestamps
|
||||
).perform!
|
||||
def self.perform!(call:)
|
||||
new(call: call).perform!
|
||||
end
|
||||
|
||||
def initialize(conversation:, direction:, payload:, user:, timestamps:)
|
||||
@conversation = conversation
|
||||
@direction = direction
|
||||
@payload = payload
|
||||
@user = user
|
||||
@timestamps = timestamps
|
||||
def initialize(call:)
|
||||
@call = call
|
||||
end
|
||||
|
||||
def perform!
|
||||
validate_sender!
|
||||
message = latest_message
|
||||
message = find_message
|
||||
message ? update_message!(message) : create_message!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :conversation, :direction, :payload, :user, :timestamps
|
||||
attr_reader :call
|
||||
|
||||
def latest_message
|
||||
conversation.messages.voice_calls.order(created_at: :desc).first
|
||||
def find_message
|
||||
call.conversation.messages.voice_calls.find do |m|
|
||||
m.content_attributes.dig('data', 'call_sid') == call.provider_call_id
|
||||
end
|
||||
end
|
||||
|
||||
def update_message!(message)
|
||||
message.update!(
|
||||
message_type: message_type,
|
||||
content_attributes: { 'data' => base_payload },
|
||||
sender: sender
|
||||
)
|
||||
existing = message.content_attributes.fetch('data', {})
|
||||
message.update!(content_attributes: { 'data' => data_payload(existing) })
|
||||
message
|
||||
end
|
||||
|
||||
def create_message!
|
||||
@@ -44,47 +33,45 @@ class Voice::CallMessageBuilder
|
||||
content: 'Voice Call',
|
||||
message_type: message_type,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { 'data' => base_payload }
|
||||
content_attributes: { 'data' => data_payload({}) }
|
||||
}
|
||||
Messages::MessageBuilder.new(sender, conversation, params).perform
|
||||
Messages::MessageBuilder.new(sender, call.conversation, params).perform
|
||||
end
|
||||
|
||||
def base_payload
|
||||
@base_payload ||= begin
|
||||
data = payload.slice(
|
||||
:call_sid,
|
||||
:status,
|
||||
:call_direction,
|
||||
:conference_sid,
|
||||
:from_number,
|
||||
:to_number
|
||||
).stringify_keys
|
||||
data['call_direction'] = direction
|
||||
data['meta'] = {
|
||||
'created_at' => timestamps[:created_at] || current_timestamp,
|
||||
'ringing_at' => timestamps[:ringing_at] || current_timestamp
|
||||
}.compact
|
||||
data
|
||||
end
|
||||
def data_payload(existing)
|
||||
now = Time.zone.now.to_i
|
||||
meta = existing.fetch('meta', {})
|
||||
|
||||
{
|
||||
'call_sid' => call.provider_call_id,
|
||||
'status' => call.status.tr('_', '-'),
|
||||
'call_direction' => call.display_direction,
|
||||
'from_number' => from_number,
|
||||
'to_number' => to_number,
|
||||
'duration' => call.duration_seconds,
|
||||
'recording_url' => existing['recording_url'],
|
||||
'transcript' => call.transcript,
|
||||
'conference_sid' => call.conference_sid,
|
||||
'meta' => {
|
||||
'created_at' => meta['created_at'] || now,
|
||||
'ringing_at' => meta['ringing_at'] || now
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def from_number
|
||||
call.incoming? ? call.contact.phone_number : call.inbox.channel&.phone_number
|
||||
end
|
||||
|
||||
def to_number
|
||||
call.incoming? ? call.inbox.channel&.phone_number : call.contact.phone_number
|
||||
end
|
||||
|
||||
def message_type
|
||||
direction == 'outbound' ? 'outgoing' : 'incoming'
|
||||
call.outgoing? ? 'outgoing' : 'incoming'
|
||||
end
|
||||
|
||||
def sender
|
||||
return user if direction == 'outbound'
|
||||
|
||||
conversation.contact
|
||||
end
|
||||
|
||||
def validate_sender!
|
||||
return unless direction == 'outbound'
|
||||
|
||||
raise ArgumentError, 'Agent sender required for outbound calls' unless user
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= Time.zone.now.to_i
|
||||
call.outgoing? ? call.accepted_by_agent : call.contact
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,94 +1,17 @@
|
||||
class Voice::CallSessionSyncService
|
||||
attr_reader :conversation, :call_sid, :message_call_sid, :from_number, :to_number, :direction
|
||||
|
||||
def initialize(conversation:, call_sid:, leg:, message_call_sid: nil)
|
||||
@conversation = conversation
|
||||
@call_sid = call_sid
|
||||
@message_call_sid = message_call_sid || call_sid
|
||||
@from_number = leg[:from_number]
|
||||
@to_number = leg[:to_number]
|
||||
@direction = leg[:direction]
|
||||
end
|
||||
pattr_initialize [:call!, { parent_call_sid: nil }]
|
||||
|
||||
def perform
|
||||
ActiveRecord::Base.transaction do
|
||||
attrs = refreshed_attributes
|
||||
conversation.update!(
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
sync_voice_call_message!(attrs)
|
||||
end
|
||||
|
||||
conversation
|
||||
record_parent_call_sid!
|
||||
call
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def refreshed_attributes
|
||||
attrs = (conversation.additional_attributes || {}).dup
|
||||
attrs['call_direction'] = direction
|
||||
attrs['call_status'] ||= 'ringing'
|
||||
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
|
||||
attrs['meta'] ||= {}
|
||||
attrs['meta']['initiated_at'] ||= current_timestamp
|
||||
attrs
|
||||
end
|
||||
def record_parent_call_sid!
|
||||
return if parent_call_sid.blank?
|
||||
return if call.parent_call_sid == parent_call_sid
|
||||
|
||||
def sync_voice_call_message!(attrs)
|
||||
Voice::CallMessageBuilder.perform!(
|
||||
conversation: conversation,
|
||||
direction: direction,
|
||||
payload: {
|
||||
call_sid: message_call_sid,
|
||||
status: attrs['call_status'],
|
||||
conference_sid: attrs['conference_sid'],
|
||||
from_number: origin_number_for(direction),
|
||||
to_number: target_number_for(direction)
|
||||
},
|
||||
user: agent_for(attrs),
|
||||
timestamps: {
|
||||
created_at: attrs.dig('meta', 'initiated_at'),
|
||||
ringing_at: attrs.dig('meta', 'ringing_at')
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def origin_number_for(current_direction)
|
||||
return outbound_origin if current_direction == 'outbound'
|
||||
|
||||
from_number.presence || inbox_number
|
||||
end
|
||||
|
||||
def target_number_for(current_direction)
|
||||
return conversation.contact&.phone_number || to_number if current_direction == 'outbound'
|
||||
|
||||
to_number || conversation.contact&.phone_number
|
||||
end
|
||||
|
||||
def agent_for(attrs)
|
||||
agent_id = attrs['agent_id']
|
||||
return nil unless agent_id
|
||||
|
||||
agent = conversation.account.users.find_by(id: agent_id)
|
||||
raise ArgumentError, 'Agent sender required for outbound call sync' if direction == 'outbound' && agent.nil?
|
||||
|
||||
agent
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= current_time.to_i
|
||||
end
|
||||
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
end
|
||||
|
||||
def outbound_origin
|
||||
inbox_number || from_number
|
||||
end
|
||||
|
||||
def inbox_number
|
||||
conversation.inbox&.channel&.phone_number
|
||||
call.update!(parent_call_sid: parent_call_sid)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,66 +1,40 @@
|
||||
class Voice::CallStatus::Manager
|
||||
pattr_initialize [:conversation!, :call_sid]
|
||||
|
||||
ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze
|
||||
TERMINAL_STATUSES = %w[completed no-answer failed].freeze
|
||||
pattr_initialize [:call!]
|
||||
|
||||
def process_status_update(status, duration: nil, timestamp: nil)
|
||||
return unless ALLOWED_STATUSES.include?(status)
|
||||
return unless Call::STATUSES.include?(status)
|
||||
return if call.status == status
|
||||
|
||||
current_status = conversation.additional_attributes&.dig('call_status')
|
||||
return if current_status == status
|
||||
|
||||
apply_status(status, duration: duration, timestamp: timestamp)
|
||||
update_message(status)
|
||||
apply_call_updates!(status, duration: duration, timestamp: timestamp)
|
||||
call.conversation.update!(last_activity_at: Time.zone.now)
|
||||
Voice::CallMessageBuilder.perform!(call: call)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_status(status, duration:, timestamp:)
|
||||
attrs = (conversation.additional_attributes || {}).dup
|
||||
attrs['call_status'] = status
|
||||
def apply_call_updates!(status, duration:, timestamp:)
|
||||
attrs = { status: status }
|
||||
ts = timestamp || now_seconds
|
||||
|
||||
if status == 'in-progress'
|
||||
attrs['call_started_at'] ||= timestamp || now_seconds
|
||||
elsif TERMINAL_STATUSES.include?(status)
|
||||
attrs['call_ended_at'] = timestamp || now_seconds
|
||||
attrs['call_duration'] = resolved_duration(attrs, duration, timestamp)
|
||||
if status == 'in_progress'
|
||||
attrs[:started_at] = Time.zone.at(ts)
|
||||
elsif Call::TERMINAL_STATUSES.include?(status)
|
||||
call.ended_at = ts
|
||||
attrs[:meta] = call.meta
|
||||
attrs[:duration_seconds] = resolved_duration(duration, ts)
|
||||
end
|
||||
|
||||
conversation.update!(
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
call.update!(attrs)
|
||||
end
|
||||
|
||||
def resolved_duration(attrs, provided_duration, timestamp)
|
||||
def resolved_duration(provided_duration, timestamp)
|
||||
return provided_duration if provided_duration
|
||||
return unless call.started_at
|
||||
|
||||
started_at = attrs['call_started_at']
|
||||
return unless started_at && timestamp
|
||||
|
||||
[timestamp - started_at.to_i, 0].max
|
||||
end
|
||||
|
||||
def update_message(status)
|
||||
message = conversation.messages
|
||||
.where(content_type: 'voice_call')
|
||||
.order(created_at: :desc)
|
||||
.first
|
||||
return unless message
|
||||
|
||||
data = (message.content_attributes || {}).dup
|
||||
data['data'] ||= {}
|
||||
data['data']['status'] = status
|
||||
|
||||
message.update!(content_attributes: data)
|
||||
[timestamp - call.started_at.to_i, 0].max
|
||||
end
|
||||
|
||||
def now_seconds
|
||||
current_time.to_i
|
||||
end
|
||||
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
Time.zone.now.to_i
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,71 +1,62 @@
|
||||
class Voice::Conference::Manager
|
||||
pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label]
|
||||
pattr_initialize [:call!, :event!, :participant_label]
|
||||
|
||||
AGENT_LABEL_PATTERN = /\Aagent-(\d+)-account-(\d+)\z/
|
||||
|
||||
def process
|
||||
case event
|
||||
when 'start'
|
||||
ensure_conference_sid!
|
||||
mark_ringing!
|
||||
when 'join'
|
||||
mark_in_progress! if agent_participant?
|
||||
join_agent! if agent_participant?
|
||||
when 'leave'
|
||||
handle_leave!
|
||||
when 'end'
|
||||
finalize_conference!
|
||||
finalize!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def status_manager
|
||||
@status_manager ||= Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid
|
||||
)
|
||||
end
|
||||
|
||||
def ensure_conference_sid!
|
||||
attrs = conversation.additional_attributes || {}
|
||||
return if attrs['conference_sid'].present?
|
||||
|
||||
attrs['conference_sid'] = Voice::Conference::Name.for(conversation)
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
@status_manager ||= Voice::CallStatus::Manager.new(call: call)
|
||||
end
|
||||
|
||||
def mark_ringing!
|
||||
return if current_status
|
||||
|
||||
status_manager.process_status_update('ringing')
|
||||
end
|
||||
|
||||
def mark_in_progress!
|
||||
status_manager.process_status_update('in-progress', timestamp: current_timestamp)
|
||||
def join_agent!
|
||||
user_id = extract_user_id
|
||||
call.update!(accepted_by_agent_id: user_id) if user_id
|
||||
status_manager.process_status_update('in_progress', timestamp: now)
|
||||
end
|
||||
|
||||
def handle_leave!
|
||||
case current_status
|
||||
case call.status
|
||||
when 'ringing'
|
||||
status_manager.process_status_update('no-answer', timestamp: current_timestamp)
|
||||
when 'in-progress'
|
||||
status_manager.process_status_update('completed', timestamp: current_timestamp)
|
||||
status_manager.process_status_update('no_answer', timestamp: now)
|
||||
when 'in_progress'
|
||||
status_manager.process_status_update('completed', timestamp: now)
|
||||
end
|
||||
end
|
||||
|
||||
def finalize_conference!
|
||||
return if %w[completed no-answer failed].include?(current_status)
|
||||
def finalize!
|
||||
return if Call::TERMINAL_STATUSES.include?(call.status)
|
||||
|
||||
status_manager.process_status_update('completed', timestamp: current_timestamp)
|
||||
end
|
||||
|
||||
def current_status
|
||||
conversation.additional_attributes&.dig('call_status')
|
||||
status_manager.process_status_update('completed', timestamp: now)
|
||||
end
|
||||
|
||||
def agent_participant?
|
||||
participant_label.to_s.start_with?('agent')
|
||||
participant_label.to_s.start_with?('agent-')
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
def extract_user_id
|
||||
match = participant_label.to_s.match(AGENT_LABEL_PATTERN)
|
||||
match && match[1].to_i
|
||||
end
|
||||
|
||||
def now
|
||||
Time.zone.now.to_i
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module Voice::Conference::Name
|
||||
def self.for(conversation)
|
||||
"conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
|
||||
def self.for(call)
|
||||
"conf_account_#{call.account_id}_call_#{call.id}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,16 +13,17 @@ class Voice::InboundCallBuilder
|
||||
end
|
||||
|
||||
def perform!
|
||||
timestamp = current_timestamp
|
||||
existing = Call.find_by(provider: :twilio, provider_call_id: call_sid)
|
||||
return existing if existing
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
contact = ensure_contact!
|
||||
contact_inbox = ensure_contact_inbox!(contact)
|
||||
conversation = find_conversation || create_conversation!(contact, contact_inbox)
|
||||
conversation.reload
|
||||
update_conversation!(conversation, timestamp)
|
||||
build_voice_message!(conversation, timestamp)
|
||||
conversation
|
||||
conversation = resolve_conversation!(contact, contact_inbox)
|
||||
call = create_call!(contact, conversation)
|
||||
message = Voice::CallMessageBuilder.perform!(call: call)
|
||||
call.update!(message_id: message.id)
|
||||
call
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,57 +44,37 @@ class Voice::InboundCallBuilder
|
||||
end
|
||||
end
|
||||
|
||||
def find_conversation
|
||||
return if call_sid.blank?
|
||||
def resolve_conversation!(contact, contact_inbox)
|
||||
if inbox.lock_to_single_conversation
|
||||
reusable = account.conversations
|
||||
.where(contact_id: contact.id, inbox_id: inbox.id)
|
||||
.where.not(status: :resolved)
|
||||
.order(last_activity_at: :desc)
|
||||
.first
|
||||
return reusable if reusable
|
||||
end
|
||||
|
||||
account.conversations.includes(:contact).find_by(identifier: call_sid)
|
||||
end
|
||||
|
||||
def create_conversation!(contact, contact_inbox)
|
||||
account.conversations.create!(
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: contact.id,
|
||||
status: :open,
|
||||
identifier: call_sid
|
||||
status: :open
|
||||
)
|
||||
end
|
||||
|
||||
def update_conversation!(conversation, timestamp)
|
||||
attrs = {
|
||||
'call_direction' => 'inbound',
|
||||
'call_status' => 'ringing',
|
||||
'conference_sid' => Voice::Conference::Name.for(conversation),
|
||||
'meta' => { 'initiated_at' => timestamp }
|
||||
}
|
||||
|
||||
conversation.update!(
|
||||
identifier: call_sid,
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
end
|
||||
|
||||
def build_voice_message!(conversation, timestamp)
|
||||
Voice::CallMessageBuilder.perform!(
|
||||
def create_call!(contact, conversation)
|
||||
call = Call.create!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
direction: 'inbound',
|
||||
payload: {
|
||||
call_sid: call_sid,
|
||||
status: 'ringing',
|
||||
conference_sid: conversation.additional_attributes['conference_sid'],
|
||||
from_number: from_number,
|
||||
to_number: inbox.channel&.phone_number
|
||||
},
|
||||
timestamps: { created_at: timestamp, ringing_at: timestamp }
|
||||
contact: contact,
|
||||
provider: :twilio,
|
||||
direction: :incoming,
|
||||
status: 'ringing',
|
||||
provider_call_id: call_sid,
|
||||
meta: { 'initiated_at' => Time.zone.now.to_i }
|
||||
)
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= current_time.to_i
|
||||
end
|
||||
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
call.update!(conference_sid: Voice::Conference::Name.for(call))
|
||||
call
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,17 +16,14 @@ class Voice::OutboundCallBuilder
|
||||
raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
|
||||
raise ArgumentError, 'Agent required' if user.blank?
|
||||
|
||||
timestamp = current_timestamp
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
contact_inbox = ensure_contact_inbox!
|
||||
conversation = create_conversation!(contact_inbox)
|
||||
conversation.reload
|
||||
conference_sid = Voice::Conference::Name.for(conversation)
|
||||
call_sid = initiate_call!
|
||||
update_conversation!(conversation, call_sid, conference_sid, timestamp)
|
||||
build_voice_message!(conversation, call_sid, conference_sid, timestamp)
|
||||
{ conversation: conversation, call_sid: call_sid }
|
||||
call = create_call!(conversation, call_sid)
|
||||
message = Voice::CallMessageBuilder.perform!(call: call)
|
||||
call.update!(message_id: message.id)
|
||||
call
|
||||
end
|
||||
end
|
||||
|
||||
@@ -51,48 +48,23 @@ class Voice::OutboundCallBuilder
|
||||
end
|
||||
|
||||
def initiate_call!
|
||||
inbox.channel.initiate_call(
|
||||
to: contact.phone_number
|
||||
)[:call_sid]
|
||||
inbox.channel.initiate_call(to: contact.phone_number)[:call_sid]
|
||||
end
|
||||
|
||||
def update_conversation!(conversation, call_sid, conference_sid, timestamp)
|
||||
attrs = {
|
||||
'call_direction' => 'outbound',
|
||||
'call_status' => 'ringing',
|
||||
'agent_id' => user.id,
|
||||
'conference_sid' => conference_sid,
|
||||
'meta' => { 'initiated_at' => timestamp }
|
||||
}
|
||||
|
||||
conversation.update!(
|
||||
identifier: call_sid,
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
end
|
||||
|
||||
def build_voice_message!(conversation, call_sid, conference_sid, timestamp)
|
||||
Voice::CallMessageBuilder.perform!(
|
||||
def create_call!(conversation, call_sid)
|
||||
call = Call.create!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
direction: 'outbound',
|
||||
payload: {
|
||||
call_sid: call_sid,
|
||||
status: 'ringing',
|
||||
conference_sid: conference_sid,
|
||||
from_number: inbox.channel&.phone_number,
|
||||
to_number: contact.phone_number
|
||||
},
|
||||
user: user,
|
||||
timestamps: { created_at: timestamp, ringing_at: timestamp }
|
||||
contact: contact,
|
||||
accepted_by_agent: user,
|
||||
provider: :twilio,
|
||||
direction: :outgoing,
|
||||
status: 'ringing',
|
||||
provider_call_id: call_sid,
|
||||
meta: { 'initiated_at' => Time.zone.now.to_i }
|
||||
)
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= current_time.to_i
|
||||
end
|
||||
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
call.update!(conference_sid: Voice::Conference::Name.for(call))
|
||||
call
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,40 +1,31 @@
|
||||
class Voice::Provider::Twilio::ConferenceService
|
||||
pattr_initialize [:conversation!, { twilio_client: nil }]
|
||||
pattr_initialize [:call!, { twilio_client: nil }]
|
||||
|
||||
def ensure_conference_sid
|
||||
existing = conversation.additional_attributes&.dig('conference_sid')
|
||||
return existing if existing.present?
|
||||
return call.conference_sid if call.conference_sid.present?
|
||||
|
||||
sid = Voice::Conference::Name.for(conversation)
|
||||
merge_attributes('conference_sid' => sid)
|
||||
sid
|
||||
call.update!(conference_sid: Voice::Conference::Name.for(call))
|
||||
call.conference_sid
|
||||
end
|
||||
|
||||
def mark_agent_joined(user:)
|
||||
merge_attributes(
|
||||
'agent_joined' => true,
|
||||
'joined_at' => Time.current.to_i,
|
||||
'joined_by' => { id: user.id, name: user.name }
|
||||
)
|
||||
call.update!(accepted_by_agent: user)
|
||||
end
|
||||
|
||||
def end_conference
|
||||
return if call.conference_sid.blank?
|
||||
|
||||
twilio_client
|
||||
.conferences
|
||||
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
|
||||
.list(friendly_name: call.conference_sid, status: 'in-progress')
|
||||
.each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def merge_attributes(attrs)
|
||||
current = conversation.additional_attributes || {}
|
||||
conversation.update!(additional_attributes: current.merge(attrs))
|
||||
end
|
||||
|
||||
def twilio_client
|
||||
@twilio_client ||= begin
|
||||
channel = conversation.inbox.channel
|
||||
channel = call.inbox.channel
|
||||
if channel.api_key_sid.present? && channel.try(:api_key_secret).present?
|
||||
::Twilio::REST::Client.new(channel.api_key_sid, channel.api_key_secret, channel.account_sid)
|
||||
else
|
||||
|
||||
@@ -5,12 +5,12 @@ class Voice::StatusUpdateService
|
||||
'queued' => 'ringing',
|
||||
'initiated' => 'ringing',
|
||||
'ringing' => 'ringing',
|
||||
'in-progress' => 'in-progress',
|
||||
'inprogress' => 'in-progress',
|
||||
'answered' => 'in-progress',
|
||||
'in-progress' => 'in_progress',
|
||||
'inprogress' => 'in_progress',
|
||||
'answered' => 'in_progress',
|
||||
'completed' => 'completed',
|
||||
'busy' => 'no-answer',
|
||||
'no-answer' => 'no-answer',
|
||||
'busy' => 'no_answer',
|
||||
'no-answer' => 'no_answer',
|
||||
'failed' => 'failed',
|
||||
'canceled' => 'failed'
|
||||
}.freeze
|
||||
@@ -19,13 +19,10 @@ class Voice::StatusUpdateService
|
||||
normalized_status = normalize_status(call_status)
|
||||
return if normalized_status.blank?
|
||||
|
||||
conversation = account.conversations.find_by(identifier: call_sid)
|
||||
return unless conversation
|
||||
call = Call.find_by_provider_call_id(:twilio, call_sid)
|
||||
return unless call
|
||||
|
||||
Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid
|
||||
).process_status_update(
|
||||
Voice::CallStatus::Manager.new(call: call).process_status_update(
|
||||
normalized_status,
|
||||
duration: payload_duration,
|
||||
timestamp: payload_timestamp
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# Implementation plan: Wire unified `Call` model into the Twilio voice flow
|
||||
|
||||
The `Call` model (`enterprise/app/models/call.rb`) and `calls` migration (`db/migrate/20260408170902_create_calls.rb`) are already merged. This plan covers the remaining work: wiring the Twilio voice flow to the `Call` model, moving state out of `conversation.additional_attributes` / `conversation.identifier`, and supporting multiple calls per conversation for `lock_to_single_conversation` inboxes.
|
||||
|
||||
No data migration — feature branch only.
|
||||
|
||||
## Guiding principles
|
||||
|
||||
- Single source of truth for call state is the `Call` record. Nothing call-related lives on `conversation.additional_attributes` anymore.
|
||||
- `conversation.identifier` is **not** used for voice anymore. Lookups go through `Call.find_by(provider: :twilio, provider_call_id: call_sid)`.
|
||||
- Conference naming keys off the `Call` id: `conf_account_{account_id}_call_{call_id}`.
|
||||
- Messages match to calls by `content_attributes.data.call_sid` — each call gets its own bubble.
|
||||
- Standardized `voice_call` message `content_attributes.data` schema: `call_sid, status, call_direction, from_number, to_number, duration, recording_url, transcript, conference_sid`. Treated as a display projection of the `Call`, written by `CallMessageBuilder` / `CallStatus::Manager`.
|
||||
- Status values split: Call model uses underscored (`in_progress`, `no_answer`); message `content_attributes.data.status` uses hyphenated (`in-progress`, `no-answer`). `CallStatus::Manager` translates via `call.status.tr('_', '-')`.
|
||||
- `call_direction` on the frontend-facing payload uses `inbound`/`outbound` (via `Call#display_direction`) to match what `voice.js` and `ConversationCard.vue` already expect.
|
||||
- Conversation reuse: when `inbox.lock_to_single_conversation` is true, incoming calls append to the most recent non-resolved conversation for `(contact, inbox)`. Otherwise, create a new conversation. Each call gets its own `Call` record and `voice_call` message either way.
|
||||
- `ConversationCard.vue` derives its call badge from the latest `voice_call` message (`content_type === 'voice_call'`), not from a cache on the conversation. This keeps the card correct when subsequent non-call messages (SMS, notes, etc.) arrive after a call.
|
||||
|
||||
## 1. Call model adjustments
|
||||
|
||||
`enterprise/app/models/call.rb`
|
||||
|
||||
| Change | Notes |
|
||||
|---|---|
|
||||
| Add convenience accessors for Twilio `meta` keys | `conference_sid`, `conference_sid=`, `recording_sid`, `parent_call_sid`, `initiated_at`, `started_at`, `ended_at` — all read/write `self.meta ||= {}` |
|
||||
| Enum values | `provider: { twilio: 0, whatsapp: 1 }`, `direction: { incoming: 0, outgoing: 1 }` (matches current branch) |
|
||||
| Keep `belongs_to :contact` | Denormalized for easier queries |
|
||||
| Add scope `find_by_provider_call_id(provider, sid)` | One-liner for webhook lookups |
|
||||
|
||||
## 2. Conference naming
|
||||
|
||||
`enterprise/app/services/voice/conference/name.rb`
|
||||
|
||||
- Change `for(conversation)` → `for(call)` returning `"conf_account_#{call.account_id}_call_#{call.id}"`.
|
||||
- Update all call sites (`OutboundCallBuilder`, `InboundCallBuilder`, conference manager lookups).
|
||||
|
||||
## 3. `InboundCallBuilder`
|
||||
|
||||
`enterprise/app/services/voice/inbound_call_builder.rb`
|
||||
|
||||
- Find/create contact (unchanged).
|
||||
- Find/create conversation:
|
||||
- When `inbox.lock_to_single_conversation` is true: return the most recent non-resolved conversation for `(contact, inbox)`; otherwise `ConversationBuilder.new(...).perform`.
|
||||
- Do **not** set `conversation.identifier`.
|
||||
- Do **not** write `call_*` keys to `conversation.additional_attributes`.
|
||||
- Create the `Call` record:
|
||||
```ruby
|
||||
Call.create!(
|
||||
account:, inbox:, conversation:, contact:,
|
||||
provider: :twilio,
|
||||
direction: :incoming,
|
||||
status: 'ringing',
|
||||
provider_call_id: call_sid,
|
||||
meta: { initiated_at: Time.current.to_i }
|
||||
)
|
||||
```
|
||||
- Set `call.conference_sid = Voice::Conference::Name.for(call)` and save.
|
||||
- Invoke `CallMessageBuilder` with the `Call`; after message creation, `call.update!(message_id: message.id)`.
|
||||
|
||||
## 4. `OutboundCallBuilder`
|
||||
|
||||
`enterprise/app/services/voice/outbound_call_builder.rb`
|
||||
|
||||
- Create conversation (no `identifier`).
|
||||
- Create `Call` record with `status: 'ringing'`, `direction: :outgoing`, no `provider_call_id` yet.
|
||||
- Generate `conference_sid` via `Voice::Conference::Name.for(call)`, save on `Call`.
|
||||
- Call `inbox.channel.initiate_call(to:, conference_sid:, agent_id:)` → on response, set `call.provider_call_id = call_sid`, `call.accepted_by_agent_id = user.id`, save.
|
||||
- Do **not** set `conversation.additional_attributes['agent_id']` — it moves to `call.accepted_by_agent_id`.
|
||||
- Invoke `CallMessageBuilder`, link `call.message_id`.
|
||||
|
||||
## 5. `CallMessageBuilder`
|
||||
|
||||
`enterprise/app/services/voice/call_message_builder.rb`
|
||||
|
||||
- Accept `call` as input.
|
||||
- Lookup: `conversation.messages.find { |m| m.content_type == 'voice_call' && m.content_attributes.dig('data', 'call_sid') == call.provider_call_id }` — not "latest voice_call message in conversation".
|
||||
- On create, set `content_attributes.data` from the `Call` record using the standardized schema:
|
||||
- `call_sid` — `call.provider_call_id`
|
||||
- `status` — hyphenated (`call.status.tr('_', '-')`)
|
||||
- `call_direction` — `call.direction`
|
||||
- `from_number`, `to_number` — from the webhook payload
|
||||
- `duration` — nil until terminal
|
||||
- `recording_url`, `transcript` — nil (filled by recording/transcription flow, out of scope)
|
||||
- `conference_sid` — `call.conference_sid`
|
||||
- `meta.ringing_at` — timestamp
|
||||
- Return the message so the caller can set `call.message_id`.
|
||||
|
||||
## 6. `StatusUpdateService` + `CallStatus::Manager`
|
||||
|
||||
`enterprise/app/services/voice/status_update_service.rb`
|
||||
|
||||
- Lookup: `Call.find_by(provider: :twilio, provider_call_id: call_sid)` instead of `Conversation.find_by(identifier:)`.
|
||||
- Delegate to `CallStatus::Manager` with the `Call`.
|
||||
|
||||
`enterprise/app/services/voice/call_status/manager.rb`
|
||||
|
||||
- Update `call.status`, `call.duration_seconds`, `call.started_at` (when entering `in_progress`), `call.meta[:ended_at]` (on terminal).
|
||||
- Bump `conversation.last_activity_at` so the conversation surfaces in the list on call activity.
|
||||
- Update the matching voice_call message via `CallMessageBuilder`, matched by `call_sid`. The message's `content_attributes.data.status` (hyphenated) and `data.duration` are refreshed from the `Call`.
|
||||
- No writes to `conversation.additional_attributes`.
|
||||
|
||||
## 7. `ConferenceManager` + `ConferenceService`
|
||||
|
||||
`enterprise/app/services/voice/conference/manager.rb`
|
||||
|
||||
- Look up `Call` via `conference_sid` (stored on `Call`, not on conversation).
|
||||
- On `join` (agent): `call.update!(status: 'in_progress', accepted_by_agent_id: user_id)`. User ID resolved from the `ParticipantLabel` on the webhook (`agent-{user_id}-account-{account_id}`) — authoritative source. See **Agent identity resolution** below.
|
||||
- On `leave` / `end`: `call.update!(status: …, duration_seconds: …)`.
|
||||
- Remove `agent_joined` / `joined_at` / `joined_by` writes to `additional_attributes`.
|
||||
|
||||
**Agent identity resolution (participant label flow):**
|
||||
|
||||
1. Agent's browser Device SDK connects using JWT with identity `agent-{user_id}-account-{account_id}` (already set in `token_service.rb`).
|
||||
2. Twilio hits TwiML endpoint with `From=client:agent-{user_id}-account-{account_id}`.
|
||||
3. VoiceController parses the identity from `From`, renders `<Dial>` with `participantLabel="agent-{user_id}-account-{account_id}"`.
|
||||
4. Conference `participant-join` webhook includes the label; `ConferenceManager` parses `user_id` from it and calls `call.update!(accepted_by_agent_id: user_id)`.
|
||||
|
||||
This is stateless — no ordering dependency between the frontend `/conference#create` API call and the Twilio webhook. `/conference#create` still runs (for frontend intent/UI), but the webhook is the authoritative source for `accepted_by_agent_id`.
|
||||
|
||||
`enterprise/app/services/voice/provider/twilio/conference_service.rb`
|
||||
|
||||
- `ensure_conference_sid(call)` replaces reading/writing `conversation.additional_attributes['conference_sid']`.
|
||||
- `end_conference(call)` uses `call.conference_sid` from the Call record.
|
||||
|
||||
## 8. `CallSessionSyncService`
|
||||
|
||||
`enterprise/app/services/voice/call_session_sync_service.rb`
|
||||
|
||||
- Trivial post-refactor — takes the `Call` (resolved by the controller via `parent_call_sid` or `call_sid`) and records `parent_call_sid` in `call.meta` for `outbound-dial` child legs. That's it.
|
||||
- All other data (`conference_sid`, `direction`, `accepted_by_agent_id`) is already on the `Call`; nothing to reconcile.
|
||||
|
||||
## 9. `Twilio::VoiceController`
|
||||
|
||||
`enterprise/app/controllers/twilio/voice_controller.rb`
|
||||
|
||||
- Incoming (`POST /twilio/voice/call/:phone` with Twilio `Direction=inbound`): delegates to `InboundCallBuilder` — no `identifier` hack.
|
||||
- Twilio `Direction=outbound-api` / `outbound-dial`: resolve the parent `Call` by `parent_call_sid` from Twilio params → `Call.find_by(provider: :twilio, provider_call_id: parent_sid)` → pass to `CallSessionSyncService`.
|
||||
- Conference status callback: resolve `Call` by `conference_sid` (friendly_name).
|
||||
- Status callback: resolve `Call` by `call_sid` via `StatusUpdateService`.
|
||||
|
||||
## 10. API controllers
|
||||
|
||||
`enterprise/app/controllers/api/v1/accounts/conference_controller.rb`
|
||||
|
||||
- `#token` — unchanged (no call record needed yet).
|
||||
- `#create` (agent joins) — resolve the `Call` by `conversation_id` or a new `call_id` param; call `ConferenceService.ensure_conference_sid(call)` and `mark_agent_joined(call, user)`.
|
||||
- `#destroy` — ends the conference for the `Call`.
|
||||
|
||||
`enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb`
|
||||
|
||||
- Return `call: CallSerializer.render_as_json(call, view: :base)` (new) with `id, provider_call_id, conference_sid, status, direction` — keep returning `conversation_id, inbox_id` for frontend compat.
|
||||
|
||||
## 11. Frontend and cleanup
|
||||
|
||||
**Conversation card** — `app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue`
|
||||
|
||||
- `voiceCallData` reads from `lastMessageInChat.content_attributes.data` only when that message's `content_type === 'voice_call'`. Otherwise it returns `{ status: null, direction: null }` and the normal `MessagePreview` branch renders. Fixes the "Call ended" stale-card bug when a text message follows a call.
|
||||
|
||||
**Store mutation** — `app/javascript/dashboard/store/`
|
||||
|
||||
- `UPDATE_CONVERSATION_CALL_STATUS` mutation, its type, and the corresponding `commit` in `helper/voice.js` are removed. Only `UPDATE_MESSAGE_CALL_STATUS` remains (which updates the matched voice_call message's `content_attributes.data.status`).
|
||||
|
||||
**Enterprise Conversation override** — `enterprise/app/models/enterprise/conversation.rb`
|
||||
|
||||
- `allowed_keys?` override removed. It existed solely to dispatch `conversation.updated` events on `additional_attributes.call_status` changes; obsolete now that call state doesn't live on the conversation. The voice_call message still dispatches `message.updated` on status transitions, which is what the frontend listens to.
|
||||
|
||||
**Backend cleanup**
|
||||
|
||||
- `conversation.identifier` — no voice writes or reads remain.
|
||||
- `conversation.additional_attributes` — no voice writes remain. All state (`conference_sid`, `agent_id`, `call_started_at/ended_at`, `call_duration`, `agent_joined`, `joined_at`, `joined_by`, `call_status`, `call_direction`) lives on the `Call` record.
|
||||
- Existing conversations may carry stale call-state keys in their `additional_attributes` JSONB. Not cleaned up — feature branch, no production data, and nothing reads them anymore.
|
||||
|
||||
## 12. Specs
|
||||
|
||||
Behavior changes force updates to existing specs. New specs deferred per CLAUDE.md unless explicitly requested.
|
||||
|
||||
| File | Update |
|
||||
|---|---|
|
||||
| `spec/factories/calls.rb` | Add if missing — traits `:twilio_incoming`, `:twilio_outgoing`, `:whatsapp_incoming`, `:whatsapp_outgoing` |
|
||||
| `spec/enterprise/services/voice/inbound_call_builder_spec.rb` | Assert `Call` is created with right attrs + linked message; drop `conversation.identifier` assertions |
|
||||
| `spec/enterprise/services/voice/outbound_call_builder_spec.rb` | Same |
|
||||
| `spec/enterprise/services/voice/status_update_service_spec.rb` | Find the `Call`, not the conversation; assert updates on it |
|
||||
| `spec/enterprise/services/voice/call_session_sync_service_spec.rb` | Resolve via parent call record |
|
||||
| `spec/enterprise/controllers/twilio/voice_controller_spec.rb` | Update lookups |
|
||||
| `spec/enterprise/services/voice/conference/manager_spec.rb` | If exists — update |
|
||||
| `spec/enterprise/models/call_spec.rb` | **Optional** — only if explicit coverage for enums/scopes/validations is wanted |
|
||||
|
||||
## 13. Out of scope (for this PR)
|
||||
|
||||
- WhatsApp voice wiring to `Call` — no WhatsApp voice services on this branch; slots in later.
|
||||
- `recording` attachment flow (Twilio recording download + Whisper transcription).
|
||||
- Multiple-calls-per-conversation UI polish — backend supports it; UI verification deferred.
|
||||
- Collapsing `voice_call` message `content_attributes.data` into a pointer + embedding the `Call` in the message serializer. Considered and deferred — the current duplication is consistent with how other message types work (self-contained display payloads), avoids N+1 risk when loading messages, and is written by a single service so drift is managed. Revisit if the duplication starts causing bugs or when WhatsApp voice is wired in.
|
||||
|
||||
## Implementation order
|
||||
|
||||
One commit per step:
|
||||
|
||||
1. Step 1 (Call model helpers) + Step 2 (Conference::Name)
|
||||
2. Step 3 (InboundCallBuilder) + Step 5 (CallMessageBuilder)
|
||||
3. Step 4 (OutboundCallBuilder)
|
||||
4. Step 6 (StatusUpdateService + CallStatus::Manager)
|
||||
5. Step 7 (ConferenceManager + ConferenceService)
|
||||
6. Step 8 (CallSessionSyncService)
|
||||
7. Step 9 (VoiceController)
|
||||
8. Step 10 (API controllers)
|
||||
9. Step 11 (cleanup sweep: grep for `conversation.identifier` and `additional_attributes['call_…']` in `enterprise/app/services/voice/**` and `enterprise/app/controllers/**/voice*`)
|
||||
10. Step 12 (spec updates) — bundle with the step that changes the behavior
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **`Call#contact_id`** — kept. Denormalized for easier queries.
|
||||
2. **Enum naming** — `direction: { incoming: 0, outgoing: 1 }` (matches current branch).
|
||||
3. **Agent identity on conference `join` webhook** — resolved via `ParticipantLabel` (`agent-{user_id}-account-{account_id}`). Stateless, no ordering dependency on `/conference#create`. See §7 **Agent identity resolution**.
|
||||
Reference in New Issue
Block a user