diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index a0b86176d..dc18ddd66 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -61,6 +61,7 @@ import { getUserPermissions, filterItemsByPermission, } from 'dashboard/helper/permissionsHelper.js'; +import { matchesFilters } from '../store/modules/conversations/helpers/filterHelpers'; import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events'; import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js'; @@ -105,7 +106,7 @@ const advancedFilterTypes = ref( ); const currentUser = useMapGetter('getCurrentUser'); -const chatLists = useMapGetter('getAllConversations'); +const chatLists = useMapGetter('getFilteredConversations'); const mineChatsList = useMapGetter('getMineChats'); const allChatList = useMapGetter('getAllStatusChats'); const unAssignedChatsList = useMapGetter('getUnAssignedChats'); @@ -324,6 +325,14 @@ const conversationList = computed(() => { } else { localConversationList = [...chatLists.value]; } + + if (activeFolder.value) { + const { payload } = activeFolder.value.query; + localConversationList = localConversationList.filter(conversation => { + return matchesFilters(conversation, payload); + }); + } + return localConversationList; }); @@ -460,6 +469,12 @@ function setParamsForEditFolderModal() { campaigns: campaigns.value, languages: languages, countries: countries, + priority: [ + { id: 'low', name: t('CONVERSATION.PRIORITY.OPTIONS.LOW') }, + { id: 'medium', name: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM') }, + { id: 'high', name: t('CONVERSATION.PRIORITY.OPTIONS.HIGH') }, + { id: 'urgent', name: t('CONVERSATION.PRIORITY.OPTIONS.URGENT') }, + ], filterTypes: advancedFilterTypes.value, allCustomAttributes: conversationCustomAttributes.value, }; diff --git a/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js b/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js index a3e8e2c10..08b456002 100644 --- a/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js +++ b/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js @@ -22,6 +22,14 @@ const filterTypes = [ filterOperators: OPERATOR_TYPES_2, attributeModel: 'standard', }, + { + attributeKey: 'priority', + attributeI18nKey: 'PRIORITY', + inputType: 'multi_select', + dataType: 'text', + filterOperators: OPERATOR_TYPES_1, + attributeModel: 'standard', + }, { attributeKey: 'inbox_id', attributeI18nKey: 'INBOX_NAME', diff --git a/app/javascript/dashboard/helper/customViewsHelper.js b/app/javascript/dashboard/helper/customViewsHelper.js index f3ed8a3a7..c837ce613 100644 --- a/app/javascript/dashboard/helper/customViewsHelper.js +++ b/app/javascript/dashboard/helper/customViewsHelper.js @@ -21,6 +21,7 @@ export const getAttributeInputType = (key, allCustomAttributes) => { const customAttribute = allCustomAttributes.find( attr => attr.attribute_key === key ); + const { attribute_display_type } = customAttribute; const filterInputTypes = generateCustomAttributesInputType( attribute_display_type @@ -68,10 +69,22 @@ const getValuesForCountries = (values, countries) => { })); }; +const getValuesForPriority = (values, priority) => { + return priority.filter(option => values.includes(option.id)); +}; + export const getValuesForFilter = (filter, params) => { const { attribute_key, values } = filter; - const { languages, countries, agents, inboxes, teams, campaigns, labels } = - params; + const { + languages, + countries, + agents, + inboxes, + teams, + campaigns, + labels, + priority, + } = params; switch (attribute_key) { case 'status': return getValuesForStatus(values); @@ -83,15 +96,14 @@ export const getValuesForFilter = (filter, params) => { return getValuesName(values, teams, 'id', 'name'); case 'campaign_id': return getValuesName(values, campaigns, 'id', 'title'); - case 'labels': { + case 'labels': return getValuesForLabels(values, labels); - } - case 'browser_language': { + case 'priority': + return getValuesForPriority(values, priority); + case 'browser_language': return getValuesForLanguages(values, languages); - } - case 'country_code': { + case 'country_code': return getValuesForCountries(values, countries); - } default: return { id: values[0], name: values[0] }; } @@ -100,9 +112,9 @@ export const getValuesForFilter = (filter, params) => { export const generateValuesForEditCustomViews = (filter, params) => { const { attribute_key, filter_operator, values } = filter; const { filterTypes, allCustomAttributes } = params; - const inboxType = getInputType(attribute_key, filter_operator, filterTypes); + const inputType = getInputType(attribute_key, filter_operator, filterTypes); - if (inboxType === undefined) { + if (inputType === undefined) { const filterInputTypes = getAttributeInputType( attribute_key, allCustomAttributes @@ -112,7 +124,7 @@ export const generateValuesForEditCustomViews = (filter, params) => { : { id: values[0], name: values[0] }; } - return inboxType === 'multi_select' || inboxType === 'search_select' + return inputType === 'multi_select' || inputType === 'search_select' ? getValuesForFilter(filter, params) : values[0].toString(); }; diff --git a/app/javascript/dashboard/store/modules/conversations/getters.js b/app/javascript/dashboard/store/modules/conversations/getters.js index 9329ef5dc..33f140fad 100644 --- a/app/javascript/dashboard/store/modules/conversations/getters.js +++ b/app/javascript/dashboard/store/modules/conversations/getters.js @@ -1,6 +1,7 @@ import { MESSAGE_TYPE } from 'shared/constants/messages'; import { applyPageFilters, sortComparator } from './helpers'; import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator'; +import { matchesFilters } from './helpers/filterHelpers'; import camelcaseKeys from 'camelcase-keys'; export const getSelectedChatConversation = ({ @@ -13,6 +14,15 @@ const getters = { getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => { return allConversations.sort((a, b) => sortComparator(a, b, sortKey)); }, + getFilteredConversations: ({ + allConversations, + chatSortFilter, + appliedFilters, + }) => { + return allConversations + .filter(conversation => matchesFilters(conversation, appliedFilters)) + .sort((a, b) => sortComparator(a, b, chatSortFilter)); + }, getSelectedChat: ({ selectedChatId, allConversations }) => { const selectedChat = allConversations.find( conversation => conversation.id === selectedChatId diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js new file mode 100644 index 000000000..2591c2c01 --- /dev/null +++ b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js @@ -0,0 +1,356 @@ +/** + * 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'; + +/** + * 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 'display_id': + case 'labels': + case 'created_at': + case 'last_activity_at': + return conversation[attributeKey]; + 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 'country_code': + 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') { + return conversationValue.toLowerCase().includes(filterValue.toLowerCase()); + } + return false; +}; + +/** + * 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; + + // Handle null/undefined values + if (conversationValue === null || conversationValue === undefined) { + return filterOperator === 'is_not_present'; + } + + 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 true; // We already handled null/undefined above + + case 'is_not_present': + return false; // We already handled null/undefined above + + case 'is_greater_than': + return new Date(conversationValue) > new Date(filterValue); + + case 'is_less_than': + return new Date(conversationValue) < new Date(filterValue); + + case 'days_before': { + 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)); +}; diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js new file mode 100644 index 000000000..6d709f085 --- /dev/null +++ b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js @@ -0,0 +1,1302 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { matchesFilters } from '../filterHelpers'; + +// SAMPLE PAYLOAD +// +// { +// attribute_key: 'status', +// attribute_model: 'standard', +// filter_operator: 'equal_to', +// values: [ +// { +// id: 'open', +// name: 'Open', +// }, +// { +// id: 'resolved', +// name: 'Resolved', +// }, +// { +// id: 'pending', +// name: 'Pending', +// }, +// { +// id: 'snoozed', +// name: 'Snoozed', +// }, +// { +// id: 'all', +// name: 'All', +// }, +// ], +// query_operator: 'and', +// custom_attribute_type: '', +// }, +// { +// attribute_key: 'priority', +// filter_operator: 'equal_to', +// values: [ +// { +// id: 'low', +// name: 'Low', +// }, +// { +// id: 'medium', +// name: 'Medium', +// }, +// { +// id: 'high', +// name: 'High', +// }, +// ], +// query_operator: 'and', +// }, +// { +// attribute_key: 'assignee_id', +// filter_operator: 'equal_to', +// values: { +// id: 12345, +// name: 'Agent Name', +// }, +// query_operator: 'and', +// }, +// { +// attribute_key: 'inbox_id', +// filter_operator: 'equal_to', +// values: { +// id: 37, +// name: 'Support Inbox', +// }, +// query_operator: 'and', +// }, +// { +// attribute_key: 'team_id', +// filter_operator: 'equal_to', +// values: { +// id: 220, +// name: 'support-team', +// }, +// query_operator: 'and', +// }, +// { +// attribute_key: 'created_at', +// filter_operator: 'is_greater_than', +// values: '2023-01-20', +// query_operator: 'and', +// }, +// { +// attribute_key: 'last_activity_at', +// filter_operator: 'days_before', +// values: '998', +// query_operator: 'and', +// }, + +describe('filterHelpers', () => { + describe('#matchesFilters', () => { + it('returns true by default when no filters are provided', () => { + const conversation = {}; + const filters = []; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + // Standard attribute tests - status + it('should match conversation with equal_to operator for status', () => { + const conversation = { status: 'open' }; + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'open', name: 'Open' }], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should match conversation with equal_to operator for status "all"', () => { + const conversation = { status: 'open' }; + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'all', name: 'all' }], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should not match conversation with not_equal_to operator for status "all"', () => { + const conversation = { status: 'open' }; + const filters = [ + { + attribute_key: 'status', + filter_operator: 'not_equal_to', + values: [{ id: 'all', name: 'all' }], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(false); + }); + + it('should not match conversation with not_equal_to operator for status', () => { + const conversation = { status: 'open' }; + const filters = [ + { + attribute_key: 'status', + filter_operator: 'not_equal_to', + values: [{ id: 'open', name: 'Open' }], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(false); + }); + + // Standard attribute tests - assignee_id + it('should match conversation with equal_to operator for assignee_id', () => { + const conversation = { meta: { assignee: { id: 1 } } }; + const filters = [ + { + attribute_key: 'assignee_id', + filter_operator: 'equal_to', + values: { id: 1, name: 'John Doe' }, + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should not match conversation with not_equal_to operator for assignee_id', () => { + const conversation = { meta: { assignee: { id: 1 } } }; + const filters = [ + { + attribute_key: 'assignee_id', + filter_operator: 'not_equal_to', + values: { id: 2, name: 'Jane Smith' }, + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should match conversation with is_present operator for assignee_id', () => { + const conversation = { meta: { assignee: { id: 1 } } }; + const filters = [ + { + attribute_key: 'assignee_id', + filter_operator: 'is_present', + values: [], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should match conversation with is_not_present operator for assignee_id', () => { + const conversation = { meta: { assignee: null } }; + const filters = [ + { + attribute_key: 'assignee_id', + filter_operator: 'is_not_present', + values: [], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should not match conversation with is_present operator when assignee is null', () => { + const conversation = { meta: { assignee: null } }; + const filters = [ + { + attribute_key: 'assignee_id', + filter_operator: 'is_present', + values: [], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(false); + }); + + // Standard attribute tests - priority + it('should match conversation with equal_to operator for priority', () => { + const conversation = { priority: 'urgent' }; + const filters = [ + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: [{ id: 'urgent', name: 'Urgent' }], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should not match conversation with not_equal_to operator for priority', () => { + const conversation = { priority: 'urgent' }; + const filters = [ + { + attribute_key: 'priority', + filter_operator: 'not_equal_to', + values: [{ id: 'urgent', name: 'Urgent' }], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(false); + }); + + // Text search tests - display_id + it('should match conversation with equal_to operator for display_id', () => { + const conversation = { display_id: '12345' }; + const filters = [ + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: '12345', + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should match conversation with contains operator for display_id', () => { + const conversation = { display_id: '12345' }; + const filters = [ + { + attribute_key: 'display_id', + filter_operator: 'contains', + values: '234', + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should not match conversation with does_not_contain operator for display_id', () => { + const conversation = { display_id: '12345' }; + const filters = [ + { + attribute_key: 'display_id', + filter_operator: 'does_not_contain', + values: '234', + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(false); + }); + + it('should match conversation with does_not_contain operator when value is not present', () => { + const conversation = { display_id: '12345' }; + const filters = [ + { + attribute_key: 'display_id', + filter_operator: 'does_not_contain', + values: '678', + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + // Array/List tests - labels + it('should match conversation with equal_to operator for labels', () => { + const conversation = { labels: ['support', 'urgent', 'new'] }; + const filters = [ + { + attribute_key: 'labels', + filter_operator: 'equal_to', + values: [{ id: 'urgent', name: 'Urgent' }], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should not match conversation with equal_to operator for labels when value is not present', () => { + const conversation = { labels: ['support', 'urgent', 'new'] }; + const filters = [ + { + attribute_key: 'labels', + filter_operator: 'equal_to', + values: [{ id: 'billing', name: 'Billing' }], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(false); + }); + + it('should match conversation with not_equal_to operator for labels when value is not present', () => { + const conversation = { labels: ['support', 'urgent', 'new'] }; + const filters = [ + { + attribute_key: 'labels', + filter_operator: 'not_equal_to', + values: [{ id: 'billing', name: 'Billing' }], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should match conversation with is_present operator for labels', () => { + const conversation = { labels: ['support', 'urgent', 'new'] }; + const filters = [ + { + attribute_key: 'labels', + filter_operator: 'is_present', + values: [], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should match conversation with is_not_present operator for labels when labels is null', () => { + const conversation = { labels: null }; + const filters = [ + { + attribute_key: 'labels', + filter_operator: 'is_not_present', + values: [], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should match conversation with is_not_present operator for labels when labels is undefined', () => { + const conversation = {}; + const filters = [ + { + attribute_key: 'labels', + filter_operator: 'is_not_present', + values: [], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + // Additional attributes tests + it('should match conversation with equal_to operator for browser_language', () => { + const conversation = { + additional_attributes: { browser_language: 'en-US' }, + }; + const filters = [ + { + attribute_key: 'browser_language', + filter_operator: 'equal_to', + values: 'en-US', + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should match conversation with contains operator for referer', () => { + const conversation = { + additional_attributes: { referer: 'https://www.chatwoot.com/pricing' }, + }; + const filters = [ + { + attribute_key: 'referer', + filter_operator: 'contains', + values: 'chatwoot', + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should not match conversation with does_not_contain operator for referer', () => { + const conversation = { + additional_attributes: { referer: 'https://www.chatwoot.com/pricing' }, + }; + const filters = [ + { + attribute_key: 'referer', + filter_operator: 'does_not_contain', + values: 'chatwoot', + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(false); + }); + + // Date tests + it('should match conversation with is_greater_than operator for created_at', () => { + const conversation = { created_at: 1647777600000 }; // March 20, 2022 + const filters = [ + { + attribute_key: 'created_at', + filter_operator: 'is_greater_than', + values: '2022-03-19', + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should not match conversation with is_greater_than operator for created_at when date is earlier', () => { + const conversation = { created_at: 1647691200000 }; // March 19, 2022 + const filters = [ + { + attribute_key: 'created_at', + filter_operator: 'is_greater_than', + values: '2022-03-20', + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(false); + }); + + it('should match conversation with is_less_than operator for created_at', () => { + const conversation = { created_at: 1647777600000 }; // March 20, 2022 + const filters = [ + { + attribute_key: 'created_at', + filter_operator: 'is_less_than', + values: '2022-03-21', // March 21, 2022 + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + describe('days_before operator', () => { + beforeEach(() => { + // Set the date to March 25, 2022 + vi.useFakeTimers(); + vi.setSystemTime(new Date(2022, 2, 25)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should match conversation with days_before operator for created_at', () => { + const conversation = { created_at: 1647777600000 }; // March 20, 2022 + const filters = [ + { + attribute_key: 'created_at', + filter_operator: 'days_before', + values: '3', // 3 days before March 25 = March 22 + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + }); + + // Multiple filters tests + it('should match conversation with multiple filters combined with AND operator', () => { + const conversation = { + status: 'open', + priority: 'urgent', + meta: { assignee: { id: 1 } }, + }; + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'open', name: 'Open' }], + query_operator: 'and', + }, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: [{ id: 'urgent', name: 'Urgent' }], + query_operator: 'and', + }, + { + attribute_key: 'assignee_id', + filter_operator: 'equal_to', + values: { + id: 1, + name: 'Agent', + }, + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should not match conversation when one filter in AND chain does not match', () => { + const conversation = { + status: 'open', + priority: 'urgent', + meta: { assignee: { id: 1 } }, + }; + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'open', name: 'Open' }], + query_operator: 'and', + }, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: [{ id: 'low', name: 'Low' }], // This doesn't match + query_operator: 'and', + }, + { + attribute_key: 'assignee_id', + filter_operator: 'equal_to', + values: { + id: 1, + name: 'Agent', + }, + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(false); + }); + + it('should match conversation with multiple filters combined with OR operator', () => { + const conversation = { + status: 'open', + priority: 'low', + }; + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'closed', name: 'Closed' }], + query_operator: 'or', + }, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: [{ id: 'low', name: 'Low' }], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should match conversation when one filter in OR chain matches', () => { + const conversation = { + status: 'open', + priority: 'low', + }; + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'open', name: 'Open' }], // This matches + query_operator: 'or', + }, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: [{ id: 'urgent', name: 'Urgent' }], // This doesn't match + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should match conversation with multiple status and priority', () => { + const conversation = { + status: 'open', + priority: 'high', + meta: { + assignee: { + id: 83235, + }, + }, + }; + + const filters = [ + { + values: ['open', 'resolved'], + attribute_key: 'status', + query_operator: 'and', + attribute_model: 'standard', + filter_operator: 'equal_to', + custom_attribute_type: '', + }, + { + values: [83235], + attribute_key: 'assignee_id', + query_operator: 'and', + filter_operator: 'equal_to', + }, + { + values: ['high', 'urgent'], + attribute_key: 'priority', + filter_operator: 'equal_to', + }, + ]; + + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + // Nested property tests + it('should match conversation with filter on nested property in meta', () => { + const conversation = { + meta: { + team: { + id: 5, + name: 'Support', + }, + }, + }; + const filters = [ + { + attribute_key: 'team_id', + filter_operator: 'equal_to', + values: { id: 5, name: 'Support' }, + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + // Edge cases + it('should handle null values in conversation', () => { + const conversation = { + status: null, + priority: 'low', + }; + const filters = [ + { + attribute_key: 'status', + filter_operator: 'is_not_present', + values: [], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should handle empty arrays in conversation', () => { + const conversation = { + labels: [], + }; + const filters = [ + { + attribute_key: 'labels', + filter_operator: 'is_present', + values: [], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should handle empty string values in conversation', () => { + const conversation = { + display_id: '', + }; + const filters = [ + { + attribute_key: 'display_id', + filter_operator: 'is_present', + values: [], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + // Custom attributes tests + it('should match conversation with filter on custom attribute', () => { + const conversation = { + custom_attributes: { + customer_type: 'premium', + }, + }; + const filters = [ + { + attribute_key: 'customer_type', + filter_operator: 'equal_to', + values: 'premium', + query_operator: 'and', + attributeModel: 'customAttributes', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should match conversation with contains operator on custom attribute', () => { + const conversation = { + custom_attributes: { + notes: 'This customer has requested a refund', + }, + }; + const filters = [ + { + attribute_key: 'notes', + filter_operator: 'contains', + values: 'refund', + query_operator: 'and', + attributeModel: 'customAttributes', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + // Combination tests + it('should match conversation with combination of different attribute types', () => { + const conversation = { + status: 'open', + created_at: 1647777600000, // March 20, 2022 + custom_attributes: { + customer_type: 'premium', + }, + }; + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'open', name: 'Open' }], + query_operator: 'and', + }, + { + attribute_key: 'created_at', + filter_operator: 'is_greater_than', + values: '2022-03-19', // March 19, 2022 + query_operator: 'and', + }, + { + attribute_key: 'customer_type', + filter_operator: 'equal_to', + values: 'premium', + query_operator: 'and', + attributeModel: 'customAttributes', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + // Backend alignment tests + describe('Backend alignment tests', () => { + // Test case for: status='open' AND priority='urgent' OR display_id='12345' + describe('with A AND B OR C filter chain', () => { + it('matches when all conditions are true', () => { + const conversation = { + status: 'open', // A: true + priority: 'urgent', // B: true + display_id: '12345', // C: true + }; + + // This filter chain is: (status='open' AND priority='urgent') OR display_id='12345' + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'open', name: 'Open' }], + query_operator: 'and', + }, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: [{ id: 'urgent', name: 'Urgent' }], + query_operator: 'or', + }, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: '12345', + query_operator: null, + }, + ]; + + // Expected: (true AND true) OR true = true OR true = true + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('matches when first condition is false but third is true', () => { + const conversation = { + status: 'resolved', // A: false + priority: 'urgent', // B: true + display_id: '12345', // C: true + }; + + // This filter chain is: (status='open' AND priority='urgent') OR display_id='12345' + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'open', name: 'Open' }], + query_operator: 'and', + }, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: [{ id: 'urgent', name: 'Urgent' }], + query_operator: 'or', + }, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: '12345', + query_operator: null, + }, + ]; + + // Expected: (false AND true) OR true = false OR true = true + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('matches when first and second condition is false but third is true', () => { + const conversation = { + status: 'resolved', // A: false + priority: 'low', // B: false + display_id: '12345', // C: true + }; + + // This filter chain is: (status='open' AND priority='urgent') OR display_id='12345' + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'open', name: 'Open' }], + query_operator: 'and', + }, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: [{ id: 'urgent', name: 'Urgent' }], + query_operator: 'or', + }, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: '12345', + query_operator: null, + }, + ]; + + // Expected: (false AND false) OR true = false OR true = true + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('does not match when all conditions are false', () => { + const conversation = { + status: 'resolved', // A: false + priority: 'low', // B: false + display_id: '67890', // C: false + }; + + // This filter chain is: (status='open' AND priority='urgent') OR display_id='12345' + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'open', name: 'Open' }], + query_operator: 'and', + }, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: [ + { + id: 'urgent', + name: 'Urgent', + }, + ], + query_operator: 'or', + }, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: '12345', + query_operator: null, + }, + ]; + + // Expected: (false AND false) OR false = false OR false = false + expect(matchesFilters(conversation, filters)).toBe(false); + }); + }); + + // Test case for: status='open' OR priority='low' AND display_id='67890' + describe('with A OR B AND C filter chain', () => { + it('matches when first condition is true', () => { + const conversation = { + status: 'open', // A: true + priority: 'urgent', // B: false + display_id: '12345', // C: false + }; + + // This filter chain is: status='open' OR (priority='low' AND display_id='67890') + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'open', name: 'Open' }], + query_operator: 'or', + }, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: [{ id: 'low', name: 'Low' }], + query_operator: 'and', + }, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: '67890', + query_operator: null, + }, + ]; + + // Expected: true OR (false AND false) = true OR false = true + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('matches when second and third conditions are true', () => { + const conversation = { + status: 'resolved', // A: false + priority: 'low', // B: true + display_id: '67890', // C: true + }; + + // This filter chain is: status='open' OR (priority='low' AND display_id='67890') + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'open', name: 'Open' }], + query_operator: 'or', + }, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: [{ id: 'low', name: 'Low' }], + query_operator: 'and', + }, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: '67890', + query_operator: null, + }, + ]; + + // Expected: false OR (true AND true) = false OR true = true + expect(matchesFilters(conversation, filters)).toBe(true); + }); + }); + + // Test case for: status='open' AND priority='urgent' OR display_id='67890' AND browser_language='tr' + describe('with complex filter chain A AND B OR C AND D', () => { + it('matches when first two conditions are true', () => { + const conversation = { + status: 'open', // A: true + priority: 'urgent', // B: true + display_id: '12345', // C: false + additional_attributes: { + browser_language: 'en', // D: false + }, + }; + + // This filter chain is: (status='open' AND priority='urgent') OR (display_id='67890' AND browser_language='tr') + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'open', name: 'Open' }], + query_operator: 'and', + }, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: [{ id: 'urgent', name: 'Urgent' }], + query_operator: 'or', + }, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: '67890', + query_operator: 'and', + }, + { + attribute_key: 'browser_language', + filter_operator: 'equal_to', + values: 'tr', + query_operator: null, + }, + ]; + + // Expected: (true AND true) OR (false AND false) = true OR false = true + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('matches when last two conditions are true', () => { + const conversation = { + status: 'resolved', // A: false + priority: 'low', // B: false + display_id: '67890', // C: true + additional_attributes: { + browser_language: 'tr', // D: true + }, + }; + + // This filter chain is: (status='open' AND priority='urgent') OR (display_id='67890' AND browser_language='tr') + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'open', name: 'Open' }], + query_operator: 'and', + }, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: [{ id: 'urgent', name: 'Urgent' }], + query_operator: 'or', + }, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: '67890', + query_operator: 'and', + }, + { + attribute_key: 'browser_language', + filter_operator: 'equal_to', + values: 'tr', + query_operator: null, + }, + ]; + + // Expected: (false AND false) OR (true AND true) = false OR true = true + expect(matchesFilters(conversation, filters)).toBe(true); + }); + }); + + // Test case for: status='open' AND (priority='urgent' OR display_id='67890') AND conversation_type='platinum' + describe('with mixed operators filter chain', () => { + it('matches when all conditions in the chain are true', () => { + const conversation = { + status: 'open', // A: true + priority: 'urgent', // B: true + display_id: '12345', // C: false + custom_attributes: { + conversation_type: 'platinum', // D: true + }, + }; + + // This filter chain is: status='open' AND (priority='urgent' OR display_id='67890') AND conversation_type='platinum' + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'open', name: 'Open' }], + query_operator: 'and', + }, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: [{ id: 'urgent', name: 'Urgent' }], + query_operator: 'or', + }, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: '67890', + query_operator: 'and', + }, + { + attribute_key: 'conversation_type', + filter_operator: 'equal_to', + values: 'platinum', + query_operator: null, + attributeModel: 'customAttributes', + }, + ]; + + // Expected: true AND (true OR false) AND true = true AND true AND true = true + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('does not match when the last condition is false', () => { + const conversation = { + status: 'open', // A: true + priority: 'urgent', // B: true + display_id: '12345', // C: false + custom_attributes: { + conversation_type: 'silver', // D: false + }, + }; + + // This filter chain is: status='open' AND (priority='urgent' OR display_id='67890') AND conversation_type='platinum' + const filters = [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: [{ id: 'open', name: 'Open' }], + query_operator: 'and', + }, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: [{ id: 'urgent', name: 'Urgent' }], + query_operator: 'or', + }, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: '67890', + query_operator: 'and', + }, + { + attribute_key: 'conversation_type', + filter_operator: 'equal_to', + values: 'platinum', + query_operator: null, + attributeModel: 'customAttributes', + }, + ]; + + // true AND true OR false AND false + // true OR false + // true + expect(matchesFilters(conversation, filters)).toBe(true); + }); + }); + }); + + // Test for inbox_id in getValueFromConversation + it('should match conversation with equal_to operator for inbox_id', () => { + const conversation = { inbox_id: 123 }; + const filters = [ + { + attribute_key: 'inbox_id', + filter_operator: 'equal_to', + values: { id: 123, name: 'Support Inbox' }, + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should not match conversation with equal_to operator for inbox_id when values differ', () => { + const conversation = { inbox_id: 123 }; + const filters = [ + { + attribute_key: 'inbox_id', + filter_operator: 'equal_to', + values: { id: 456, name: 'Sales Inbox' }, + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(false); + }); + + // Test for default case (returning null) in getValueFromConversation + it('should not match conversation when attribute key is not recognized', () => { + const conversation = { status: 'open' }; + const filters = [ + { + attribute_key: 'unknown_attribute', + filter_operator: 'equal_to', + values: 'some_value', + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(false); + }); + + it('should match conversation with is_not_present operator for unknown attribute', () => { + const conversation = { status: 'open' }; + const filters = [ + { + attribute_key: 'unknown_attribute', + filter_operator: 'is_not_present', + values: [], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + // Test for contains operator when value is not a string + it('should not match conversation with contains operator when value is not a string', () => { + const conversation = { + custom_attributes: { + numeric_value: 12345, + }, + }; + const filters = [ + { + attribute_key: 'numeric_value', + filter_operator: 'contains', + values: '123', + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(false); + }); + + it('should not match conversation with contains operator when value is an array', () => { + const conversation = { + custom_attributes: { + array_value: [1, 2, 3, 4, 5], + }, + }; + const filters = [ + { + attribute_key: 'array_value', + filter_operator: 'contains', + values: '3', + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(false); + }); + + // Test for does_not_contain operator when value is not a string + it('should match conversation with does_not_contain operator when value is not a string', () => { + const conversation = { + custom_attributes: { + numeric_value: 12345, + }, + }; + const filters = [ + { + attribute_key: 'numeric_value', + filter_operator: 'does_not_contain', + values: '123', + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should match conversation with does_not_contain operator when value is an array', () => { + const conversation = { + custom_attributes: { + array_value: [1, 2, 3, 4, 5], + }, + }; + const filters = [ + { + attribute_key: 'array_value', + filter_operator: 'does_not_contain', + values: '3', + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + // Test for default case in matchesCondition + it('should not match conversation with unknown filter operator', () => { + const conversation = { status: 'open' }; + const filters = [ + { + attribute_key: 'status', + filter_operator: 'unknown_operator', + values: 'open', + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(false); + }); + }); +}); diff --git a/package.json b/package.json index adb86f768..9e5bab631 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "highlight.js": "^11.10.0", "idb": "^8.0.0", "js-cookie": "^3.0.5", + "json-logic-js": "^2.0.5", "lettersanitizer": "^1.0.6", "libphonenumber-js": "^1.11.9", "markdown-it": "^13.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdeeac9fb..6749103a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,6 +136,9 @@ importers: js-cookie: specifier: ^3.0.5 version: 3.0.5 + json-logic-js: + specifier: ^2.0.5 + version: 2.0.5 lettersanitizer: specifier: ^1.0.6 version: 1.0.6 @@ -3398,6 +3401,9 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-logic-js@2.0.5: + resolution: {integrity: sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -8761,6 +8767,8 @@ snapshots: json-buffer@3.0.1: {} + json-logic-js@2.0.5: {} + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} diff --git a/spec/services/conversations/filter_service_spec.rb b/spec/services/conversations/filter_service_spec.rb index 53878c780..5afa92e77 100644 --- a/spec/services/conversations/filter_service_spec.rb +++ b/spec/services/conversations/filter_service_spec.rb @@ -521,4 +521,241 @@ describe Conversations::FilterService do end end end + + describe 'Frontend alignment tests' do + let!(:account) { create(:account) } + let!(:user_1) { create(:user, account: account) } + let!(:inbox) { create(:inbox, account: account) } + let!(:params) { { payload: [], page: 1 } } + + before do + account.conversations.destroy_all + end + + context 'with A AND B OR C filter chain' do + let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) } + let(:filter_payload) do + [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: ['open'], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: ['urgent'], + query_operator: 'OR' + }.with_indifferent_access, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: ['12345'], + query_operator: nil + }.with_indifferent_access + ] + end + + before do + conversation.update!( + status: 'open', + priority: 'urgent', + display_id: '12345', + additional_attributes: { 'browser_language': 'en' } + ) + end + + it 'matches when all conditions are true' do + params[:payload] = filter_payload + result = described_class.new(params, user_1).perform + expect(result[:conversations].length).to be 1 + end + + it 'matches when first condition is false but third is true' do + conversation.update!(status: 'resolved', priority: 'urgent', display_id: '12345') + params[:payload] = filter_payload + result = described_class.new(params, user_1).perform + expect(result[:conversations].length).to be 1 + end + + it 'matches when first and second condition is false but third is true' do + conversation.update!(status: 'resolved', priority: 'low', display_id: '12345') + params[:payload] = filter_payload + result = described_class.new(params, user_1).perform + expect(result[:conversations].length).to be 1 + end + + it 'does not match when all conditions are false' do + conversation.update!(status: 'resolved', priority: 'low', display_id: '67890') + params[:payload] = filter_payload + result = described_class.new(params, user_1).perform + expect(result[:conversations].length).to be 0 + end + end + + context 'with A OR B AND C filter chain' do + let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) } + let(:filter_payload) do + [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: ['open'], + query_operator: 'OR' + }.with_indifferent_access, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: ['low'], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: ['67890'], + query_operator: nil + }.with_indifferent_access + ] + end + + before do + conversation.update!( + status: 'open', + priority: 'urgent', + display_id: '12345', + additional_attributes: { 'browser_language': 'en' } + ) + end + + it 'matches when first condition is true' do + params[:payload] = filter_payload + result = described_class.new(params, user_1).perform + expect(result[:conversations].length).to be 1 + end + + it 'matches when second and third conditions are true' do + conversation.update!(status: 'resolved', priority: 'low', display_id: '67890') + params[:payload] = filter_payload + result = described_class.new(params, user_1).perform + expect(result[:conversations].length).to be 1 + end + end + + context 'with complex filter chain A AND B OR C AND D' do + let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) } + let(:filter_payload) do + [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: ['open'], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: ['urgent'], + query_operator: 'OR' + }.with_indifferent_access, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: ['67890'], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'browser_language', + filter_operator: 'equal_to', + values: ['tr'], + query_operator: nil + }.with_indifferent_access + ] + end + + before do + conversation.update!( + status: 'open', + priority: 'urgent', + display_id: '12345', + additional_attributes: { 'browser_language': 'en' }, + custom_attributes: { conversation_type: 'platinum' } + ) + end + + it 'matches when first two conditions are true' do + params[:payload] = filter_payload + result = described_class.new(params, user_1).perform + expect(result[:conversations].length).to be 1 + end + + it 'matches when last two conditions are true' do + conversation.update!( + status: 'resolved', + priority: 'low', + display_id: '67890', + additional_attributes: { 'browser_language': 'tr' } + ) + params[:payload] = filter_payload + result = described_class.new(params, user_1).perform + expect(result[:conversations].length).to be 1 + end + end + + context 'with mixed operators filter chain' do + let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) } + let(:filter_payload) do + [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: ['open'], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: ['urgent'], + query_operator: 'OR' + }.with_indifferent_access, + { + attribute_key: 'display_id', + filter_operator: 'equal_to', + values: ['67890'], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'conversation_type', + filter_operator: 'equal_to', + values: ['platinum'], + custom_attribute_type: '', + query_operator: nil + }.with_indifferent_access + ] + end + + before do + conversation.update!( + status: 'open', + priority: 'urgent', + display_id: '12345', + additional_attributes: { 'browser_language': 'en' }, + custom_attributes: { conversation_type: 'platinum' } + ) + end + + it 'matches when all conditions in the chain are true' do + params[:payload] = filter_payload + result = described_class.new(params, user_1).perform + expect(result[:conversations].length).to be 1 + end + + it 'does not match when the last condition is false' do + conversation.update!(custom_attributes: { conversation_type: 'silver' }) + params[:payload] = filter_payload + result = described_class.new(params, user_1).perform + expect(result[:conversations].length).to be 1 + end + end + end end