Fix inbound voice call ownership flow

This commit is contained in:
Muhsin
2026-03-11 20:50:08 +04:00
parent 6e46be36c8
commit fac4675dc5
14 changed files with 518 additions and 50 deletions
@@ -0,0 +1,80 @@
import { ref } from 'vue';
import { mount } from '@vue/test-utils';
import VoiceCallBubble from './VoiceCall.vue';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
let messageContext;
vi.mock('../provider.js', () => ({
useMessageContext: () => messageContext,
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params = {}) => {
const translations = {
'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS': 'Call in progress',
'CONVERSATION.VOICE_CALL.AGENT_ANSWERED': `${params.agentName} answered`,
'CONVERSATION.VOICE_CALL.YOU_ANSWERED': 'You answered',
'CONVERSATION.VOICE_CALL.THEY_ANSWERED': 'They answered',
'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET': 'Not answered yet',
'CONVERSATION.VOICE_CALL.CALL_ENDED': 'Call ended',
'CONVERSATION.VOICE_CALL.NO_ANSWER': 'No answer',
};
return translations[key] || key;
},
}),
}));
const mountComponent = () =>
mount(VoiceCallBubble, {
global: {
stubs: {
BaseBubble: {
template: '<div><slot /></div>',
},
Icon: true,
},
mocks: {
$t: key => key,
},
},
});
describe('VoiceCall.vue', () => {
beforeEach(() => {
messageContext = {
contentAttributes: ref({
data: {
status: VOICE_CALL_STATUS.IN_PROGRESS,
meta: {},
},
}),
currentUserId: ref(1),
messageType: ref(MESSAGE_TYPES.INCOMING),
};
});
it('shows the answering agent name when another agent answers the call', () => {
messageContext.contentAttributes.value.data.meta.joinedBy = {
id: 2,
name: 'Ben',
};
const wrapper = mountComponent();
expect(wrapper.text()).toContain('Ben answered');
});
it('shows "You answered" when the current user answered the call', () => {
messageContext.contentAttributes.value.data.meta.joinedBy = {
id: 1,
name: 'John',
};
const wrapper = mountComponent();
expect(wrapper.text()).toContain('You answered');
});
});
@@ -1,21 +1,12 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
const LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const SUBTEXT_MAP = {
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
@@ -30,38 +21,69 @@ const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const { contentAttributes, messageType } = useMessageContext();
const { t } = useI18n();
const { contentAttributes, currentUserId, messageType } = useMessageContext();
const data = computed(() => contentAttributes.value?.data);
const status = computed(() => data.value?.status?.toString());
const joinedBy = computed(() => {
return data.value?.meta?.joinedBy || data.value?.meta?.joined_by;
});
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
const didCurrentUserAnswer = computed(() => {
return joinedBy.value?.id === currentUserId.value;
});
const subtextKey = computed(() => {
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
const label = computed(() => {
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
if (status.value === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? t('CONVERSATION.VOICE_CALL.OUTGOING_CALL')
: t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
? t('CONVERSATION.VOICE_CALL.MISSED_CALL')
: t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
});
const subtext = computed(() => {
if (status.value === VOICE_CALL_STATUS.RINGING) {
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
if (didCurrentUserAnswer.value) {
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
if (joinedBy.value?.name) {
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
agentName: joinedBy.value.name,
});
}
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
}
return isFailed.value
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
});
const iconName = computed(() => {
@@ -92,10 +114,10 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
<div class="flex overflow-hidden flex-col flex-grow">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }}
{{ label }}
</span>
<span class="text-xs text-n-slate-11">
{{ $t(subtextKey) }}
{{ subtext }}
</span>
</div>
</div>
@@ -0,0 +1,79 @@
import { createPinia, setActivePinia } from 'pinia';
import { useAlert } from 'dashboard/composables';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallSession } from '../useCallSession';
vi.mock('vue', async importOriginal => {
const actual = await importOriginal();
return {
...actual,
onMounted: vi.fn(),
onUnmounted: vi.fn(),
};
});
vi.mock('dashboard/composables', () => ({
useAlert: vi.fn(),
}));
vi.mock('dashboard/api/channel/voice/voiceAPIClient', () => ({
default: {
joinConference: vi.fn(),
leaveConference: vi.fn(),
},
}));
vi.mock('dashboard/api/channel/voice/twilioVoiceClient', () => ({
default: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
initializeDevice: vi.fn(),
joinClientCall: vi.fn(),
endClientCall: vi.fn(),
},
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: vi.fn(key => key),
}),
}));
describe('useCallSession', () => {
let consoleErrorSpy;
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('shows the backend conflict message when another agent already claimed the call', async () => {
TwilioVoiceClient.initializeDevice.mockResolvedValue({ device: true });
VoiceAPI.joinConference.mockRejectedValue({
response: {
data: {
error: 'Jane Agent is already handling the call.',
},
},
});
const { joinCall } = useCallSession();
const result = await joinCall({
conversationId: 42,
inboxId: 1,
callSid: 'CALL123',
});
expect(result).toBeNull();
expect(useAlert).toHaveBeenCalledWith(
'Jane Agent is already handling the call.'
);
expect(TwilioVoiceClient.joinClientCall).not.toHaveBeenCalled();
});
});
@@ -1,16 +1,20 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useAlert } from 'dashboard/composables';
import { useCallsStore } from 'dashboard/stores/calls';
import Timer from 'dashboard/helper/Timer';
export function useCallSession() {
const callsStore = useCallsStore();
const { t } = useI18n();
const isJoining = ref(false);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
callDuration.value = elapsed;
});
const handleCallDisconnected = () => callsStore.clearActiveCall();
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
@@ -30,15 +34,17 @@ export function useCallSession() {
);
onMounted(() => {
TwilioVoiceClient.addEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
TwilioVoiceClient.addEventListener(
'call:disconnected',
handleCallDisconnected
);
});
onUnmounted(() => {
durationTimer.stop();
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
TwilioVoiceClient.removeEventListener(
'call:disconnected',
handleCallDisconnected
);
});
@@ -73,6 +79,7 @@ export function useCallSession() {
return { conferenceSid: joinResponse?.conference_sid };
} catch (error) {
useAlert(error.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
// eslint-disable-next-line no-console
console.error('Failed to join call:', error);
return null;
@@ -0,0 +1,116 @@
import { createPinia, setActivePinia } from 'pinia';
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
import { useCallsStore } from 'dashboard/stores/calls';
import {
handleVoiceCallCreated,
handleVoiceCallUpdated,
syncConversationCallVisibility,
} from '../voice';
vi.mock('dashboard/api/channel/voice/twilioVoiceClient', () => ({
default: {
endClientCall: vi.fn(),
},
}));
const buildVoiceMessage = overrides => ({
content_type: CONTENT_TYPES.VOICE_CALL,
content_attributes: {
data: {
call_sid: 'CALL123',
status: 'ringing',
call_direction: 'inbound',
},
},
conversation_id: 42,
conversation: {
assignee_id: null,
},
sender: {
id: 7,
},
...overrides,
});
describe('voice helper', () => {
const commit = vi.fn();
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('shows inbound calls to everyone when the conversation is unassigned', () => {
const callsStore = useCallsStore();
handleVoiceCallCreated(buildVoiceMessage(), 1);
expect(callsStore.calls).toEqual([
expect.objectContaining({
callSid: 'CALL123',
conversationId: 42,
}),
]);
});
it('hides inbound calls when they are assigned to another agent', () => {
const callsStore = useCallsStore();
handleVoiceCallCreated(
buildVoiceMessage({
conversation: {
assignee_id: 9,
},
}),
1
);
expect(callsStore.calls).toEqual([]);
});
it('removes a visible call when an update shows it is assigned to another agent', () => {
const callsStore = useCallsStore();
callsStore.addCall({
callSid: 'CALL123',
conversationId: 42,
callDirection: 'inbound',
senderId: 7,
});
handleVoiceCallUpdated(
commit,
buildVoiceMessage({
conversation: {
assignee_id: 9,
},
}),
1
);
expect(callsStore.calls).toEqual([]);
});
it('removes the call widget when the conversation assignment changes to another agent', () => {
const callsStore = useCallsStore();
callsStore.addCall({
callSid: 'CALL123',
conversationId: 42,
callDirection: 'inbound',
senderId: 7,
});
syncConversationCallVisibility(
{
id: 42,
meta: {
assignee: {
id: 9,
},
},
},
1
);
expect(callsStore.calls).toEqual([]);
});
});
+61 -7
View File
@@ -22,6 +22,26 @@ 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) => {
return !!assigneeId && assigneeId !== currentUserId;
};
const shouldShowCall = ({
callDirection,
senderId,
assigneeId,
currentUserId,
}) => {
return (
!shouldSkipCall(callDirection, senderId, currentUserId) &&
!isAssignedToAnotherAgent(assigneeId, currentUserId)
);
};
function extractCallData(message) {
const contentData = message?.content_attributes?.data || {};
return {
@@ -29,6 +49,7 @@ function extractCallData(message) {
status: contentData.status,
callDirection: contentData.call_direction,
conversationId: message?.conversation_id,
assigneeId: extractAssigneeId(message?.conversation),
senderId: message?.sender?.id,
};
}
@@ -36,10 +57,19 @@ function extractCallData(message) {
export function handleVoiceCallCreated(message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, callDirection, conversationId, senderId } =
const { callSid, callDirection, conversationId, assigneeId, senderId } =
extractCallData(message);
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
if (
!shouldShowCall({
callDirection,
senderId,
assigneeId,
currentUserId,
})
) {
return;
}
const callsStore = useCallsStore();
callsStore.addCall({
@@ -53,8 +83,14 @@ export function handleVoiceCallCreated(message, currentUserId) {
export function handleVoiceCallUpdated(commit, message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, status, callDirection, conversationId, senderId } =
extractCallData(message);
const {
callSid,
status,
callDirection,
conversationId,
assigneeId,
senderId,
} = extractCallData(message);
const callsStore = useCallsStore();
@@ -64,9 +100,19 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
const isNewCall =
status === 'ringing' &&
!shouldSkipCall(callDirection, senderId, currentUserId);
if (
!shouldShowCall({
callDirection,
senderId,
assigneeId,
currentUserId,
})
) {
callsStore.removeCallsForConversation(conversationId);
return;
}
const isNewCall = status === 'ringing';
if (isNewCall) {
callsStore.addCall({
@@ -77,3 +123,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
});
}
}
export function syncConversationCallVisibility(conversation, currentUserId) {
const assigneeId = extractAssigneeId(conversation);
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
const callsStore = useCallsStore();
callsStore.removeCallsForConversation(conversation.id);
}
@@ -81,6 +81,7 @@
"MISSED_CALL": "Missed call",
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"AGENT_ANSWERED": "{agentName} answered",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered"
},
@@ -15,6 +15,7 @@ import * as Sentry from '@sentry/vue';
import {
handleVoiceCallCreated,
handleVoiceCallUpdated,
syncConversationCallVisibility,
} from 'dashboard/helper/voice';
export const hasMessageFailedWithExternalError = pendingMessage => {
@@ -391,18 +392,19 @@ const actions = {
}
},
updateConversation({ commit, dispatch }, conversation) {
const {
meta: { sender },
} = conversation;
updateConversation({ commit, dispatch, rootGetters }, conversation) {
const sender = conversation.meta?.sender;
commit(types.UPDATE_CONVERSATION, conversation);
syncConversationCallVisibility(conversation, rootGetters?.getCurrentUserID);
dispatch('conversationLabels/setConversationLabel', {
id: conversation.id,
data: conversation.labels,
});
dispatch('contacts/setContact', sender);
if (sender) {
dispatch('contacts/setContact', sender);
}
},
updateConversationLastActivity(
@@ -1,4 +1,5 @@
import axios from 'axios';
import { createPinia, setActivePinia } from 'pinia';
import actions, {
hasMessageFailedWithExternalError,
} from '../../conversations/actions';
@@ -56,6 +57,11 @@ describe('#hasMessageFailedWithExternalError', () => {
});
describe('#actions', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
describe('#getConversation', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
+14
View File
@@ -40,6 +40,20 @@ export const useCallsStore = defineStore('calls', {
this.calls = this.calls.filter(c => c.callSid !== callSid);
},
removeCallsForConversation(conversationId) {
const callsToRemove = this.calls.filter(
call => call.conversationId === conversationId
);
if (callsToRemove.some(call => call.isActive)) {
TwilioVoiceClient.endClientCall();
}
this.calls = this.calls.filter(
call => call.conversationId !== conversationId
);
},
setCallActive(callSid) {
this.calls = this.calls.map(call => ({
...call,