Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,539 @@
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,
isOnUnattendedView,
isOnFoldersView,
} from './helpers/actionHelpers';
import messageReadActions from './actions/messageReadActions';
import messageTranslateActions from './actions/messageTranslateActions';
import * as Sentry from '@sentry/vue';
import {
handleVoiceCallCreated,
handleVoiceCallUpdated,
} 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) &&
!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 }, conversation) {
const {
meta: { sender },
} = conversation;
commit(types.UPDATE_CONVERSATION, conversation);
dispatch('conversationLabels/setConversationLabel', {
id: conversation.id,
data: conversation.labels,
});
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 }) => {
try {
await ConversationApi.sendEmailTranscript({ conversationId, email });
} catch (error) {
throw new Error(error);
}
},
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;

View File

@@ -0,0 +1,35 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
import ConversationApi from '../../../../api/inbox/conversation';
import mutationTypes from '../../../mutation-types';
export default {
markMessagesRead: async ({ commit }, data) => {
try {
const {
data: { id, agent_last_seen_at: lastSeen },
} = await ConversationApi.markMessageRead(data);
setTimeout(
() =>
commit(mutationTypes.UPDATE_MESSAGE_UNREAD_COUNT, { id, lastSeen }),
4000
);
} catch (error) {
// Handle error
}
},
markMessagesUnread: async ({ commit }, { id }) => {
try {
const {
data: { agent_last_seen_at: lastSeen, unread_count: unreadCount },
} = await ConversationApi.markMessagesUnread({ id });
commit(mutationTypes.UPDATE_MESSAGE_UNREAD_COUNT, {
id,
lastSeen,
unreadCount,
});
} catch (error) {
throwErrorMessage(error);
}
},
};

View File

@@ -0,0 +1,15 @@
import MessageApi from '../../../../api/inbox/message';
export default {
async translateMessage(_, { conversationId, messageId, targetLanguage }) {
try {
await MessageApi.translateMessage(
conversationId,
messageId,
targetLanguage
);
} catch (error) {
// ignore error
}
},
};

View File

@@ -0,0 +1,166 @@
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { applyPageFilters, applyRoleFilter, sortComparator } from './helpers';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import { matchesFilters } from './helpers/filterHelpers';
import {
getUserPermissions,
getUserRole,
} from '../../../helper/permissionsHelper';
import camelcaseKeys from 'camelcase-keys';
export const getSelectedChatConversation = ({
allConversations,
selectedChatId,
}) =>
allConversations.filter(conversation => conversation.id === selectedChatId);
const getters = {
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
},
getFilteredConversations: (
{ allConversations, chatSortFilter, appliedFilters },
_,
__,
rootGetters
) => {
const currentUser = rootGetters.getCurrentUser;
const currentUserId = rootGetters.getCurrentUser.id;
const currentAccountId = rootGetters.getCurrentAccountId;
const permissions = getUserPermissions(currentUser, currentAccountId);
const userRole = getUserRole(currentUser, currentAccountId);
return allConversations
.filter(conversation => {
const matchesFilterResult = matchesFilters(
conversation,
appliedFilters
);
const allowedForRole = applyRoleFilter(
conversation,
userRole,
permissions,
currentUserId
);
return matchesFilterResult && allowedForRole;
})
.sort((a, b) => sortComparator(a, b, chatSortFilter));
},
getSelectedChat: ({ selectedChatId, allConversations }) => {
const selectedChat = allConversations.find(
conversation => conversation.id === selectedChatId
);
return selectedChat || {};
},
getSelectedChatAttachments: ({ selectedChatId, attachments }) => {
return attachments[selectedChatId] || [];
},
getChatListFilters: ({ conversationFilters }) => conversationFilters,
getLastEmailInSelectedChat: (stage, _getters) => {
const selectedChat = _getters.getSelectedChat;
const { messages = [] } = selectedChat;
const lastEmail = [...messages].reverse().find(message => {
const { message_type: messageType } = message;
if (message.private) return false;
return [MESSAGE_TYPE.OUTGOING, MESSAGE_TYPE.INCOMING].includes(
messageType
);
});
return lastEmail;
},
getMineChats: (_state, _, __, rootGetters) => activeFilters => {
const currentUserID = rootGetters.getCurrentUser?.id;
return _state.allConversations.filter(conversation => {
const { assignee } = conversation.meta;
const isAssignedToMe = assignee && assignee.id === currentUserID;
const shouldFilter = applyPageFilters(conversation, activeFilters);
const isChatMine = isAssignedToMe && shouldFilter;
return isChatMine;
});
},
getAppliedConversationFiltersV2: _state => {
// TODO: Replace existing one with V2 after migrating the filters to use camelcase
return _state.appliedFilters.map(camelcaseKeys);
},
getAppliedConversationFilters: _state => {
return _state.appliedFilters;
},
getAppliedConversationFiltersQuery: _state => {
const hasAppliedFilters = _state.appliedFilters.length !== 0;
return hasAppliedFilters ? filterQueryGenerator(_state.appliedFilters) : [];
},
getUnAssignedChats: _state => activeFilters => {
return _state.allConversations.filter(conversation => {
const isUnAssigned = !conversation.meta.assignee;
const shouldFilter = applyPageFilters(conversation, activeFilters);
return isUnAssigned && shouldFilter;
});
},
getAllStatusChats: (_state, _, __, rootGetters) => activeFilters => {
const currentUser = rootGetters.getCurrentUser;
const currentUserId = rootGetters.getCurrentUser.id;
const currentAccountId = rootGetters.getCurrentAccountId;
const permissions = getUserPermissions(currentUser, currentAccountId);
const userRole = getUserRole(currentUser, currentAccountId);
return _state.allConversations.filter(conversation => {
const shouldFilter = applyPageFilters(conversation, activeFilters);
const allowedForRole = applyRoleFilter(
conversation,
userRole,
permissions,
currentUserId
);
return shouldFilter && allowedForRole;
});
},
getChatListLoadingStatus: ({ listLoadingStatus }) => listLoadingStatus,
getAllMessagesLoaded(_state) {
const [chat] = getSelectedChatConversation(_state);
return !chat || chat.allMessagesLoaded === undefined
? false
: chat.allMessagesLoaded;
},
getUnreadCount(_state) {
const [chat] = getSelectedChatConversation(_state);
if (!chat) return [];
return chat.messages.filter(
chatMessage =>
chatMessage.created_at * 1000 > chat.agent_last_seen_at * 1000 &&
chatMessage.message_type === 0 &&
chatMessage.private !== true
).length;
},
getChatStatusFilter: ({ chatStatusFilter }) => chatStatusFilter,
getChatSortFilter: ({ chatSortFilter }) => chatSortFilter,
getSelectedInbox: ({ currentInbox }) => currentInbox,
getConversationById: _state => conversationId => {
return _state.allConversations.find(
value => value.id === Number(conversationId)
);
},
getConversationParticipants: _state => {
return _state.conversationParticipants;
},
getConversationLastSeen: _state => {
return _state.conversationLastSeen;
},
getContextMenuChatId: _state => {
return _state.contextMenuChatId;
},
getCopilotAssistant: _state => {
return _state.copilotAssistant;
},
};
export default getters;

View File

@@ -0,0 +1,159 @@
import { CONVERSATION_PRIORITY_ORDER } from 'shared/constants/messages';
export const findPendingMessageIndex = (chat, message) => {
const { echo_id: tempMessageId } = message;
return chat.messages.findIndex(
m => m.id === message.id || m.id === tempMessageId
);
};
export const filterByStatus = (chatStatus, filterStatus) =>
filterStatus === 'all' ? true : chatStatus === filterStatus;
export const filterByInbox = (shouldFilter, inboxId, chatInboxId) => {
const isOnInbox = Number(inboxId) === chatInboxId;
return inboxId ? isOnInbox && shouldFilter : shouldFilter;
};
export const filterByTeam = (shouldFilter, teamId, chatTeamId) => {
const isOnTeam = Number(teamId) === chatTeamId;
return teamId ? isOnTeam && shouldFilter : shouldFilter;
};
export const filterByLabel = (shouldFilter, labels, chatLabels) => {
const isOnLabel = labels.every(label => chatLabels.includes(label));
return labels.length ? isOnLabel && shouldFilter : shouldFilter;
};
export const filterByUnattended = (
shouldFilter,
conversationType,
firstReplyOn,
waitingSince
) => {
return conversationType === 'unattended'
? (!firstReplyOn || !!waitingSince) && shouldFilter
: shouldFilter;
};
export const applyPageFilters = (conversation, filters) => {
const { inboxId, status, labels = [], teamId, conversationType } = filters;
const {
status: chatStatus,
inbox_id: chatInboxId,
labels: chatLabels = [],
meta = {},
first_reply_created_at: firstReplyOn,
waiting_since: waitingSince,
} = conversation;
const team = meta.team || {};
const { id: chatTeamId } = team;
let shouldFilter = filterByStatus(chatStatus, status);
shouldFilter = filterByInbox(shouldFilter, inboxId, chatInboxId);
shouldFilter = filterByTeam(shouldFilter, teamId, chatTeamId);
shouldFilter = filterByLabel(shouldFilter, labels, chatLabels);
shouldFilter = filterByUnattended(
shouldFilter,
conversationType,
firstReplyOn,
waitingSince
);
return shouldFilter;
};
/**
* Filters conversations based on user role and permissions
*
* @param {Object} conversation - The conversation object to check permissions for
* @param {string} role - The user's role (administrator, agent, etc.)
* @param {Array<string>} permissions - List of permission strings the user has
* @param {number|string} currentUserId - The ID of the current user
* @returns {boolean} - Whether the user has permissions to access this conversation
*/
export const applyRoleFilter = (
conversation,
role,
permissions,
currentUserId
) => {
// the role === "agent" check is typically not correct on it's own
// the backend handles this by checking the custom_role_id at the user model
// here however, the `getUserRole` returns "custom_role" if the id is present,
// so we can check the role === "agent" directly
if (['administrator', 'agent'].includes(role)) {
return true;
}
// Check for full conversation management permission
if (permissions.includes('conversation_manage')) {
return true;
}
const conversationAssignee = conversation.meta.assignee;
const isUnassigned = !conversationAssignee;
const isAssignedToUser = conversationAssignee?.id === currentUserId;
// Check unassigned management permission
if (permissions.includes('conversation_unassigned_manage')) {
return isUnassigned || isAssignedToUser;
}
// Check participating conversation management permission
if (permissions.includes('conversation_participating_manage')) {
return isAssignedToUser;
}
return false;
};
const SORT_OPTIONS = {
last_activity_at_asc: ['sortOnLastActivityAt', 'asc'],
last_activity_at_desc: ['sortOnLastActivityAt', 'desc'],
created_at_asc: ['sortOnCreatedAt', 'asc'],
created_at_desc: ['sortOnCreatedAt', 'desc'],
priority_asc: ['sortOnPriority', 'asc'],
priority_desc: ['sortOnPriority', 'desc'],
waiting_since_asc: ['sortOnWaitingSince', 'asc'],
waiting_since_desc: ['sortOnWaitingSince', 'desc'],
};
const sortAscending = (valueA, valueB) => valueA - valueB;
const sortDescending = (valueA, valueB) => valueB - valueA;
const getSortOrderFunction = sortOrder =>
sortOrder === 'asc' ? sortAscending : sortDescending;
const sortConfig = {
sortOnLastActivityAt: (a, b, sortDirection) =>
getSortOrderFunction(sortDirection)(a.last_activity_at, b.last_activity_at),
sortOnCreatedAt: (a, b, sortDirection) =>
getSortOrderFunction(sortDirection)(a.created_at, b.created_at),
sortOnPriority: (a, b, sortDirection) => {
const DEFAULT_FOR_NULL = sortDirection === 'asc' ? 5 : 0;
const p1 = CONVERSATION_PRIORITY_ORDER[a.priority] || DEFAULT_FOR_NULL;
const p2 = CONVERSATION_PRIORITY_ORDER[b.priority] || DEFAULT_FOR_NULL;
return getSortOrderFunction(sortDirection)(p1, p2);
},
sortOnWaitingSince: (a, b, sortDirection) => {
const sortFunc = getSortOrderFunction(sortDirection);
if (!a.waiting_since || !b.waiting_since) {
if (!a.waiting_since && !b.waiting_since) {
return sortFunc(a.created_at, b.created_at);
}
return sortFunc(a.waiting_since ? 0 : 1, b.waiting_since ? 0 : 1);
}
return sortFunc(a.waiting_since, b.waiting_since);
},
};
export const sortComparator = (a, b, sortKey) => {
const [sortMethod, sortDirection] =
SORT_OPTIONS[sortKey] || SORT_OPTIONS.last_activity_at_desc;
return sortConfig[sortMethod](a, b, sortDirection);
};

View File

@@ -0,0 +1,62 @@
import types from '../../../mutation-types';
export const setPageFilter = ({ dispatch, filter, page, markEndReached }) => {
dispatch('conversationPage/setCurrentPage', { filter, page }, { root: true });
if (markEndReached) {
dispatch('conversationPage/setEndReached', { filter }, { root: true });
}
};
export const setContacts = (commit, chatList) => {
commit(
`contacts/${types.SET_CONTACTS}`,
chatList.map(chat => chat.meta.sender)
);
};
export const isOnMentionsView = ({ route: { name: routeName } }) => {
const MENTION_ROUTES = [
'conversation_mentions',
'conversation_through_mentions',
];
return MENTION_ROUTES.includes(routeName);
};
export const isOnUnattendedView = ({ route: { name: routeName } }) => {
const UNATTENDED_ROUTES = [
'conversation_unattended',
'conversation_through_unattended',
];
return UNATTENDED_ROUTES.includes(routeName);
};
export const isOnFoldersView = ({ route: { name: routeName } }) => {
const FOLDER_ROUTES = [
'folder_conversations',
'conversations_through_folders',
];
return FOLDER_ROUTES.includes(routeName);
};
export const buildConversationList = (
context,
requestPayload,
responseData,
filterType
) => {
const { payload: conversationList, meta: metaData } = responseData;
context.commit(types.SET_ALL_CONVERSATION, conversationList);
context.dispatch('conversationStats/set', metaData);
context.dispatch(
'conversationLabels/setBulkConversationLabels',
conversationList
);
context.commit(types.CLEAR_LIST_LOADING_STATUS);
setContacts(context.commit, conversationList);
setPageFilter({
dispatch: context.dispatch,
filter: filterType,
page: requestPayload.page,
markEndReached: !conversationList.length,
});
};

View File

@@ -0,0 +1,385 @@
/**
* Conversation Filter Helpers
* ---------------------------
* This file contains helper functions for filtering conversations in the frontend.
* The filtering logic is designed to align with the backend SQL behavior to ensure
* consistent results across the application.
*
* Key components:
* 1. getValueFromConversation: Retrieves values from conversation objects, handling
* both top-level properties and nested attributes.
* 2. matchesCondition: Evaluates a single filter condition against a value.
* 3. matchesFilters: Evaluates a complete filter chain against a conversation.
* 4. buildJsonLogicRule: Transforms evaluated filters into a JSON Logic frule that
* respects SQL-like operator precedence.
*
* Filter Structure:
* -----------------
* Each filter has the following structure:
* {
* attributeKey: 'status', // The attribute to filter on
* filterOperator: 'equal_to', // The operator to use (equal_to, contains, etc.)
* values: ['open'], // The values to compare against
* queryOperator: 'and' // How this filter connects to the next one (and/or)
* }
*
* Operator Precedence:
* --------------------
* The filter evaluation respects SQL-like operator precedence using JSON Logic:
* https://www.postgresql.org/docs/17/sql-syntax-lexical.html#SQL-PRECEDENCE
* 1. First evaluates individual conditions
* 2. Then applies AND operators (groups consecutive AND conditions)
* 3. Finally applies OR operators (connects AND groups with OR operations)
*
* This means that a filter chain like "A AND B OR C" is evaluated as "(A AND B) OR C",
* and "A OR B AND C" is evaluated as "A OR (B AND C)".
*
* The implementation uses json-logic-js to apply these rules. The JsonLogic format is designed
* to allow you to share rules (logic) between front-end and back-end code
* Here we use json-logic-js to transform filter conditions into a nested JSON Logic structure that preserves proper
* operator precedence, effectively mimicking SQL-like operator precedence.
*
* Conversation Object Structure:
* -----------------------------
* The conversation object can have:
* 1. Top-level properties (status, priority, display_id, etc.)
* 2. Nested properties in additional_attributes (browser_language, referer, etc.)
* 3. Nested properties in custom_attributes (conversation_type, etc.)
*/
import jsonLogic from 'json-logic-js';
import { coerceToDate } from '@chatwoot/utils';
/**
* Gets a value from a conversation based on the attribute key
* @param {Object} conversation - The conversation object
* @param {String} attributeKey - The attribute key to get the value for
* @returns {*} - The value of the attribute
*
* This function handles various attribute locations:
* 1. Direct properties on the conversation object (status, priority, etc.)
* 2. Properties in conversation.additional_attributes (browser_language, referer, etc.)
* 3. Properties in conversation.custom_attributes (conversation_type, etc.)
*/
const getValueFromConversation = (conversation, attributeKey) => {
switch (attributeKey) {
case 'status':
case 'priority':
case 'labels':
case 'created_at':
case 'last_activity_at':
return conversation[attributeKey];
case 'display_id':
// Frontend uses 'id' but backend expects 'display_id'
return conversation.display_id || conversation.id;
case 'assignee_id':
return conversation.meta?.assignee?.id;
case 'inbox_id':
return conversation.inbox_id;
case 'team_id':
return conversation.meta?.team?.id;
case 'browser_language':
case 'referer':
return conversation.additional_attributes?.[attributeKey];
default:
// Check if it's a custom attribute
if (
conversation.custom_attributes &&
conversation.custom_attributes[attributeKey]
) {
return conversation.custom_attributes[attributeKey];
}
return null;
}
};
/**
* Resolves the value from an input candidate
* @param {*} candidate - The input value to resolve
* @returns {*} - If the candidate is an object with an id property, returns the id;
* otherwise returns the candidate unchanged
*
* This helper function is used to normalize values, particularly when dealing with
* objects that represent entities like users, teams, or inboxes where we want to
* compare by ID rather than by the whole object.
*/
const resolveValue = candidate => {
if (
typeof candidate === 'object' &&
candidate !== null &&
'id' in candidate
) {
return candidate.id;
}
return candidate;
};
/**
* Checks if two values are equal in the context of filtering
* @param {*} filterValue - The filterValue value
* @param {*} conversationValue - The conversationValue value
* @returns {Boolean} - Returns true if the values are considered equal according to filtering rules
*
* This function handles various equality scenarios:
* 1. When both values are arrays: checks if all items in filterValue exist in conversationValue
* 2. When filterValue is an array but conversationValue is not: checks if conversationValue is included in filterValue
* 3. Otherwise: performs strict equality comparison
*/
const equalTo = (filterValue, conversationValue) => {
if (Array.isArray(filterValue)) {
if (filterValue.includes('all')) return true;
if (filterValue === 'all') return true;
if (Array.isArray(conversationValue)) {
// For array values like labels, check if any of the filter values exist in the array
return filterValue.every(val => conversationValue.includes(val));
}
if (!Array.isArray(conversationValue)) {
return filterValue.includes(conversationValue);
}
}
return conversationValue === filterValue;
};
/**
* Checks if the filterValue value is contained within the conversationValue value
* @param {*} filterValue - The value to look for
* @param {*} conversationValue - The value to search within
* @returns {Boolean} - Returns true if filterValue is contained within conversationValue
*
* This function performs case-insensitive string containment checks.
* It only works with string values and returns false for non-string types.
*/
const contains = (filterValue, conversationValue) => {
if (
typeof conversationValue === 'string' &&
typeof filterValue === 'string'
) {
return conversationValue.toLowerCase().includes(filterValue.toLowerCase());
}
return false;
};
/**
* Compares two date values using a comparison function
* @param {*} conversationValue - The conversation value to compare
* @param {*} filterValue - The filter value to compare against
* @param {Function} compareFn - The comparison function to apply
* @returns {Boolean} - Returns true if the comparison succeeds, false otherwise
*/
const compareDates = (conversationValue, filterValue, compareFn) => {
const conversationDate = coerceToDate(conversationValue);
// In saved views, the filterValue might be returned as an Array
// In conversation list, when filtering, the filterValue will be returned as a string
const valueToCompare = Array.isArray(filterValue)
? filterValue[0]
: filterValue;
const filterDate = coerceToDate(valueToCompare);
if (conversationDate === null || filterDate === null) return false;
return compareFn(conversationDate, filterDate);
};
/**
* Checks if a value matches a filter condition
* @param {*} conversationValue - The value to check
* @param {Object} filter - The filter condition
* @returns {Boolean} - Returns true if the value matches the filter
*/
const matchesCondition = (conversationValue, filter) => {
const { filter_operator: filterOperator, values } = filter;
const isNullish =
conversationValue === null || conversationValue === undefined;
const filterValue = Array.isArray(values)
? values.map(resolveValue)
: resolveValue(values);
switch (filterOperator) {
case 'equal_to':
return equalTo(filterValue, conversationValue);
case 'not_equal_to':
return !equalTo(filterValue, conversationValue);
case 'contains':
return contains(filterValue, conversationValue);
case 'does_not_contain':
return !contains(filterValue, conversationValue);
case 'is_present':
return !isNullish;
case 'is_not_present':
return isNullish;
case 'is_greater_than':
return compareDates(conversationValue, filterValue, (a, b) => a > b);
case 'is_less_than':
return compareDates(conversationValue, filterValue, (a, b) => a < b);
case 'days_before': {
if (isNullish) {
return false;
}
const today = new Date();
const daysInMilliseconds = filterValue * 24 * 60 * 60 * 1000;
const targetDate = new Date(today.getTime() - daysInMilliseconds);
return conversationValue < targetDate.getTime();
}
default:
return false;
}
};
/**
* Converts an array of evaluated filters into a JSON Logic rule
* that respects SQL-like operator precedence (AND before OR)
*
* This function transforms the linear sequence of filter results and operators
* into a nested JSON Logic structure that correctly implements SQL-like precedence:
* - AND operators are evaluated before OR operators
* - Consecutive AND conditions are grouped together
* - These AND groups are then connected with OR operators
*
* For example:
* - "A AND B AND C" becomes { "and": [A, B, C] }
* - "A OR B OR C" becomes { "or": [A, B, C] }
* - "A AND B OR C" becomes { "or": [{ "and": [A, B] }, C] }
* - "A OR B AND C" becomes { "or": [A, { "and": [B, C] }] }
*
* FILTER CHAIN: A --AND--> B --OR--> C --AND--> D --AND--> E --OR--> F
* | | | | | |
* v v v v v v
* EVALUATED: true false true false true false
* \ / \ \ / /
* \ / \ \ / /
* \ / \ \ / /
* \ / \ \ / /
* \ / \ \ / /
* AND GROUPS: [true,false] [true,false,true] [false]
* | | |
* v v v
* JSON LOGIC: {"and":[true,false]} {"and":[true,false,true]} false
* \ | /
* \ | /
* \ | /
* \ | /
* \ | /
* FINAL RULE: {"or":[{"and":[true,false]},{"and":[true,false,true]},false]}
*
* {
* "or": [
* { "and": [true, false] },
* { "and": [true, false, true] },
* { "and": [false] }
* ]
* }
* @param {Array} evaluatedFilters - Array of evaluated filter conditions with results and operators
* @returns {Object} - JSON Logic rule
*/
const buildJsonLogicRule = evaluatedFilters => {
// Step 1: Group consecutive AND conditions into logical units
// This implements the higher precedence of AND over OR
const andGroups = [];
let currentAndGroup = [evaluatedFilters[0].result];
for (let i = 0; i < evaluatedFilters.length - 1; i += 1) {
if (evaluatedFilters[i].operator === 'and') {
// When we see an AND operator, we add the next filter to the current AND group
// This builds up chains of AND conditions that will be evaluated together
currentAndGroup.push(evaluatedFilters[i + 1].result);
} else {
// When we see an OR operator, it marks the boundary between AND groups
// We finalize the current AND group and start a new one
// If the AND group has only one item, don't wrap it in an "and" operator
// Otherwise, create a proper "and" JSON Logic expression
andGroups.push(
currentAndGroup.length === 1
? currentAndGroup[0] // Single item doesn't need an "and" wrapper
: { and: currentAndGroup } // Multiple items need to be AND-ed together
);
// Start a new AND group with the next filter's result
currentAndGroup = [evaluatedFilters[i + 1].result];
}
}
// Step 2: Add the final AND group that wasn't followed by an OR
if (currentAndGroup.length > 0) {
andGroups.push(
currentAndGroup.length === 1
? currentAndGroup[0] // Single item doesn't need an "and" wrapper
: { and: currentAndGroup } // Multiple items need to be AND-ed together
);
}
// Step 3: Combine all AND groups with OR operators
// If we have multiple AND groups, they are separated by OR operators
// in the original filter chain, so we combine them with an "or" operation
if (andGroups.length > 1) {
return { or: andGroups };
}
// If there's only one AND group (which might be a single condition
// or multiple AND-ed conditions), just return it directly
return andGroups[0];
};
/**
* Evaluates each filter against the conversation and prepares the results array
* @param {Object} conversation - The conversation to evaluate
* @param {Array} filters - Filters to apply
* @returns {Array} - Array of evaluated filter results with operators
*/
const evaluateFilters = (conversation, filters) => {
return filters.map((filter, index) => {
const value = getValueFromConversation(conversation, filter.attribute_key);
const result = matchesCondition(value, filter);
// This part determines the logical operator that connects this filter to the next one:
// - If this is not the last filter (index < filters.length - 1), use the filter's query_operator
// or default to 'and' if query_operator is not specified
// - If this is the last filter, set operator to null since there's no next filter to connect to
const isLastFilter = index === filters.length - 1;
const operator = isLastFilter ? null : filter.query_operator || 'and';
return { result, operator };
});
};
/**
* Checks if a conversation matches the given filters
* @param {Object} conversation - The conversation object to check
* @param {Array} filters - Array of filter conditions
* @returns {Boolean} - Returns true if conversation matches filters, false otherwise
*/
export const matchesFilters = (conversation, filters) => {
// If no filters, return true
if (!filters || filters.length === 0) {
return true;
}
// Handle single filter case
if (filters.length === 1) {
const value = getValueFromConversation(
conversation,
filters[0].attribute_key
);
return matchesCondition(value, filters[0]);
}
// Evaluate all conditions and prepare for jsonLogic
const evaluatedFilters = evaluateFilters(conversation, filters);
return jsonLogic.apply(buildJsonLogicRule(evaluatedFilters));
};

View File

@@ -0,0 +1,26 @@
import { isOnMentionsView, isOnFoldersView } from '../actionHelpers';
describe('#isOnMentionsView', () => {
it('return valid responses when passing the state', () => {
expect(isOnMentionsView({ route: { name: 'conversation_mentions' } })).toBe(
true
);
expect(isOnMentionsView({ route: { name: 'conversation_messages' } })).toBe(
false
);
});
});
describe('#isOnFoldersView', () => {
it('return valid responses when passing the state', () => {
expect(isOnFoldersView({ route: { name: 'folder_conversations' } })).toBe(
true
);
expect(
isOnFoldersView({ route: { name: 'conversations_through_folders' } })
).toBe(true);
expect(isOnFoldersView({ route: { name: 'conversation_messages' } })).toBe(
false
);
});
});

View File

@@ -0,0 +1,386 @@
import types from '../../mutation-types';
import getters, { getSelectedChatConversation } from './getters';
import actions from './actions';
import { findPendingMessageIndex } from './helpers';
import { MESSAGE_STATUS } from 'shared/constants/messages';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants.js';
const state = {
allConversations: [],
attachments: {},
listLoadingStatus: true,
chatStatusFilter: wootConstants.STATUS_TYPE.OPEN,
chatSortFilter: wootConstants.SORT_BY_TYPE.LATEST,
currentInbox: null,
selectedChatId: null,
appliedFilters: [],
contextMenuChatId: null,
conversationParticipants: [],
conversationLastSeen: null,
syncConversationsMessages: {},
conversationFilters: {},
copilotAssistant: {},
};
const getConversationById = _state => conversationId => {
return _state.allConversations.find(c => c.id === conversationId);
};
// mutations
export const mutations = {
[types.SET_ALL_CONVERSATION](_state, conversationList) {
const newAllConversations = [..._state.allConversations];
conversationList.forEach(conversation => {
const indexInCurrentList = newAllConversations.findIndex(
c => c.id === conversation.id
);
if (indexInCurrentList < 0) {
newAllConversations.push(conversation);
} else if (conversation.id !== _state.selectedChatId) {
// If the conversation is already in the list, replace it
// Added this to fix the issue of the conversation not being updated
// When reconnecting to the websocket. If the selectedChatId is not the same as
// the conversation.id in the store, replace the existing conversation with the new one
newAllConversations[indexInCurrentList] = conversation;
} else {
// If the conversation is already in the list and selectedChatId is the same,
// replace all data except the messages array, attachments, dataFetched, allMessagesLoaded
const existingConversation = newAllConversations[indexInCurrentList];
newAllConversations[indexInCurrentList] = {
...conversation,
allMessagesLoaded: existingConversation.allMessagesLoaded,
messages: existingConversation.messages,
dataFetched: existingConversation.dataFetched,
};
}
});
_state.allConversations = newAllConversations;
},
[types.EMPTY_ALL_CONVERSATION](_state) {
_state.allConversations = [];
_state.selectedChatId = null;
},
[types.SET_ALL_MESSAGES_LOADED](_state, conversationId) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.allMessagesLoaded = true;
}
},
[types.CLEAR_ALL_MESSAGES_LOADED](_state, conversationId) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.allMessagesLoaded = false;
}
},
[types.CLEAR_CURRENT_CHAT_WINDOW](_state) {
_state.selectedChatId = null;
},
[types.SET_PREVIOUS_CONVERSATIONS](_state, { id, data }) {
if (data.length) {
const [chat] = _state.allConversations.filter(c => c.id === id);
chat.messages.unshift(...data);
}
},
[types.SET_ALL_ATTACHMENTS](_state, { id, data }) {
_state.attachments[id] = [...data];
},
[types.SET_MISSING_MESSAGES](_state, { id, data }) {
const [chat] = _state.allConversations.filter(c => c.id === id);
if (!chat) return;
chat.messages = data;
},
[types.SET_CHAT_DATA_FETCHED](_state, conversationId) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.dataFetched = true;
}
},
[types.SET_CURRENT_CHAT_WINDOW](_state, activeChat) {
if (activeChat) {
_state.selectedChatId = activeChat.id;
}
},
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.meta.assignee = assignee;
}
},
[types.ASSIGN_TEAM](_state, { team, conversationId }) {
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
chat.meta.team = team;
},
[types.UPDATE_CONVERSATION_LAST_ACTIVITY](
_state,
{ lastActivityAt, conversationId }
) {
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
if (chat) {
chat.last_activity_at = lastActivityAt;
}
},
[types.ASSIGN_PRIORITY](_state, { priority, conversationId }) {
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
chat.priority = priority;
},
[types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES](
_state,
{ conversationId, customAttributes }
) {
const conversation = _state.allConversations.find(
c => c.id === conversationId
);
if (conversation) {
conversation.custom_attributes = {
...conversation.custom_attributes,
...customAttributes,
};
}
},
[types.CHANGE_CONVERSATION_STATUS](
_state,
{ conversationId, status, snoozedUntil }
) {
const conversation =
getters.getConversationById(_state)(conversationId) || {};
conversation.snoozed_until = snoozedUntil;
conversation.status = status;
},
[types.MUTE_CONVERSATION](_state) {
const [chat] = getSelectedChatConversation(_state);
chat.muted = true;
},
[types.UNMUTE_CONVERSATION](_state) {
const [chat] = getSelectedChatConversation(_state);
chat.muted = false;
},
[types.ADD_CONVERSATION_ATTACHMENTS](_state, message) {
// early return if the message has not been sent, or has no attachments
if (
message.status !== MESSAGE_STATUS.SENT ||
!message.attachments?.length
) {
return;
}
const id = message.conversation_id;
const existingAttachments = _state.attachments[id] || [];
const attachmentsToAdd = message.attachments.filter(attachment => {
// if the attachment is not already in the store, add it
// this is to prevent duplicates
return !existingAttachments.some(
existingAttachment => existingAttachment.id === attachment.id
);
});
// replace the attachments in the store
_state.attachments[id] = [...existingAttachments, ...attachmentsToAdd];
},
[types.DELETE_CONVERSATION_ATTACHMENTS](_state, message) {
if (message.status !== MESSAGE_STATUS.SENT) return;
const { conversation_id: id } = message;
const existingAttachments = _state.attachments[id] || [];
if (!existingAttachments.length) return;
_state.attachments[id] = existingAttachments.filter(attachment => {
return attachment.message_id !== message.id;
});
},
[types.ADD_MESSAGE]({ allConversations, selectedChatId }, message) {
const { conversation_id: conversationId } = message;
const [chat] = getSelectedChatConversation({
allConversations,
selectedChatId: conversationId,
});
if (!chat) return;
const pendingMessageIndex = findPendingMessageIndex(chat, message);
if (pendingMessageIndex !== -1) {
chat.messages[pendingMessageIndex] = message;
} else {
chat.messages.push(message);
chat.timestamp = message.created_at;
const { conversation: { unread_count: unreadCount = 0 } = {} } = message;
chat.unread_count = unreadCount;
if (selectedChatId === conversationId) {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
}
},
[types.ADD_CONVERSATION](_state, conversation) {
_state.allConversations.push(conversation);
},
[types.DELETE_CONVERSATION](_state, conversationId) {
_state.allConversations = _state.allConversations.filter(
c => c.id !== conversationId
);
},
[types.UPDATE_CONVERSATION](_state, conversation) {
const { allConversations } = _state;
const index = allConversations.findIndex(c => c.id === conversation.id);
if (index > -1) {
const selectedConversation = allConversations[index];
// ignore out of order events
if (conversation.updated_at < selectedConversation.updated_at) {
return;
}
const { messages, ...updates } = conversation;
allConversations[index] = { ...selectedConversation, ...updates };
if (_state.selectedChatId === conversation.id) {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
} else {
_state.allConversations.push(conversation);
}
},
[types.SET_LIST_LOADING_STATUS](_state) {
_state.listLoadingStatus = true;
},
[types.CLEAR_LIST_LOADING_STATUS](_state) {
_state.listLoadingStatus = false;
},
[types.UPDATE_MESSAGE_UNREAD_COUNT](
_state,
{ id, lastSeen, unreadCount = 0 }
) {
const [chat] = _state.allConversations.filter(c => c.id === id);
if (chat) {
chat.agent_last_seen_at = lastSeen;
chat.unread_count = unreadCount;
}
},
[types.CHANGE_CHAT_STATUS_FILTER](_state, data) {
_state.chatStatusFilter = data;
},
[types.CHANGE_CHAT_SORT_FILTER](_state, data) {
_state.chatSortFilter = data;
},
// Update assignee on action cable message
[types.UPDATE_ASSIGNEE](_state, payload) {
const chat = getConversationById(_state)(payload.id);
if (chat) {
chat.meta.assignee = payload.assignee;
}
},
[types.UPDATE_CONVERSATION_CONTACT](_state, { conversationId, ...payload }) {
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
if (chat) {
chat.meta.sender = payload;
}
},
[types.UPDATE_CONVERSATION_CALL_STATUS](
_state,
{ conversationId, callStatus }
) {
const chat = getConversationById(_state)(conversationId);
if (!chat) return;
chat.additional_attributes = {
...chat.additional_attributes,
call_status: callStatus,
};
},
[types.UPDATE_MESSAGE_CALL_STATUS](_state, { conversationId, callStatus }) {
const chat = getConversationById(_state)(conversationId);
if (!chat) return;
const lastCall = (chat.messages || []).findLast(
m => m.content_type === CONTENT_TYPES.VOICE_CALL
);
if (!lastCall) return;
lastCall.content_attributes ??= {};
lastCall.content_attributes.data = {
...lastCall.content_attributes.data,
status: callStatus,
};
},
[types.SET_ACTIVE_INBOX](_state, inboxId) {
_state.currentInbox = inboxId ? parseInt(inboxId, 10) : null;
},
[types.SET_CONVERSATION_CAN_REPLY](_state, { conversationId, canReply }) {
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
if (chat) {
chat.can_reply = canReply;
}
},
[types.CLEAR_CONTACT_CONVERSATIONS](_state, contactId) {
const chats = _state.allConversations.filter(
c => c.meta.sender.id !== contactId
);
_state.allConversations = chats;
},
[types.SET_CONVERSATION_FILTERS](_state, data) {
_state.appliedFilters = data;
},
[types.CLEAR_CONVERSATION_FILTERS](_state) {
_state.appliedFilters = [];
},
[types.SET_LAST_MESSAGE_ID_IN_SYNC_CONVERSATION](
_state,
{ conversationId, messageId }
) {
_state.syncConversationsMessages[conversationId] = messageId;
},
[types.SET_CONTEXT_MENU_CHAT_ID](_state, chatId) {
_state.contextMenuChatId = chatId;
},
[types.SET_CHAT_LIST_FILTERS](_state, data) {
_state.conversationFilters = data;
},
[types.UPDATE_CHAT_LIST_FILTERS](_state, data) {
_state.conversationFilters = { ..._state.conversationFilters, ...data };
},
[types.SET_INBOX_CAPTAIN_ASSISTANT](_state, data) {
_state.copilotAssistant = data.assistant;
},
};
export default {
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,276 @@
import { describe, it, expect } from 'vitest';
import { applyRoleFilter } from '../helpers';
describe('Conversation Helpers', () => {
describe('#applyRoleFilter', () => {
// Test data for conversations
const conversationWithAssignee = {
meta: {
assignee: {
id: 1,
},
},
};
const conversationWithDifferentAssignee = {
meta: {
assignee: {
id: 2,
},
},
};
const conversationWithoutAssignee = {
meta: {
assignee: null,
},
};
// Test for administrator role
it('always returns true for administrator role regardless of permissions', () => {
const role = 'administrator';
const permissions = [];
const currentUserId = 1;
expect(
applyRoleFilter(
conversationWithAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
expect(
applyRoleFilter(
conversationWithDifferentAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
expect(
applyRoleFilter(
conversationWithoutAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
});
// Test for agent role
it('always returns true for agent role regardless of permissions', () => {
const role = 'agent';
const permissions = [];
const currentUserId = 1;
expect(
applyRoleFilter(
conversationWithAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
expect(
applyRoleFilter(
conversationWithDifferentAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
expect(
applyRoleFilter(
conversationWithoutAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
});
// Test for custom role with 'conversation_manage' permission
it('returns true for any user with conversation_manage permission', () => {
const role = 'custom_role';
const permissions = ['conversation_manage'];
const currentUserId = 1;
expect(
applyRoleFilter(
conversationWithAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
expect(
applyRoleFilter(
conversationWithDifferentAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
expect(
applyRoleFilter(
conversationWithoutAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
});
// Test for custom role with 'conversation_unassigned_manage' permission
describe('with conversation_unassigned_manage permission', () => {
const role = 'custom_role';
const permissions = ['conversation_unassigned_manage'];
const currentUserId = 1;
it('returns true for conversations assigned to the user', () => {
expect(
applyRoleFilter(
conversationWithAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
});
it('returns true for unassigned conversations', () => {
expect(
applyRoleFilter(
conversationWithoutAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
});
it('returns false for conversations assigned to other users', () => {
expect(
applyRoleFilter(
conversationWithDifferentAssignee,
role,
permissions,
currentUserId
)
).toBe(false);
});
});
// Test for custom role with 'conversation_participating_manage' permission
describe('with conversation_participating_manage permission', () => {
const role = 'custom_role';
const permissions = ['conversation_participating_manage'];
const currentUserId = 1;
it('returns true for conversations assigned to the user', () => {
expect(
applyRoleFilter(
conversationWithAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
});
it('returns false for unassigned conversations', () => {
expect(
applyRoleFilter(
conversationWithoutAssignee,
role,
permissions,
currentUserId
)
).toBe(false);
});
it('returns false for conversations assigned to other users', () => {
expect(
applyRoleFilter(
conversationWithDifferentAssignee,
role,
permissions,
currentUserId
)
).toBe(false);
});
});
// Test for user with no relevant permissions
it('returns false for custom role without any relevant permissions', () => {
const role = 'custom_role';
const permissions = ['some_other_permission'];
const currentUserId = 1;
expect(
applyRoleFilter(
conversationWithAssignee,
role,
permissions,
currentUserId
)
).toBe(false);
expect(
applyRoleFilter(
conversationWithDifferentAssignee,
role,
permissions,
currentUserId
)
).toBe(false);
expect(
applyRoleFilter(
conversationWithoutAssignee,
role,
permissions,
currentUserId
)
).toBe(false);
});
// Test edge cases for meta.assignee
describe('handles edge cases with meta.assignee', () => {
const role = 'custom_role';
const permissions = ['conversation_unassigned_manage'];
const currentUserId = 1;
it('treats undefined assignee as unassigned', () => {
const conversationWithUndefinedAssignee = {
meta: {
assignee: undefined,
},
};
expect(
applyRoleFilter(
conversationWithUndefinedAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
});
it('handles empty meta object', () => {
const conversationWithEmptyMeta = {
meta: {},
};
expect(
applyRoleFilter(
conversationWithEmptyMeta,
role,
permissions,
currentUserId
)
).toBe(true);
});
});
});
});

View File

@@ -0,0 +1,159 @@
import { mutations } from '../index';
import types from '../../../mutation-types';
describe('#mutations', () => {
describe('#UPDATE_CONVERSATION_CALL_STATUS', () => {
it('does nothing if conversation is not found', () => {
const state = { allConversations: [] };
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations).toEqual([]);
});
it('updates call_status preserving existing additional_attributes', () => {
const state = {
allConversations: [
{ id: 1, additional_attributes: { other_attr: 'value' } },
],
};
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
});
expect(state.allConversations[0].additional_attributes).toEqual({
other_attr: 'value',
call_status: 'in-progress',
});
});
it('creates additional_attributes if it does not exist', () => {
const state = { allConversations: [{ id: 1 }] };
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
});
expect(state.allConversations[0].additional_attributes).toEqual({
call_status: 'completed',
});
});
});
describe('#UPDATE_MESSAGE_CALL_STATUS', () => {
it('does nothing if conversation is not found', () => {
const state = { allConversations: [] };
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations).toEqual([]);
});
it('does nothing if no voice call message exists', () => {
const state = {
allConversations: [
{ id: 1, messages: [{ id: 1, content_type: 'text' }] },
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations[0].messages[0]).toEqual({
id: 1,
content_type: 'text',
});
});
it('updates the last voice call message status', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
id: 1,
content_type: 'voice_call',
content_attributes: { data: { status: 'ringing' } },
},
{
id: 2,
content_type: 'voice_call',
content_attributes: { data: { status: 'ringing' } },
},
],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
});
expect(
state.allConversations[0].messages[0].content_attributes.data.status
).toBe('ringing');
expect(
state.allConversations[0].messages[1].content_attributes.data.status
).toBe('in-progress');
});
it('creates content_attributes.data if it does not exist', () => {
const state = {
allConversations: [
{
id: 1,
messages: [{ id: 1, content_type: 'voice_call' }],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
});
expect(
state.allConversations[0].messages[0].content_attributes.data.status
).toBe('completed');
});
it('preserves existing data in content_attributes.data', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
id: 1,
content_type: 'voice_call',
content_attributes: {
data: { call_sid: 'CA123', status: 'ringing' },
},
},
],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
});
expect(
state.allConversations[0].messages[0].content_attributes.data
).toEqual({
call_sid: 'CA123',
status: 'in-progress',
});
});
it('handles empty messages array', () => {
const state = {
allConversations: [{ id: 1, messages: [] }],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations[0].messages).toEqual([]);
});
});
});