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:
Muhsin
2026-04-17 19:44:11 +04:00
parent 3b89787c7f
commit bb4feec53e
20 changed files with 450 additions and 486 deletions
@@ -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);