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.
14 KiB
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
Callrecord. Nothing call-related lives onconversation.additional_attributesanymore. conversation.identifieris not used for voice anymore. Lookups go throughCall.find_by(provider: :twilio, provider_call_id: call_sid).- Conference naming keys off the
Callid: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_callmessagecontent_attributes.dataschema:call_sid, status, call_direction, from_number, to_number, duration, recording_url, transcript, conference_sid. Treated as a display projection of theCall, written byCallMessageBuilder/CallStatus::Manager. - Status values split: Call model uses underscored (
in_progress,no_answer); messagecontent_attributes.data.statususes hyphenated (in-progress,no-answer).CallStatus::Managertranslates viacall.status.tr('_', '-'). call_directionon the frontend-facing payload usesinbound/outbound(viaCall#display_direction) to match whatvoice.jsandConversationCard.vuealready expect.- Conversation reuse: when
inbox.lock_to_single_conversationis true, incoming calls append to the most recent non-resolved conversation for(contact, inbox). Otherwise, create a new conversation. Each call gets its ownCallrecord andvoice_callmessage either way. ConversationCard.vuederives its call badge from the latestvoice_callmessage (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_conversationis true: return the most recent non-resolved conversation for(contact, inbox); otherwiseConversationBuilder.new(...).perform. - Do not set
conversation.identifier. - Do not write
call_*keys toconversation.additional_attributes.
- When
- Create the
Callrecord: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
CallMessageBuilderwith theCall; after message creation,call.update!(message_id: message.id).
4. OutboundCallBuilder
enterprise/app/services/voice/outbound_call_builder.rb
- Create conversation (no
identifier). - Create
Callrecord withstatus: 'ringing',direction: :outgoing, noprovider_call_idyet. - Generate
conference_sidviaVoice::Conference::Name.for(call), save onCall. - Call
inbox.channel.initiate_call(to:, conference_sid:, agent_id:)→ on response, setcall.provider_call_id = call_sid,call.accepted_by_agent_id = user.id, save. - Do not set
conversation.additional_attributes['agent_id']— it moves tocall.accepted_by_agent_id. - Invoke
CallMessageBuilder, linkcall.message_id.
5. CallMessageBuilder
enterprise/app/services/voice/call_message_builder.rb
- Accept
callas 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.datafrom theCallrecord using the standardized schema:call_sid—call.provider_call_idstatus— hyphenated (call.status.tr('_', '-'))call_direction—call.directionfrom_number,to_number— from the webhook payloadduration— nil until terminalrecording_url,transcript— nil (filled by recording/transcription flow, out of scope)conference_sid—call.conference_sidmeta.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 ofConversation.find_by(identifier:). - Delegate to
CallStatus::Managerwith theCall.
enterprise/app/services/voice/call_status/manager.rb
- Update
call.status,call.duration_seconds,call.started_at(when enteringin_progress),call.meta[:ended_at](on terminal). - Bump
conversation.last_activity_atso the conversation surfaces in the list on call activity. - Update the matching voice_call message via
CallMessageBuilder, matched bycall_sid. The message'scontent_attributes.data.status(hyphenated) anddata.durationare refreshed from theCall. - No writes to
conversation.additional_attributes.
7. ConferenceManager + ConferenceService
enterprise/app/services/voice/conference/manager.rb
- Look up
Callviaconference_sid(stored onCall, not on conversation). - On
join(agent):call.update!(status: 'in_progress', accepted_by_agent_id: user_id). User ID resolved from theParticipantLabelon 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_bywrites toadditional_attributes.
Agent identity resolution (participant label flow):
- Agent's browser Device SDK connects using JWT with identity
agent-{user_id}-account-{account_id}(already set intoken_service.rb). - Twilio hits TwiML endpoint with
From=client:agent-{user_id}-account-{account_id}. - VoiceController parses the identity from
From, renders<Dial>withparticipantLabel="agent-{user_id}-account-{account_id}". - Conference
participant-joinwebhook includes the label;ConferenceManagerparsesuser_idfrom it and callscall.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/writingconversation.additional_attributes['conference_sid'].end_conference(call)usescall.conference_sidfrom the Call record.
8. CallSessionSyncService
enterprise/app/services/voice/call_session_sync_service.rb
- Trivial post-refactor — takes the
Call(resolved by the controller viaparent_call_sidorcall_sid) and recordsparent_call_sidincall.metaforoutbound-dialchild legs. That's it. - All other data (
conference_sid,direction,accepted_by_agent_id) is already on theCall; nothing to reconcile.
9. Twilio::VoiceController
enterprise/app/controllers/twilio/voice_controller.rb
- Incoming (
POST /twilio/voice/call/:phonewith TwilioDirection=inbound): delegates toInboundCallBuilder— noidentifierhack. - Twilio
Direction=outbound-api/outbound-dial: resolve the parentCallbyparent_call_sidfrom Twilio params →Call.find_by(provider: :twilio, provider_call_id: parent_sid)→ pass toCallSessionSyncService. - Conference status callback: resolve
Callbyconference_sid(friendly_name). - Status callback: resolve
Callbycall_sidviaStatusUpdateService.
10. API controllers
enterprise/app/controllers/api/v1/accounts/conference_controller.rb
#token— unchanged (no call record needed yet).#create(agent joins) — resolve theCallbyconversation_idor a newcall_idparam; callConferenceService.ensure_conference_sid(call)andmark_agent_joined(call, user).#destroy— ends the conference for theCall.
enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb
- Return
call: CallSerializer.render_as_json(call, view: :base)(new) withid, provider_call_id, conference_sid, status, direction— keep returningconversation_id, inbox_idfor frontend compat.
11. Frontend and cleanup
Conversation card — app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
voiceCallDatareads fromlastMessageInChat.content_attributes.dataonly when that message'scontent_type === 'voice_call'. Otherwise it returns{ status: null, direction: null }and the normalMessagePreviewbranch renders. Fixes the "Call ended" stale-card bug when a text message follows a call.
Store mutation — app/javascript/dashboard/store/
UPDATE_CONVERSATION_CALL_STATUSmutation, its type, and the correspondingcommitinhelper/voice.jsare removed. OnlyUPDATE_MESSAGE_CALL_STATUSremains (which updates the matched voice_call message'scontent_attributes.data.status).
Enterprise Conversation override — enterprise/app/models/enterprise/conversation.rb
allowed_keys?override removed. It existed solely to dispatchconversation.updatedevents onadditional_attributes.call_statuschanges; obsolete now that call state doesn't live on the conversation. The voice_call message still dispatchesmessage.updatedon 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 theCallrecord.- Existing conversations may carry stale call-state keys in their
additional_attributesJSONB. 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. recordingattachment flow (Twilio recording download + Whisper transcription).- Multiple-calls-per-conversation UI polish — backend supports it; UI verification deferred.
- Collapsing
voice_callmessagecontent_attributes.datainto a pointer + embedding theCallin 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:
- Step 1 (Call model helpers) + Step 2 (Conference::Name)
- Step 3 (InboundCallBuilder) + Step 5 (CallMessageBuilder)
- Step 4 (OutboundCallBuilder)
- Step 6 (StatusUpdateService + CallStatus::Manager)
- Step 7 (ConferenceManager + ConferenceService)
- Step 8 (CallSessionSyncService)
- Step 9 (VoiceController)
- Step 10 (API controllers)
- Step 11 (cleanup sweep: grep for
conversation.identifierandadditional_attributes['call_…']inenterprise/app/services/voice/**andenterprise/app/controllers/**/voice*) - Step 12 (spec updates) — bundle with the step that changes the behavior
Decisions
Call#contact_id— kept. Denormalized for easier queries.- Enum naming —
direction: { incoming: 0, outgoing: 1 }(matches current branch). - Agent identity on conference
joinwebhook — resolved viaParticipantLabel(agent-{user_id}-account-{account_id}). Stateless, no ordering dependency on/conference#create. See §7 Agent identity resolution.