diff --git a/app/helpers/filter_helper.rb b/app/helpers/filters/filter_helper.rb similarity index 90% rename from app/helpers/filter_helper.rb rename to app/helpers/filters/filter_helper.rb index 9b5cac684..fe03dae28 100644 --- a/app/helpers/filter_helper.rb +++ b/app/helpers/filters/filter_helper.rb @@ -1,4 +1,4 @@ -module FilterHelper +module Filters::FilterHelper def build_condition_query(model_filters, query_hash, current_index) current_filter = model_filters[query_hash['attribute_key']] @@ -89,4 +89,18 @@ module FilterHelper operator = condition['query_operator'].upcase raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator) end + + def conversation_status_values(values) + return Conversation.statuses.values if values.include?('all') + + values.map { |x| Conversation.statuses[x.to_sym] } + end + + def conversation_priority_values(values) + values.map { |x| Conversation.priorities[x.to_sym] } + end + + def message_type_values(values) + values.map { |x| Message.message_types[x.to_sym] } + end end diff --git a/app/javascript/dashboard/components-next/filter/contactProvider.js b/app/javascript/dashboard/components-next/filter/contactProvider.js index 4aceac359..4b2709e62 100644 --- a/app/javascript/dashboard/components-next/filter/contactProvider.js +++ b/app/javascript/dashboard/components-next/filter/contactProvider.js @@ -2,7 +2,10 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { useOperators } from './operators'; import { useMapGetter } from 'dashboard/composables/store.js'; -import { buildAttributesFilterTypes } from './helper/filterHelper.js'; +import { + buildAttributesFilterTypes, + CONTACT_ATTRIBUTES, +} from './helper/filterHelper.js'; import countries from 'shared/constants/countries.js'; /** @@ -59,7 +62,11 @@ export function useContactFilterContext() { * @type {import('vue').ComputedRef} */ const customFilterTypes = computed(() => - buildAttributesFilterTypes(contactAttributes.value, getOperatorTypes) + buildAttributesFilterTypes( + contactAttributes.value, + getOperatorTypes, + 'contact' + ) ); /** @@ -67,8 +74,8 @@ export function useContactFilterContext() { */ const filterTypes = computed(() => [ { - attributeKey: 'name', - value: 'name', + attributeKey: CONTACT_ATTRIBUTES.NAME, + value: CONTACT_ATTRIBUTES.NAME, attributeName: t('CONTACTS_LAYOUT.FILTER.NAME'), label: t('CONTACTS_LAYOUT.FILTER.NAME'), inputType: 'plainText', @@ -77,8 +84,8 @@ export function useContactFilterContext() { attributeModel: 'standard', }, { - attributeKey: 'email', - value: 'email', + attributeKey: CONTACT_ATTRIBUTES.EMAIL, + value: CONTACT_ATTRIBUTES.EMAIL, attributeName: t('CONTACTS_LAYOUT.FILTER.EMAIL'), label: t('CONTACTS_LAYOUT.FILTER.EMAIL'), inputType: 'plainText', @@ -87,8 +94,8 @@ export function useContactFilterContext() { attributeModel: 'standard', }, { - attributeKey: 'phone_number', - value: 'phone_number', + attributeKey: CONTACT_ATTRIBUTES.PHONE_NUMBER, + value: CONTACT_ATTRIBUTES.PHONE_NUMBER, attributeName: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'), label: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'), inputType: 'plainText', @@ -97,8 +104,8 @@ export function useContactFilterContext() { attributeModel: 'standard', }, { - attributeKey: 'identifier', - value: 'identifier', + attributeKey: CONTACT_ATTRIBUTES.IDENTIFIER, + value: CONTACT_ATTRIBUTES.IDENTIFIER, attributeName: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'), label: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'), inputType: 'plainText', @@ -107,8 +114,8 @@ export function useContactFilterContext() { attributeModel: 'standard', }, { - attributeKey: 'country_code', - value: 'country_code', + attributeKey: CONTACT_ATTRIBUTES.COUNTRY_CODE, + value: CONTACT_ATTRIBUTES.COUNTRY_CODE, attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'), label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'), inputType: 'searchSelect', @@ -118,8 +125,8 @@ export function useContactFilterContext() { attributeModel: 'additional', }, { - attributeKey: 'city', - value: 'city', + attributeKey: CONTACT_ATTRIBUTES.CITY, + value: CONTACT_ATTRIBUTES.CITY, attributeName: t('CONTACTS_LAYOUT.FILTER.CITY'), label: t('CONTACTS_LAYOUT.FILTER.CITY'), inputType: 'plainText', @@ -128,8 +135,8 @@ export function useContactFilterContext() { attributeModel: 'standard', }, { - attributeKey: 'created_at', - value: 'created_at', + attributeKey: CONTACT_ATTRIBUTES.CREATED_AT, + value: CONTACT_ATTRIBUTES.CREATED_AT, attributeName: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'), label: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'), inputType: 'date', @@ -138,8 +145,8 @@ export function useContactFilterContext() { attributeModel: 'standard', }, { - attributeKey: 'last_activity_at', - value: 'last_activity_at', + attributeKey: CONTACT_ATTRIBUTES.LAST_ACTIVITY_AT, + value: CONTACT_ATTRIBUTES.LAST_ACTIVITY_AT, attributeName: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'), label: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'), inputType: 'date', @@ -148,8 +155,8 @@ export function useContactFilterContext() { attributeModel: 'standard', }, { - attributeKey: 'referer', - value: 'referer', + attributeKey: CONTACT_ATTRIBUTES.REFERER, + value: CONTACT_ATTRIBUTES.REFERER, attributeName: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'), label: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'), inputType: 'plainText', @@ -158,8 +165,8 @@ export function useContactFilterContext() { attributeModel: 'standard', }, { - attributeKey: 'blocked', - value: 'blocked', + attributeKey: CONTACT_ATTRIBUTES.BLOCKED, + value: CONTACT_ATTRIBUTES.BLOCKED, attributeName: t('CONTACTS_LAYOUT.FILTER.BLOCKED'), label: t('CONTACTS_LAYOUT.FILTER.BLOCKED'), inputType: 'searchSelect', diff --git a/app/javascript/dashboard/components-next/filter/helper/filterHelper.js b/app/javascript/dashboard/components-next/filter/helper/filterHelper.js index 2160365f6..cb5acfe40 100644 --- a/app/javascript/dashboard/components-next/filter/helper/filterHelper.js +++ b/app/javascript/dashboard/components-next/filter/helper/filterHelper.js @@ -1,3 +1,35 @@ +/** + * Standard attributes of the conversation model + */ +export const CONVERSATION_ATTRIBUTES = { + STATUS: 'status', + PRIORITY: 'priority', + ASSIGNEE_ID: 'assignee_id', + INBOX_ID: 'inbox_id', + TEAM_ID: 'team_id', + DISPLAY_ID: 'display_id', + CAMPAIGN_ID: 'campaign_id', + LABELS: 'labels', + BROWSER_LANGUAGE: 'browser_language', + COUNTRY_CODE: 'country_code', + REFERER: 'referer', + CREATED_AT: 'created_at', + LAST_ACTIVITY_AT: 'last_activity_at', +}; + +export const CONTACT_ATTRIBUTES = { + NAME: 'name', + EMAIL: 'email', + PHONE_NUMBER: 'phone_number', + IDENTIFIER: 'identifier', + COUNTRY_CODE: 'country_code', + CITY: 'city', + CREATED_AT: 'created_at', + LAST_ACTIVITY_AT: 'last_activity_at', + REFERER: 'referer', + BLOCKED: 'blocked', +}; + /** * Determines the input type for a custom attribute based on its key * @param {string} key - The attribute display type key @@ -20,24 +52,37 @@ export const getCustomAttributeInputType = key => { /** * Builds filter types for custom attributes + * This also removes any conflicting attributes * @param {Array} attributes - The attributes array * @param {Function} getOperatorTypes - Function to get operator types * @returns {Array} Array of filter types */ -export const buildAttributesFilterTypes = (attributes, getOperatorTypes) => { - return attributes.map(attr => ({ - attributeKey: attr.attributeKey, - value: attr.attributeKey, - attributeName: attr.attributeDisplayName, - label: attr.attributeDisplayName, - inputType: getCustomAttributeInputType(attr.attributeDisplayType), - filterOperators: getOperatorTypes(attr.attributeDisplayType), - options: - attr.attributeDisplayType === 'list' - ? attr.attributeValues.map(item => ({ id: item, name: item })) - : [], - attributeModel: 'customAttributes', - })); +export const buildAttributesFilterTypes = ( + attributes, + getOperatorTypes, + filterModel = 'conversation' +) => { + const standardAttributes = Object.values( + filterModel === 'conversation' + ? CONVERSATION_ATTRIBUTES + : CONTACT_ATTRIBUTES + ); + + return attributes + .filter(attr => !standardAttributes.includes(attr.attributeKey)) + .map(attr => ({ + attributeKey: attr.attributeKey, + value: attr.attributeKey, + attributeName: attr.attributeDisplayName, + label: attr.attributeDisplayName, + inputType: getCustomAttributeInputType(attr.attributeDisplayType), + filterOperators: getOperatorTypes(attr.attributeDisplayType), + options: + attr.attributeDisplayType === 'list' + ? attr.attributeValues.map(item => ({ id: item, name: item })) + : [], + attributeModel: 'customAttributes', + })); }; /** diff --git a/app/javascript/dashboard/components-next/filter/provider.js b/app/javascript/dashboard/components-next/filter/provider.js index bb775a663..f6d078d76 100644 --- a/app/javascript/dashboard/components-next/filter/provider.js +++ b/app/javascript/dashboard/components-next/filter/provider.js @@ -3,7 +3,10 @@ import { useI18n } from 'vue-i18n'; import { useOperators } from './operators'; import { useMapGetter } from 'dashboard/composables/store.js'; import { useChannelIcon } from 'next/icon/provider'; -import { buildAttributesFilterTypes } from './helper/filterHelper'; +import { + buildAttributesFilterTypes, + CONVERSATION_ATTRIBUTES, +} from './helper/filterHelper'; import countries from 'shared/constants/countries.js'; import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js'; @@ -70,7 +73,11 @@ export function useConversationFilterContext() { * @type {import('vue').ComputedRef} */ const customFilterTypes = computed(() => - buildAttributesFilterTypes(conversationAttributes.value, getOperatorTypes) + buildAttributesFilterTypes( + conversationAttributes.value, + getOperatorTypes, + 'conversation' + ) ); /** @@ -78,8 +85,8 @@ export function useConversationFilterContext() { */ const filterTypes = computed(() => [ { - attributeKey: 'status', - value: 'status', + attributeKey: CONVERSATION_ATTRIBUTES.STATUS, + value: CONVERSATION_ATTRIBUTES.STATUS, attributeName: t('FILTER.ATTRIBUTES.STATUS'), label: t('FILTER.ATTRIBUTES.STATUS'), inputType: 'multiSelect', @@ -94,8 +101,24 @@ export function useConversationFilterContext() { attributeModel: 'standard', }, { - attributeKey: 'assignee_id', - value: 'assignee_id', + attributeKey: CONVERSATION_ATTRIBUTES.PRIORITY, + value: CONVERSATION_ATTRIBUTES.PRIORITY, + attributeName: t('FILTER.ATTRIBUTES.PRIORITY'), + label: t('FILTER.ATTRIBUTES.PRIORITY'), + inputType: 'multiSelect', + options: ['low', 'medium', 'high', 'urgent'].map(id => { + return { + id, + name: t(`CONVERSATION.PRIORITY.OPTIONS.${id.toUpperCase()}`), + }; + }), + dataType: 'text', + filterOperators: equalityOperators.value, + attributeModel: 'standard', + }, + { + attributeKey: CONVERSATION_ATTRIBUTES.ASSIGNEE_ID, + value: CONVERSATION_ATTRIBUTES.ASSIGNEE_ID, attributeName: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'), label: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'), inputType: 'searchSelect', @@ -110,8 +133,8 @@ export function useConversationFilterContext() { attributeModel: 'standard', }, { - attributeKey: 'inbox_id', - value: 'inbox_id', + attributeKey: CONVERSATION_ATTRIBUTES.INBOX_ID, + value: CONVERSATION_ATTRIBUTES.INBOX_ID, attributeName: t('FILTER.ATTRIBUTES.INBOX_NAME'), label: t('FILTER.ATTRIBUTES.INBOX_NAME'), inputType: 'searchSelect', @@ -126,8 +149,8 @@ export function useConversationFilterContext() { attributeModel: 'standard', }, { - attributeKey: 'team_id', - value: 'team_id', + attributeKey: CONVERSATION_ATTRIBUTES.TEAM_ID, + value: CONVERSATION_ATTRIBUTES.TEAM_ID, attributeName: t('FILTER.ATTRIBUTES.TEAM_NAME'), label: t('FILTER.ATTRIBUTES.TEAM_NAME'), inputType: 'searchSelect', @@ -137,8 +160,8 @@ export function useConversationFilterContext() { attributeModel: 'standard', }, { - attributeKey: 'display_id', - value: 'display_id', + attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID, + value: CONVERSATION_ATTRIBUTES.DISPLAY_ID, attributeName: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'), label: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'), inputType: 'plainText', @@ -147,8 +170,8 @@ export function useConversationFilterContext() { attributeModel: 'standard', }, { - attributeKey: 'campaign_id', - value: 'campaign_id', + attributeKey: CONVERSATION_ATTRIBUTES.CAMPAIGN_ID, + value: CONVERSATION_ATTRIBUTES.CAMPAIGN_ID, attributeName: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'), label: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'), inputType: 'searchSelect', @@ -161,8 +184,8 @@ export function useConversationFilterContext() { attributeModel: 'standard', }, { - attributeKey: 'labels', - value: 'labels', + attributeKey: CONVERSATION_ATTRIBUTES.LABELS, + value: CONVERSATION_ATTRIBUTES.LABELS, attributeName: t('FILTER.ATTRIBUTES.LABELS'), label: t('FILTER.ATTRIBUTES.LABELS'), inputType: 'multiSelect', @@ -185,8 +208,8 @@ export function useConversationFilterContext() { attributeModel: 'standard', }, { - attributeKey: 'browser_language', - value: 'browser_language', + attributeKey: CONVERSATION_ATTRIBUTES.BROWSER_LANGUAGE, + value: CONVERSATION_ATTRIBUTES.BROWSER_LANGUAGE, attributeName: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'), label: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'), inputType: 'searchSelect', @@ -196,8 +219,8 @@ export function useConversationFilterContext() { attributeModel: 'additional', }, { - attributeKey: 'country_code', - value: 'country_code', + attributeKey: CONVERSATION_ATTRIBUTES.COUNTRY_CODE, + value: CONVERSATION_ATTRIBUTES.COUNTRY_CODE, attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'), label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'), inputType: 'searchSelect', @@ -207,8 +230,8 @@ export function useConversationFilterContext() { attributeModel: 'additional', }, { - attributeKey: 'referer', - value: 'referer', + attributeKey: CONVERSATION_ATTRIBUTES.REFERER, + value: CONVERSATION_ATTRIBUTES.REFERER, attributeName: t('FILTER.ATTRIBUTES.REFERER_LINK'), label: t('FILTER.ATTRIBUTES.REFERER_LINK'), inputType: 'plainText', @@ -217,8 +240,8 @@ export function useConversationFilterContext() { attributeModel: 'additional', }, { - attributeKey: 'created_at', - value: 'created_at', + attributeKey: CONVERSATION_ATTRIBUTES.CREATED_AT, + value: CONVERSATION_ATTRIBUTES.CREATED_AT, attributeName: t('FILTER.ATTRIBUTES.CREATED_AT'), label: t('FILTER.ATTRIBUTES.CREATED_AT'), inputType: 'date', @@ -227,8 +250,8 @@ export function useConversationFilterContext() { attributeModel: 'standard', }, { - attributeKey: 'last_activity_at', - value: 'last_activity_at', + attributeKey: CONVERSATION_ATTRIBUTES.LAST_ACTIVITY_AT, + value: CONVERSATION_ATTRIBUTES.LAST_ACTIVITY_AT, attributeName: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'), label: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'), inputType: 'date', diff --git a/app/models/custom_attribute_definition.rb b/app/models/custom_attribute_definition.rb index 7d7b36e42..09d415401 100644 --- a/app/models/custom_attribute_definition.rb +++ b/app/models/custom_attribute_definition.rb @@ -22,6 +22,12 @@ # index_custom_attribute_definitions_on_account_id (account_id) # class CustomAttributeDefinition < ApplicationRecord + STANDARD_ATTRIBUTES = { + :conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at + last_activity_at], + :contact => %w[name email phone_number identifier country_code city created_at last_activity_at referer blocked] + }.freeze + scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) } validates :attribute_display_name, presence: true @@ -31,6 +37,7 @@ class CustomAttributeDefinition < ApplicationRecord validates :attribute_display_type, presence: true validates :attribute_model, presence: true + validate :attribute_must_not_conflict, on: :create enum attribute_model: { conversation_attribute: 0, contact_attribute: 1 } enum attribute_display_type: { text: 0, number: 1, currency: 2, percent: 3, link: 4, date: 5, list: 6, checkbox: 7 } @@ -48,4 +55,11 @@ class CustomAttributeDefinition < ApplicationRecord def update_widget_pre_chat_custom_fields ::Inboxes::UpdateWidgetPreChatCustomFieldsJob.perform_later(account, self) end + + def attribute_must_not_conflict + model_keys = attribute_model.to_sym == :conversation_attribute ? :conversation : :contact + return unless attribute_key.in?(STANDARD_ATTRIBUTES[model_keys]) + + errors.add(:attribute_key, I18n.t('errors.custom_attribute_definition.key_conflict')) + end end diff --git a/app/services/filter_service.rb b/app/services/filter_service.rb index 23cc0e188..e4cef7941 100644 --- a/app/services/filter_service.rb +++ b/app/services/filter_service.rb @@ -1,7 +1,7 @@ require 'json' class FilterService - include FilterHelper + include Filters::FilterHelper include CustomExceptions::CustomFilter ATTRIBUTE_MODEL = 'conversation_attribute'.freeze @@ -43,18 +43,15 @@ class FilterService end def filter_values(query_hash) - case query_hash['attribute_key'] - when 'status' - return Conversation.statuses.values if query_hash['values'].include?('all') + attribute_key = query_hash['attribute_key'] + values = query_hash['values'] - query_hash['values'].map { |x| Conversation.statuses[x.to_sym] } - when 'message_type' - query_hash['values'].map { |x| Message.message_types[x.to_sym] } - when 'content' - downcase_array_values(query_hash['values']) - else - case_insensitive_values(query_hash) - end + return conversation_status_values(values) if attribute_key == 'status' + return conversation_priority_values(values) if attribute_key == 'priority' + return message_type_values(values) if attribute_key == 'message_type' + return downcase_array_values(values) if attribute_key == 'content' + + case_insensitive_values(query_hash) end def downcase_array_values(values) diff --git a/config/locales/en.yml b/config/locales/en.yml index 16d13ab71..4183c873d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -82,6 +82,8 @@ en: invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}]. invalid_query_operator: Query operator must be either "AND" or "OR". invalid_value: Invalid value. The values provided for %{attribute_name} are invalid + custom_attribute_definition: + key_conflict: The provided key is not allowed as it might conflict with default attributes. reports: period: Reporting period %{since} to %{until} utc_warning: The report generated is in UTC timezone diff --git a/spec/controllers/api/v1/accounts/custom_attribute_definitions_controller_spec.rb b/spec/controllers/api/v1/accounts/custom_attribute_definitions_controller_spec.rb index d434381cd..c5be392b6 100644 --- a/spec/controllers/api/v1/accounts/custom_attribute_definitions_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/custom_attribute_definitions_controller_spec.rb @@ -89,6 +89,30 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do json_response = response.parsed_body expect(json_response['attribute_key']).to eq 'developer_id' end + + context 'when creating with a conflicting attribute_key' do + let(:standard_key) { CustomAttributeDefinition::STANDARD_ATTRIBUTES[:conversation].first } + let(:conflicting_payload) do + { + custom_attribute_definition: { + attribute_display_name: 'Conflicting Key', + attribute_key: standard_key, + attribute_model: 'conversation_attribute', + attribute_display_type: 'text' + } + } + end + + it 'returns error for conflicting key' do + post "/api/v1/accounts/#{account.id}/custom_attribute_definitions", + headers: user.create_new_auth_token, + params: conflicting_payload + + expect(response).to have_http_status(:unprocessable_entity) + json_response = response.parsed_body + expect(json_response['message']).to include('The provided key is not allowed as it might conflict with default attributes.') + end + end end end diff --git a/spec/services/conversations/filter_service_spec.rb b/spec/services/conversations/filter_service_spec.rb index 45e5be143..53878c780 100644 --- a/spec/services/conversations/filter_service_spec.rb +++ b/spec/services/conversations/filter_service_spec.rb @@ -77,6 +77,63 @@ describe Conversations::FilterService do expect(result[:count][:all_count]).to be conversations.count end + it 'filter conversations by priority' do + conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :high) + params[:payload] = [ + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: ['high'], + query_operator: nil, + custom_attribute_type: '' + }.with_indifferent_access + ] + result = filter_service.new(params, user_1).perform + expect(result[:conversations].length).to eq 1 + expect(result[:conversations][0][:id]).to eq conversation.id + end + + it 'filter conversations by multiple priority values' do + high_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :high) + urgent_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :urgent) + create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :low) + + params[:payload] = [ + { + attribute_key: 'priority', + filter_operator: 'equal_to', + values: %w[high urgent], + query_operator: nil, + custom_attribute_type: '' + }.with_indifferent_access + ] + result = filter_service.new(params, user_1).perform + expect(result[:conversations].length).to eq 2 + expect(result[:conversations].pluck(:id)).to include(high_priority.id, urgent_priority.id) + end + + it 'filter conversations with not_equal_to priority operator' do + create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :high) + create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :urgent) + low_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :low) + medium_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :medium) + + params[:payload] = [ + { + attribute_key: 'priority', + filter_operator: 'not_equal_to', + values: %w[high urgent], + query_operator: nil, + custom_attribute_type: '' + }.with_indifferent_access + ] + result = filter_service.new(params, user_1).perform + + # Only include conversations with medium and low priority, excluding high and urgent + expect(result[:conversations].length).to eq 2 + expect(result[:conversations].pluck(:id)).to include(low_priority.id, medium_priority.id) + end + it 'filter conversations by additional_attributes and status with pagination' do params[:payload] = payload params[:page] = 2