Files
chatwoot/app/javascript/dashboard/store/modules/conversations/actions.js
T
353089473e feat(voice): Assignment aware visibility and join conflict for inbound calls (#14333)
### 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>
2026-04-30 18:38:10 +04:00

539 lines
15 KiB
JavaScript

import types from '../../mutation-types';
import ConversationApi from '../../../api/inbox/conversation';
import MessageApi from '../../../api/inbox/message';
import { MESSAGE_STATUS, MESSAGE_TYPE } from 'shared/constants/messages';
import { createPendingMessage } from 'dashboard/helper/commons';
import {
buildConversationList,
isOnMentionsView,
isOnParticipatingView,
isOnUnattendedView,
isOnFoldersView,
} from './helpers/actionHelpers';
import messageReadActions from './actions/messageReadActions';
import messageTranslateActions from './actions/messageTranslateActions';
import * as Sentry from '@sentry/vue';
import {
handleVoiceCallCreated,
handleVoiceCallUpdated,
syncConversationCallVisibility,
} from 'dashboard/helper/voice';
export const hasMessageFailedWithExternalError = pendingMessage => {
// This helper is used to check if the message has failed with an external error.
// We have two cases
// 1. Messages that fail from the UI itself (due to large attachments or a failed network):
// In this case, the message will have a status of failed but no external error. So we need to create that message again
// 2. Messages sent from Chatwoot but failed to deliver to the customer for some reason (user blocking or client system down):
// In this case, the message will have a status of failed and an external error. So we need to retry that message
const { content_attributes: contentAttributes, status } = pendingMessage;
const externalError = contentAttributes?.external_error ?? '';
return status === MESSAGE_STATUS.FAILED && externalError !== '';
};
// actions
const actions = {
getConversation: async ({ commit }, conversationId) => {
try {
const response = await ConversationApi.show(conversationId);
commit(types.UPDATE_CONVERSATION, response.data);
commit(`contacts/${types.SET_CONTACT_ITEM}`, response.data.meta.sender);
} catch (error) {
// Ignore error
}
},
fetchAllConversations: async ({ commit, state, dispatch }) => {
commit(types.SET_LIST_LOADING_STATUS);
try {
const params = state.conversationFilters;
const {
data: { data },
} = await ConversationApi.get(params);
buildConversationList(
{ commit, dispatch },
params,
data,
params.assigneeType
);
} catch (error) {
// Handle error
}
},
fetchFilteredConversations: async ({ commit, dispatch }, params) => {
commit(types.SET_LIST_LOADING_STATUS);
try {
const { data } = await ConversationApi.filter(params);
buildConversationList(
{ commit, dispatch },
params,
data,
'appliedFilters'
);
} catch (error) {
// Handle error
}
},
emptyAllConversations({ commit }) {
commit(types.EMPTY_ALL_CONVERSATION);
},
clearSelectedState({ commit }) {
commit(types.CLEAR_CURRENT_CHAT_WINDOW);
},
fetchPreviousMessages: async ({ commit }, data) => {
try {
const {
data: { meta, payload },
} = await MessageApi.getPreviousMessages(data);
commit(`conversationMetadata/${types.SET_CONVERSATION_METADATA}`, {
id: data.conversationId,
data: meta,
});
commit(types.SET_PREVIOUS_CONVERSATIONS, {
id: data.conversationId,
data: payload,
});
if (!payload.length) {
commit(types.SET_ALL_MESSAGES_LOADED, data.conversationId);
}
} catch (error) {
// Handle error
}
},
fetchAllAttachments: async ({ commit }, conversationId) => {
let attachments = [];
try {
const { data } = await ConversationApi.getAllAttachments(conversationId);
attachments = data.payload;
} catch (error) {
// in case of error, log the error and continue
Sentry.setContext('Conversation', {
id: conversationId,
});
Sentry.captureException(error);
} finally {
// we run the commit even if the request fails
// this ensures that the `attachment` variable is always present on chat
commit(types.SET_ALL_ATTACHMENTS, {
id: conversationId,
data: attachments,
});
}
},
syncActiveConversationMessages: async (
{ commit, state, dispatch },
{ conversationId }
) => {
const { allConversations, syncConversationsMessages } = state;
const lastMessageId = syncConversationsMessages[conversationId];
const selectedChat = allConversations.find(
conversation => conversation.id === conversationId
);
if (!selectedChat) return;
try {
const { messages } = selectedChat;
// Fetch all the messages after the last message id
const {
data: { meta, payload },
} = await MessageApi.getPreviousMessages({
conversationId,
after: lastMessageId,
});
commit(`conversationMetadata/${types.SET_CONVERSATION_METADATA}`, {
id: conversationId,
data: meta,
});
// Find the messages that are not already present in the store
const missingMessages = payload.filter(
message => !messages.find(item => item.id === message.id)
);
selectedChat.messages.push(...missingMessages);
// Sort the messages by created_at
const sortedMessages = selectedChat.messages.sort((a, b) => {
return new Date(a.created_at) - new Date(b.created_at);
});
commit(types.SET_MISSING_MESSAGES, {
id: conversationId,
data: sortedMessages,
});
commit(types.SET_LAST_MESSAGE_ID_IN_SYNC_CONVERSATION, {
conversationId,
messageId: null,
});
dispatch('markMessagesRead', { id: conversationId }, { root: true });
} catch (error) {
// Handle error
}
},
setConversationLastMessageId: async (
{ commit, state },
{ conversationId }
) => {
const { allConversations } = state;
const selectedChat = allConversations.find(
conversation => conversation.id === conversationId
);
if (!selectedChat) return;
const { messages } = selectedChat;
const lastMessage = messages.last();
if (!lastMessage) return;
commit(types.SET_LAST_MESSAGE_ID_IN_SYNC_CONVERSATION, {
conversationId,
messageId: lastMessage.id,
});
},
async setActiveChat({ commit, dispatch }, { data, after }) {
commit(types.SET_CURRENT_CHAT_WINDOW, data);
commit(types.CLEAR_ALL_MESSAGES_LOADED, data.id);
if (data.dataFetched === undefined) {
try {
await dispatch('fetchPreviousMessages', {
after,
before: data.messages[0].id,
conversationId: data.id,
});
commit(types.SET_CHAT_DATA_FETCHED, data.id);
} catch (error) {
// Ignore error
}
}
},
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
try {
const response = await ConversationApi.assignAgent({
conversationId,
agentId,
});
dispatch('setCurrentChatAssignee', {
conversationId,
assignee: response.data,
});
} catch (error) {
// Handle error
}
},
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
commit(types.ASSIGN_AGENT, { conversationId, assignee });
},
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
try {
const response = await ConversationApi.assignTeam({
conversationId,
teamId,
});
dispatch('setCurrentChatTeam', { team: response.data, conversationId });
} catch (error) {
// Handle error
}
},
setCurrentChatTeam({ commit }, { team, conversationId }) {
commit(types.ASSIGN_TEAM, { team, conversationId });
},
toggleStatus: async (
{ commit },
{ conversationId, status, snoozedUntil = null, customAttributes = null }
) => {
try {
// Update custom attributes first if provided
if (customAttributes) {
await ConversationApi.updateCustomAttributes({
conversationId,
customAttributes,
});
commit(types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES, {
conversationId,
customAttributes,
});
}
const {
data: {
payload: {
current_status: updatedStatus,
snoozed_until: updatedSnoozedUntil,
} = {},
} = {},
} = await ConversationApi.toggleStatus({
conversationId,
status,
snoozedUntil,
});
commit(types.CHANGE_CONVERSATION_STATUS, {
conversationId,
status: updatedStatus,
snoozedUntil: updatedSnoozedUntil,
});
} catch (error) {
// Handle error
}
},
createPendingMessageAndSend: async ({ dispatch }, data) => {
const pendingMessage = createPendingMessage(data);
dispatch('sendMessageWithData', pendingMessage);
},
sendMessageWithData: async ({ commit }, pendingMessage) => {
const { conversation_id: conversationId, id } = pendingMessage;
try {
commit(types.ADD_MESSAGE, {
...pendingMessage,
status: MESSAGE_STATUS.PROGRESS,
});
const response = hasMessageFailedWithExternalError(pendingMessage)
? await MessageApi.retry(conversationId, id)
: await MessageApi.create(pendingMessage);
commit(types.ADD_MESSAGE, {
...response.data,
status: MESSAGE_STATUS.SENT,
});
commit(types.ADD_CONVERSATION_ATTACHMENTS, {
...response.data,
status: MESSAGE_STATUS.SENT,
});
} catch (error) {
const errorMessage = error.response
? error.response.data.error
: undefined;
commit(types.ADD_MESSAGE, {
...pendingMessage,
meta: {
error: errorMessage,
},
status: MESSAGE_STATUS.FAILED,
});
throw error;
}
},
addMessage({ commit, rootGetters }, message) {
commit(types.ADD_MESSAGE, message);
if (message.message_type === MESSAGE_TYPE.INCOMING) {
commit(types.SET_CONVERSATION_CAN_REPLY, {
conversationId: message.conversation_id,
canReply: true,
});
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
}
handleVoiceCallCreated(message, rootGetters?.getCurrentUserID);
},
updateMessage({ commit, rootGetters }, message) {
commit(types.ADD_MESSAGE, message);
handleVoiceCallUpdated(commit, message, rootGetters?.getCurrentUserID);
},
deleteMessage: async function deleteLabels(
{ commit },
{ conversationId, messageId }
) {
try {
const { data } = await MessageApi.delete(conversationId, messageId);
commit(types.ADD_MESSAGE, data);
commit(types.DELETE_CONVERSATION_ATTACHMENTS, data);
} catch (error) {
throw new Error(error);
}
},
deleteConversation: async ({ commit, dispatch }, conversationId) => {
try {
await ConversationApi.delete(conversationId);
commit(types.DELETE_CONVERSATION, conversationId);
dispatch('conversationStats/get', {}, { root: true });
} catch (error) {
throw new Error(error);
}
},
addConversation({ commit, state, dispatch, rootState }, conversation) {
const { currentInbox, appliedFilters } = state;
const {
inbox_id: inboxId,
meta: { sender },
} = conversation;
const hasAppliedFilters = !!appliedFilters.length;
const isMatchingInboxFilter =
!currentInbox || Number(currentInbox) === inboxId;
if (
!hasAppliedFilters &&
!isOnFoldersView(rootState) &&
!isOnMentionsView(rootState) &&
!isOnParticipatingView(rootState) &&
!isOnUnattendedView(rootState) &&
isMatchingInboxFilter
) {
commit(types.ADD_CONVERSATION, conversation);
dispatch('contacts/setContact', sender);
}
},
addMentions({ dispatch, rootState }, conversation) {
if (isOnMentionsView(rootState)) {
dispatch('updateConversation', conversation);
}
},
addUnattended({ dispatch, rootState }, conversation) {
if (isOnUnattendedView(rootState)) {
dispatch('updateConversation', 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,
});
if (sender) dispatch('contacts/setContact', sender);
},
updateConversationLastActivity(
{ commit },
{ conversationId, lastActivityAt }
) {
commit(types.UPDATE_CONVERSATION_LAST_ACTIVITY, {
lastActivityAt,
conversationId,
});
},
setChatStatusFilter({ commit }, data) {
commit(types.CHANGE_CHAT_STATUS_FILTER, data);
},
setChatSortFilter({ commit }, data) {
commit(types.CHANGE_CHAT_SORT_FILTER, data);
},
updateAssignee({ commit }, data) {
commit(types.UPDATE_ASSIGNEE, data);
},
updateConversationContact({ commit }, data) {
if (data.id) {
commit(`contacts/${types.SET_CONTACT_ITEM}`, data);
}
commit(types.UPDATE_CONVERSATION_CONTACT, data);
},
setActiveInbox({ commit }, inboxId) {
commit(types.SET_ACTIVE_INBOX, inboxId);
},
muteConversation: async ({ commit }, conversationId) => {
try {
await ConversationApi.mute(conversationId);
commit(types.MUTE_CONVERSATION);
} catch (error) {
//
}
},
unmuteConversation: async ({ commit }, conversationId) => {
try {
await ConversationApi.unmute(conversationId);
commit(types.UNMUTE_CONVERSATION);
} catch (error) {
//
}
},
sendEmailTranscript: async (_, { conversationId, email }) => {
await ConversationApi.sendEmailTranscript({ conversationId, email });
},
updateCustomAttributes: async (
{ commit },
{ conversationId, customAttributes }
) => {
try {
const response = await ConversationApi.updateCustomAttributes({
conversationId,
customAttributes,
});
const { custom_attributes } = response.data;
commit(types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES, {
conversationId,
customAttributes: custom_attributes,
});
} catch (error) {
// Handle error
}
},
setConversationFilters({ commit }, data) {
commit(types.SET_CONVERSATION_FILTERS, data);
},
clearConversationFilters({ commit }) {
commit(types.CLEAR_CONVERSATION_FILTERS);
},
setChatListFilters({ commit }, data) {
commit(types.SET_CHAT_LIST_FILTERS, data);
},
updateChatListFilters({ commit }, data) {
commit(types.UPDATE_CHAT_LIST_FILTERS, data);
},
assignPriority: async ({ dispatch }, { conversationId, priority }) => {
try {
await ConversationApi.togglePriority({
conversationId,
priority,
});
dispatch('setCurrentChatPriority', {
priority,
conversationId,
});
} catch (error) {
// Handle error
}
},
setCurrentChatPriority({ commit }, { priority, conversationId }) {
commit(types.ASSIGN_PRIORITY, { priority, conversationId });
},
setContextMenuChatId({ commit }, chatId) {
commit(types.SET_CONTEXT_MENU_CHAT_ID, chatId);
},
getInboxCaptainAssistantById: async ({ commit }, conversationId) => {
try {
const response = await ConversationApi.getInboxAssistant(conversationId);
commit(types.SET_INBOX_CAPTAIN_ASSISTANT, response.data);
} catch (error) {
// Handle error
}
},
...messageReadActions,
...messageTranslateActions,
};
export default actions;