chore: Ability to filter conversations with priority (#10967)

- Ability to filter conversation with priority

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Sojan Jose
2025-03-03 03:08:22 -08:00
committed by GitHub
parent 488c334b72
commit 6040e50265
9 changed files with 258 additions and 75 deletions

View File

@@ -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

View File

@@ -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<FilterType[]>}
*/
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',

View File

@@ -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',
}));
};
/**

View File

@@ -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<FilterType[]>}
*/
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',

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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