### Description Inbound voice calls now route ownership cleanly: the call widget is hidden from agents who aren't the conversation assignee, the first agent to pick up becomes the assignee, and any later join attempt by another agent is rejected with a clear "<agent> is already handling the call." alert. Closes https://linear.app/chatwoot/issue/PLA-98/inbound-voice-calls-assignment-aware-visibility-auto-assignment-on ### How to test 1. As Agent A and Agent B, open the dashboard for the same voice inbox in two browsers. 2. Place an inbound call to the inbox with the conversation **unassigned** — both agents should see the call widget. 3. Have Agent A click **Join**. Agent A's widget transitions to the active call; Agent B's widget disappears (conversation is now assigned to Agent A). 4. While the call is in progress, attempt to join from a third agent (e.g., via the bubble in the conversation timeline) — the join is rejected with the toast `Agent A is already handling the call.` 5. Resolve the conversation, then place a second call to a conversation that is already manually assigned to Agent A — only Agent A sees the widget; nobody else does. 6. Race test: trigger two near-simultaneous join attempts (two agents click Join within a few hundred ms of each other) — exactly one wins; the other gets the conflict alert. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
137 lines
3.3 KiB
JavaScript
137 lines
3.3 KiB
JavaScript
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
|
|
import { useCallsStore } from 'dashboard/stores/calls';
|
|
import types from 'dashboard/store/mutation-types';
|
|
|
|
export const TERMINAL_STATUSES = [
|
|
'completed',
|
|
'busy',
|
|
'failed',
|
|
'no-answer',
|
|
'canceled',
|
|
'missed',
|
|
'ended',
|
|
];
|
|
|
|
export const isInbound = direction => direction === 'inbound';
|
|
|
|
const isVoiceCallMessage = message => {
|
|
return CONTENT_TYPES.VOICE_CALL === message?.content_type;
|
|
};
|
|
|
|
const shouldSkipCall = (callDirection, senderId, currentUserId) => {
|
|
return callDirection === 'outbound' && senderId !== currentUserId;
|
|
};
|
|
|
|
const extractAssigneeId = conversation => {
|
|
return conversation?.assignee_id || conversation?.meta?.assignee?.id || null;
|
|
};
|
|
|
|
const isAssignedToAnotherAgent = (assigneeId, currentUserId) => {
|
|
if (currentUserId == null) return false;
|
|
return !!assigneeId && assigneeId !== currentUserId;
|
|
};
|
|
|
|
const shouldShowCall = ({
|
|
callDirection,
|
|
senderId,
|
|
assigneeId,
|
|
currentUserId,
|
|
}) => {
|
|
if (shouldSkipCall(callDirection, senderId, currentUserId)) return false;
|
|
// Outbound calls are scoped to the initiator via shouldSkipCall; the
|
|
// conversation may be auto-assigned to a different agent on creation, so
|
|
// skip the assignee filter for outbound to avoid hiding the caller's own widget.
|
|
if (callDirection === 'outbound') return true;
|
|
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
|
|
};
|
|
|
|
function extractCallData(message) {
|
|
const call = message?.call || {};
|
|
return {
|
|
callSid: call.provider_call_id,
|
|
status: call.status,
|
|
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
|
conversationId: message?.conversation_id,
|
|
assigneeId: extractAssigneeId(message?.conversation),
|
|
senderId: message?.sender?.id,
|
|
};
|
|
}
|
|
|
|
export function handleVoiceCallCreated(message, currentUserId) {
|
|
if (!isVoiceCallMessage(message)) return;
|
|
|
|
const { callSid, callDirection, conversationId, assigneeId, senderId } =
|
|
extractCallData(message);
|
|
|
|
if (
|
|
!shouldShowCall({
|
|
callDirection,
|
|
senderId,
|
|
assigneeId,
|
|
currentUserId,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const callsStore = useCallsStore();
|
|
callsStore.addCall({
|
|
callSid,
|
|
conversationId,
|
|
callDirection,
|
|
senderId,
|
|
});
|
|
}
|
|
|
|
export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
|
if (!isVoiceCallMessage(message)) return;
|
|
|
|
const {
|
|
callSid,
|
|
status,
|
|
callDirection,
|
|
conversationId,
|
|
assigneeId,
|
|
senderId,
|
|
} = extractCallData(message);
|
|
|
|
const callsStore = useCallsStore();
|
|
|
|
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
|
|
|
|
commit(types.UPDATE_MESSAGE_CALL_STATUS, {
|
|
conversationId,
|
|
callStatus: status,
|
|
callSid,
|
|
});
|
|
|
|
if (
|
|
!shouldShowCall({
|
|
callDirection,
|
|
senderId,
|
|
assigneeId,
|
|
currentUserId,
|
|
})
|
|
) {
|
|
callsStore.removeCall(callSid);
|
|
return;
|
|
}
|
|
|
|
if (status === 'ringing') {
|
|
callsStore.addCall({
|
|
callSid,
|
|
conversationId,
|
|
callDirection,
|
|
senderId,
|
|
});
|
|
}
|
|
}
|
|
|
|
export function syncConversationCallVisibility(conversation, currentUserId) {
|
|
const assigneeId = extractAssigneeId(conversation);
|
|
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
|
|
|
|
const callsStore = useCallsStore();
|
|
callsStore.removeCallsForConversation(conversation.id);
|
|
}
|