Files
leadchat/app/javascript/dashboard/store/modules/conversations/actions.js
Muhsin Keloth 04b2901e1f feat: Conversation workflows(EE) (#13040)
We are expanding Chatwoot’s automation capabilities by
introducing **Conversation Workflows**, a dedicated section in settings
where teams can configure rules that govern how conversations are closed
and what information agents must fill before resolving. This feature
helps teams enforce data consistency, collect structured resolution
information, and ensure downstream reporting is accurate.

Instead of having auto‑resolution buried inside Account Settings, we
introduced a new sidebar item:
- Auto‑resolve conversations (existing behaviour)
- Required attributes on resolution (new)

This groups all conversation‑closing logic into a single place.

#### Required Attributes on Resolve

Admins can now pick which custom conversation attributes must be filled
before an agent can resolve a conversation.

**How it works**

- Admin selects one or more attributes from the list of existing
conversation level custom attributes.
- These selected attributes become mandatory during resolution.
- List all the attributes configured via Required Attributes (Text,
Number, Link, Date, List, Checkbox)
- When an agent clicks Resolve Conversation:
If attributes already have values → the conversation resolves normally.
If attributes are missing → a modal appears prompting the agent to fill
them.

<img width="1554" height="1282" alt="CleanShot 2025-12-10 at 11 42
23@2x"
src="https://github.com/user-attachments/assets/4cd5d6e1-abe8-4999-accd-d4a08913b373"
/>


#### Custom Attributes Integration

On the Custom Attributes page, we will surfaced indicators showing how
each attribute is being used.

Each attribute will show badges such as:

- Resolution → used in the required‑on‑resolve workflow

- Pre‑chat form → already existing

<img width="2390" height="1822" alt="CleanShot 2025-12-10 at 11 43
42@2x"
src="https://github.com/user-attachments/assets/b92a6eb7-7f6c-40e6-bf23-6a5310f2d9c5"
/>


#### Admin Flow

- Navigate to Settings → Conversation Workflows.
- Under Required attributes on resolve, click Add Required Attribute.
- Pick from the dropdown list of conversation attributes.
- Save changes.

Agents will now be prompted automatically whenever they resolve.

<img width="2434" height="872" alt="CleanShot 2025-12-10 at 11 44 42@2x"
src="https://github.com/user-attachments/assets/632fc0e5-767c-4a1c-8cf4-ffe3d058d319"
/>



#### NOTES
- The Required Attributes on Resolve modal should only appear when
values are missing.
- Required attributes must block the resolution action until satisfied.
- Bulk‑resolve actions should follow the same rules — any conversation
missing attributes cannot be bulk‑resolved, rest will be resolved, show
a notification that the resolution cannot be done.
- API resolution does not respect the attributes.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-27 11:36:20 +04:00

537 lines
15 KiB
JavaScript

import types from '../../mutation-types';
import ConversationApi from '../../../api/inbox/conversation';
import MessageApi from '../../../api/inbox/message';
import { MESSAGE_STATUS, MESSAGE_TYPE } from 'shared/constants/messages';
import { createPendingMessage } from 'dashboard/helper/commons';
import {
buildConversationList,
isOnMentionsView,
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);
}
} 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);
if (data.dataFetched === undefined) {
try {
await dispatch('fetchPreviousMessages', {
after,
before: data.messages[0].id,
conversationId: data.id,
});
data.dataFetched = true;
} catch (error) {
// Ignore error
}
}
},
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
try {
const response = await ConversationApi.assignAgent({
conversationId,
agentId,
});
dispatch('setCurrentChatAssignee', response.data);
} catch (error) {
// Handle error
}
},
setCurrentChatAssignee({ commit }, assignee) {
commit(types.ASSIGN_AGENT, 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;