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,
@@ -11,7 +11,7 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
def create
conversation = fetch_conversation_by_display_id
ensure_call_sid!(conversation)
return if claim_conversation!(conversation) == false
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
conference_sid = conference_service.ensure_conference_sid
@@ -33,6 +33,22 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
private
def claim_conversation!(conversation)
conversation.with_lock do
if conversation.assignee_id == current_user.id
ensure_call_sid!(conversation)
true
elsif conversation.assignee.present?
render_conflict_for_assigned_agent!(conversation.assignee.name)
false
else
ensure_call_sid!(conversation)
conversation.update!(assignee: current_user)
true
end
end
end
def ensure_call_sid!(conversation)
return conversation.identifier if conversation.identifier.present?
@@ -55,4 +71,8 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
authorize conversation, :show?
conversation
end
def render_conflict_for_assigned_agent!(agent_name)
render json: { error: "#{agent_name} is already handling the call." }, status: :conflict
end
end
@@ -11,11 +11,15 @@ class Voice::Provider::Twilio::ConferenceService
end
def mark_agent_joined(user:)
joined_by = { id: user.id, name: user.name }
merge_attributes(
'agent_joined' => true,
'joined_at' => Time.current.to_i,
'joined_by' => { id: user.id, name: user.name }
'joined_by' => joined_by
)
sync_message_join_metadata!(joined_by)
end
def end_conference
@@ -32,6 +36,18 @@ class Voice::Provider::Twilio::ConferenceService
conversation.update!(additional_attributes: current.merge(attrs))
end
def sync_message_join_metadata!(joined_by)
message = conversation.messages.voice_calls.order(created_at: :desc).first
return unless message
content_attributes = (message.content_attributes || {}).deep_dup
content_attributes['data'] ||= {}
content_attributes['data']['meta'] ||= {}
content_attributes['data']['meta']['joined_by'] = joined_by
message.update!(content_attributes: content_attributes)
end
def twilio_client
@twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token)
end
@@ -78,8 +78,9 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
expect(body['conference_sid']).to be_present
conversation.reload
expect(conversation.identifier).to eq('CALL123')
expect(conversation.assignee).to eq(agent)
expect(conference_service).to have_received(:ensure_conference_sid)
expect(conference_service).to have_received(:mark_agent_joined)
expect(conference_service).to have_received(:mark_agent_joined).with(user: agent)
end
it 'does not allow accessing conversations from inboxes without access' do
@@ -102,6 +103,39 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
expect(response).to have_http_status(:unprocessable_content)
end
it 'returns conflict when another agent is already assigned' do
other_agent = create(:user, account: account, role: :agent, name: 'Jane Agent')
create(:inbox_member, inbox: voice_inbox, user: other_agent)
conversation.update!(assignee: other_agent)
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:conflict)
expect(response.parsed_body['error']).to eq('Jane Agent is already handling the call.')
conversation.reload
expect(conversation.assignee).to eq(other_agent)
expect(conversation.identifier).to be_nil
expect(conference_service).not_to have_received(:ensure_conference_sid)
expect(conference_service).not_to have_received(:mark_agent_joined)
end
it 'allows the assigned agent to rejoin' do
conversation.update!(assignee: agent)
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:ok)
conversation.reload
expect(conversation.assignee).to eq(agent)
expect(conversation.identifier).to eq('CALL123')
expect(conference_service).to have_received(:ensure_conference_sid)
expect(conference_service).to have_received(:mark_agent_joined).with(user: agent)
end
end
end
@@ -32,12 +32,29 @@ describe Voice::Provider::Twilio::ConferenceService do
describe '#mark_agent_joined' do
it 'stores agent join metadata' do
agent = create(:user, account: account)
message = create(
:message,
account: account,
conversation: conversation,
inbox: channel.inbox,
sender: conversation.contact,
content_type: 'voice_call',
content_attributes: {
'data' => {
'status' => 'ringing',
'meta' => {}
}
}
)
service.mark_agent_joined(user: agent)
attrs = conversation.reload.additional_attributes
expect(attrs['agent_joined']).to be true
expect(attrs['joined_by']['id']).to eq(agent.id)
expect(message.reload.content_attributes.dig('data', 'meta', 'joined_by')).to eq(
{ 'id' => agent.id, 'name' => agent.name }
)
end
end