Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user