From bb4feec53ec57df94a9bc60c3de7b49441e19c2e Mon Sep 17 00:00:00 2001 From: Muhsin <12408980+muhsin-k@users.noreply.github.com> Date: Fri, 17 Apr 2026 19:44:11 +0400 Subject: [PATCH] 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. --- .../widgets/conversation/ConversationCard.vue | 11 +- app/javascript/dashboard/helper/voice.js | 7 +- .../store/modules/conversations/index.js | 13 -- .../conversations/specs/mutations.spec.js | 38 ---- .../dashboard/store/mutation-types.js | 1 - .../api/v1/accounts/conference_controller.rb | 24 +- .../v1/accounts/contacts/calls_controller.rb | 10 +- .../controllers/twilio/voice_controller.rb | 83 +++---- enterprise/app/models/call.rb | 18 ++ .../app/models/enterprise/conversation.rb | 11 - .../services/voice/call_message_builder.rb | 101 ++++----- .../voice/call_session_sync_service.rb | 91 +------- .../app/services/voice/call_status/manager.rb | 66 ++---- .../app/services/voice/conference/manager.rb | 57 ++--- .../app/services/voice/conference/name.rb | 4 +- .../services/voice/inbound_call_builder.rb | 77 +++---- .../services/voice/outbound_call_builder.rb | 64 ++---- .../provider/twilio/conference_service.rb | 27 +-- .../services/voice/status_update_service.rb | 19 +- voice_call_wiring.md | 214 ++++++++++++++++++ 20 files changed, 450 insertions(+), 486 deletions(-) create mode 100644 voice_call_wiring.md diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index f50485723..ba7a5c63d 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -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); diff --git a/app/javascript/dashboard/helper/voice.js b/app/javascript/dashboard/helper/voice.js index 9f753a811..0093f810c 100644 --- a/app/javascript/dashboard/helper/voice.js +++ b/app/javascript/dashboard/helper/voice.js @@ -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' && diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js index bffc2204a..509629001 100644 --- a/app/javascript/dashboard/store/modules/conversations/index.js +++ b/app/javascript/dashboard/store/modules/conversations/index.js @@ -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; diff --git a/app/javascript/dashboard/store/modules/conversations/specs/mutations.spec.js b/app/javascript/dashboard/store/modules/conversations/specs/mutations.spec.js index 09c89f0b0..6ac66353e 100644 --- a/app/javascript/dashboard/store/modules/conversations/specs/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/conversations/specs/mutations.spec.js @@ -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: [] }; diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index dcc64de8c..5cd2f5e6c 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -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', diff --git a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb index 3d802fc31..b20446413 100644 --- a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb @@ -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 diff --git a/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb index 11a352325..8217e1a37 100644 --- a/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb @@ -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 diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb index fdadd334c..0f1892d38 100644 --- a/enterprise/app/controllers/twilio/voice_controller.rb +++ b/enterprise/app/controllers/twilio/voice_controller.rb @@ -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! diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb index 9864a4167..b2346b4d5 100644 --- a/enterprise/app/models/call.rb +++ b/enterprise/app/models/call.rb @@ -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 diff --git a/enterprise/app/models/enterprise/conversation.rb b/enterprise/app/models/enterprise/conversation.rb index 48db11869..09cb2c569 100644 --- a/enterprise/app/models/enterprise/conversation.rb +++ b/enterprise/app/models/enterprise/conversation.rb @@ -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) diff --git a/enterprise/app/services/voice/call_message_builder.rb b/enterprise/app/services/voice/call_message_builder.rb index adf72f67c..d91e9204b 100644 --- a/enterprise/app/services/voice/call_message_builder.rb +++ b/enterprise/app/services/voice/call_message_builder.rb @@ -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 diff --git a/enterprise/app/services/voice/call_session_sync_service.rb b/enterprise/app/services/voice/call_session_sync_service.rb index 8ce80ae0e..b18810b71 100644 --- a/enterprise/app/services/voice/call_session_sync_service.rb +++ b/enterprise/app/services/voice/call_session_sync_service.rb @@ -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 diff --git a/enterprise/app/services/voice/call_status/manager.rb b/enterprise/app/services/voice/call_status/manager.rb index 82d7efde7..e7cc95f38 100644 --- a/enterprise/app/services/voice/call_status/manager.rb +++ b/enterprise/app/services/voice/call_status/manager.rb @@ -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 diff --git a/enterprise/app/services/voice/conference/manager.rb b/enterprise/app/services/voice/conference/manager.rb index 0b54f6ae8..d86b70457 100644 --- a/enterprise/app/services/voice/conference/manager.rb +++ b/enterprise/app/services/voice/conference/manager.rb @@ -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 diff --git a/enterprise/app/services/voice/conference/name.rb b/enterprise/app/services/voice/conference/name.rb index 027937b4c..39909bb6f 100644 --- a/enterprise/app/services/voice/conference/name.rb +++ b/enterprise/app/services/voice/conference/name.rb @@ -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 diff --git a/enterprise/app/services/voice/inbound_call_builder.rb b/enterprise/app/services/voice/inbound_call_builder.rb index 03981ac58..fa5afc691 100644 --- a/enterprise/app/services/voice/inbound_call_builder.rb +++ b/enterprise/app/services/voice/inbound_call_builder.rb @@ -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 diff --git a/enterprise/app/services/voice/outbound_call_builder.rb b/enterprise/app/services/voice/outbound_call_builder.rb index 1ebcade8a..aea29fa7d 100644 --- a/enterprise/app/services/voice/outbound_call_builder.rb +++ b/enterprise/app/services/voice/outbound_call_builder.rb @@ -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 diff --git a/enterprise/app/services/voice/provider/twilio/conference_service.rb b/enterprise/app/services/voice/provider/twilio/conference_service.rb index 30721bfb5..0aa24c816 100644 --- a/enterprise/app/services/voice/provider/twilio/conference_service.rb +++ b/enterprise/app/services/voice/provider/twilio/conference_service.rb @@ -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 diff --git a/enterprise/app/services/voice/status_update_service.rb b/enterprise/app/services/voice/status_update_service.rb index 8503d8d3b..7ff22f2c9 100644 --- a/enterprise/app/services/voice/status_update_service.rb +++ b/enterprise/app/services/voice/status_update_service.rb @@ -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 diff --git a/voice_call_wiring.md b/voice_call_wiring.md new file mode 100644 index 000000000..9b8571681 --- /dev/null +++ b/voice_call_wiring.md @@ -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 `` 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**.