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>
This commit is contained in:
@@ -3,10 +3,13 @@ import { useStore } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
export function useBulkActions() {
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const selectedConversations = useMapGetter(
|
||||
'bulkActions/getSelectedConversationIds'
|
||||
@@ -116,17 +119,61 @@ export function useBulkActions() {
|
||||
}
|
||||
|
||||
async function onUpdateConversations(status, snoozedUntil) {
|
||||
try {
|
||||
await store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: selectedConversations.value,
|
||||
fields: {
|
||||
status,
|
||||
let conversationIds = selectedConversations.value;
|
||||
let skippedCount = 0;
|
||||
|
||||
// If resolving, check for required attributes
|
||||
if (status === wootConstants.STATUS_TYPE.RESOLVED) {
|
||||
const { validIds, skippedIds } = selectedConversations.value.reduce(
|
||||
(acc, id) => {
|
||||
const conversation = store.getters.getConversationById(id);
|
||||
const currentCustomAttributes = conversation?.custom_attributes || {};
|
||||
const { hasMissing } = checkMissingAttributes(
|
||||
currentCustomAttributes
|
||||
);
|
||||
|
||||
if (!hasMissing) {
|
||||
acc.validIds.push(id);
|
||||
} else {
|
||||
acc.skippedIds.push(id);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
snoozed_until: snoozedUntil,
|
||||
});
|
||||
{ validIds: [], skippedIds: [] }
|
||||
);
|
||||
|
||||
conversationIds = validIds;
|
||||
skippedCount = skippedIds.length;
|
||||
|
||||
if (skippedCount > 0 && validIds.length === 0) {
|
||||
// All conversations have missing attributes
|
||||
useAlert(
|
||||
t('BULK_ACTION.RESOLVE.ALL_MISSING_ATTRIBUTES') ||
|
||||
'Cannot resolve conversations due to missing required attributes'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (conversationIds.length > 0) {
|
||||
await store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: conversationIds,
|
||||
fields: {
|
||||
status,
|
||||
},
|
||||
snoozed_until: snoozedUntil,
|
||||
});
|
||||
}
|
||||
|
||||
store.dispatch('bulkActions/clearSelectedConversationIds');
|
||||
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
|
||||
|
||||
if (skippedCount > 0) {
|
||||
useAlert(t('BULK_ACTION.RESOLVE.PARTIAL_SUCCESS'));
|
||||
} else {
|
||||
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
|
||||
}
|
||||
} catch (err) {
|
||||
useAlert(t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { useConversationRequiredAttributes } from '../useConversationRequiredAttributes';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables/useAccount');
|
||||
|
||||
const defaultAttributes = [
|
||||
{
|
||||
attributeKey: 'priority',
|
||||
attributeDisplayName: 'Priority',
|
||||
attributeDisplayType: 'list',
|
||||
attributeValues: ['High', 'Medium', 'Low'],
|
||||
},
|
||||
{
|
||||
attributeKey: 'category',
|
||||
attributeDisplayName: 'Category',
|
||||
attributeDisplayType: 'text',
|
||||
attributeValues: [],
|
||||
},
|
||||
{
|
||||
attributeKey: 'is_urgent',
|
||||
attributeDisplayName: 'Is Urgent',
|
||||
attributeDisplayType: 'checkbox',
|
||||
attributeValues: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe('useConversationRequiredAttributes', () => {
|
||||
beforeEach(() => {
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
if (getter === 'accounts/isFeatureEnabledonAccount') {
|
||||
return { value: () => true };
|
||||
}
|
||||
if (getter === 'attributes/getConversationAttributes') {
|
||||
return { value: defaultAttributes };
|
||||
}
|
||||
return { value: null };
|
||||
});
|
||||
|
||||
useAccount.mockReturnValue({
|
||||
currentAccount: {
|
||||
value: {
|
||||
settings: {
|
||||
conversation_required_attributes: [
|
||||
'priority',
|
||||
'category',
|
||||
'is_urgent',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: { value: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
const setupMocks = (
|
||||
requiredAttributes = ['priority', 'category', 'is_urgent'],
|
||||
{ attributes = defaultAttributes, featureEnabled = true } = {}
|
||||
) => {
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
if (getter === 'accounts/isFeatureEnabledonAccount') {
|
||||
return { value: () => featureEnabled };
|
||||
}
|
||||
if (getter === 'attributes/getConversationAttributes') {
|
||||
return { value: attributes };
|
||||
}
|
||||
return { value: null };
|
||||
});
|
||||
|
||||
useAccount.mockReturnValue({
|
||||
currentAccount: {
|
||||
value: {
|
||||
settings: {
|
||||
conversation_required_attributes: requiredAttributes,
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: { value: 1 },
|
||||
});
|
||||
};
|
||||
|
||||
describe('requiredAttributeKeys', () => {
|
||||
it('should return required attribute keys from account settings', () => {
|
||||
setupMocks();
|
||||
const { requiredAttributeKeys } = useConversationRequiredAttributes();
|
||||
|
||||
expect(requiredAttributeKeys.value).toEqual([
|
||||
'priority',
|
||||
'category',
|
||||
'is_urgent',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array when no required attributes configured', () => {
|
||||
setupMocks([]);
|
||||
const { requiredAttributeKeys } = useConversationRequiredAttributes();
|
||||
|
||||
expect(requiredAttributeKeys.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when account settings is null', () => {
|
||||
setupMocks([], { attributes: [] });
|
||||
useAccount.mockReturnValue({
|
||||
currentAccount: { value: { settings: null } },
|
||||
accountId: { value: 1 },
|
||||
});
|
||||
|
||||
const { requiredAttributeKeys } = useConversationRequiredAttributes();
|
||||
|
||||
expect(requiredAttributeKeys.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requiredAttributes', () => {
|
||||
it('should return full attribute definitions for required attributes only', () => {
|
||||
setupMocks();
|
||||
const { requiredAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
expect(requiredAttributes.value).toHaveLength(3);
|
||||
expect(requiredAttributes.value[0]).toEqual({
|
||||
attributeKey: 'priority',
|
||||
attributeDisplayName: 'Priority',
|
||||
attributeDisplayType: 'list',
|
||||
attributeValues: ['High', 'Medium', 'Low'],
|
||||
value: 'priority',
|
||||
label: 'Priority',
|
||||
type: 'list',
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out deleted attributes that no longer exist', () => {
|
||||
// Mock with only 2 attributes available but 3 required
|
||||
setupMocks(['priority', 'category', 'is_urgent'], {
|
||||
attributes: [
|
||||
{
|
||||
attributeKey: 'priority',
|
||||
attributeDisplayName: 'Priority',
|
||||
attributeDisplayType: 'list',
|
||||
attributeValues: ['High', 'Medium', 'Low'],
|
||||
},
|
||||
{
|
||||
attributeKey: 'is_urgent',
|
||||
attributeDisplayName: 'Is Urgent',
|
||||
attributeDisplayType: 'checkbox',
|
||||
attributeValues: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { requiredAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
expect(requiredAttributes.value).toHaveLength(2);
|
||||
expect(requiredAttributes.value.map(attr => attr.value)).toEqual([
|
||||
'priority',
|
||||
'is_urgent',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkMissingAttributes', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it('should return no missing when all attributes are filled', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: 'Bug Report',
|
||||
is_urgent: true,
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
expect(result.all).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should detect missing text attributes', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
is_urgent: true,
|
||||
// category is missing
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(true);
|
||||
expect(result.missing).toHaveLength(1);
|
||||
expect(result.missing[0].value).toBe('category');
|
||||
});
|
||||
|
||||
it('should detect empty string values as missing', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: '', // empty string
|
||||
is_urgent: true,
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(true);
|
||||
expect(result.missing[0].value).toBe('category');
|
||||
});
|
||||
|
||||
it('should consider checkbox attribute present when value is true', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: 'Bug Report',
|
||||
is_urgent: true,
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should consider checkbox attribute present when value is false', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: 'Bug Report',
|
||||
is_urgent: false, // false is still considered "filled"
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect missing checkbox when key does not exist', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: 'Bug Report',
|
||||
// is_urgent key is completely missing
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(true);
|
||||
expect(result.missing).toHaveLength(1);
|
||||
expect(result.missing[0].value).toBe('is_urgent');
|
||||
expect(result.missing[0].type).toBe('checkbox');
|
||||
});
|
||||
|
||||
it('should handle falsy values correctly for non-checkbox attributes', () => {
|
||||
setupMocks(['score', 'status_flag'], {
|
||||
attributes: [
|
||||
{
|
||||
attributeKey: 'score',
|
||||
attributeDisplayName: 'Score',
|
||||
attributeDisplayType: 'number',
|
||||
attributeValues: [],
|
||||
},
|
||||
{
|
||||
attributeKey: 'status_flag',
|
||||
attributeDisplayName: 'Status Flag',
|
||||
attributeDisplayType: 'text',
|
||||
attributeValues: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
score: 0, // zero should be considered valid, not missing
|
||||
status_flag: false, // false should be considered valid, not missing
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle null values as missing for text attributes', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: null, // null should be missing for text attribute
|
||||
is_urgent: true, // checkbox is present
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(true);
|
||||
expect(result.missing).toHaveLength(1);
|
||||
expect(result.missing[0].value).toBe('category');
|
||||
});
|
||||
|
||||
it('should consider undefined checkbox values as present when key exists', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: 'Bug Report',
|
||||
is_urgent: undefined, // key exists but value is undefined - still considered "filled" for checkbox
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return no missing when no attributes are required', () => {
|
||||
setupMocks([]); // No required attributes
|
||||
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const result = checkMissingAttributes({});
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle whitespace-only values as missing', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: ' ', // whitespace only
|
||||
is_urgent: true,
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(true);
|
||||
expect(result.missing[0].value).toBe('category');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { ATTRIBUTE_TYPES } from 'dashboard/components-next/ConversationWorkflow/constants';
|
||||
|
||||
/**
|
||||
* Composable for managing conversation required attributes workflow
|
||||
*
|
||||
* This handles the logic for checking if conversations have all required
|
||||
* custom attributes filled before they can be resolved.
|
||||
*/
|
||||
export function useConversationRequiredAttributes() {
|
||||
const { currentAccount, accountId } = useAccount();
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
const conversationAttributes = useMapGetter(
|
||||
'attributes/getConversationAttributes'
|
||||
);
|
||||
|
||||
const isFeatureEnabled = computed(() =>
|
||||
isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES
|
||||
)
|
||||
);
|
||||
|
||||
const requiredAttributeKeys = computed(() => {
|
||||
if (!isFeatureEnabled.value) return [];
|
||||
return (
|
||||
currentAccount.value?.settings?.conversation_required_attributes || []
|
||||
);
|
||||
});
|
||||
|
||||
const allAttributeOptions = computed(() =>
|
||||
(conversationAttributes.value || []).map(attribute => ({
|
||||
...attribute,
|
||||
value: attribute.attributeKey,
|
||||
label: attribute.attributeDisplayName,
|
||||
type: attribute.attributeDisplayType,
|
||||
attributeValues: attribute.attributeValues,
|
||||
}))
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the full attribute definitions for only the required attributes
|
||||
* Filters allAttributeOptions to only include attributes marked as required
|
||||
*/
|
||||
const requiredAttributes = computed(
|
||||
() =>
|
||||
requiredAttributeKeys.value
|
||||
.map(key =>
|
||||
allAttributeOptions.value.find(attribute => attribute.value === key)
|
||||
)
|
||||
.filter(Boolean) // Remove any undefined attributes (deleted attributes)
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if a conversation is missing any required attributes
|
||||
*
|
||||
* @param {Object} conversationCustomAttributes - Current conversation's custom attributes
|
||||
* @returns {Object} - Analysis result with missing attributes info
|
||||
*/
|
||||
const checkMissingAttributes = (conversationCustomAttributes = {}) => {
|
||||
// If no attributes are required, conversation can be resolved
|
||||
if (!requiredAttributes.value.length) {
|
||||
return { hasMissing: false, missing: [] };
|
||||
}
|
||||
|
||||
// Find attributes that are missing or empty
|
||||
const missing = requiredAttributes.value.filter(attribute => {
|
||||
const value = conversationCustomAttributes[attribute.value];
|
||||
|
||||
// For checkbox/boolean attributes, only check if the key exists
|
||||
if (attribute.type === ATTRIBUTE_TYPES.CHECKBOX) {
|
||||
return !(attribute.value in conversationCustomAttributes);
|
||||
}
|
||||
|
||||
// For other attribute types, only consider null, undefined, empty string, or whitespace-only as missing
|
||||
// Allow falsy values like 0, false as they are valid filled values
|
||||
return value == null || String(value).trim() === '';
|
||||
});
|
||||
|
||||
return {
|
||||
hasMissing: missing.length > 0,
|
||||
missing,
|
||||
all: requiredAttributes.value,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
requiredAttributeKeys,
|
||||
requiredAttributes,
|
||||
checkMissingAttributes,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user