fix: Update validations for filter service (#8239)

- Refactor filter service for better readability and maintenance
- Add validations for the following:
   - If an invalid attribute is passed, a custom exception InvalidAttribute will be thrown.
   - If an invalid operator is passed, a custom exception InvalidOperator will be thrown.
   - If an invalid value (currently checking only null check), a custom exception InvalidValue will be thrown.

Fixes: https://linear.app/chatwoot/issue/CW-2702/activerecordstatementinvalid-pginvalidtextrepresentation-error-invalid
Fixes: https://linear.app/chatwoot/issue/CW-2703/activerecordstatementinvalid-pginvaliddatetimeformat-error-invalid
Fixes:  https://linear.app/chatwoot/issue/CW-2700/activerecordstatementinvalid-pgsyntaxerror-error-syntax-error-at-or


Co-authored-by: Sojan <sojan@pepalo.com>
This commit is contained in:
Pranav 2024-03-20 03:59:36 -07:00 committed by GitHub
parent b017d05ed9
commit f78f278e2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 470 additions and 588 deletions

View File

@ -65,6 +65,10 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
contacts = result[:contacts] contacts = result[:contacts]
@contacts_count = result[:count] @contacts_count = result[:count]
@contacts = fetch_contacts(contacts) @contacts = fetch_contacts(contacts)
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end end
def contactable_inboxes def contactable_inboxes

View File

@ -44,6 +44,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
result = ::Conversations::FilterService.new(params.permit!, current_user).perform result = ::Conversations::FilterService.new(params.permit!, current_user).perform
@conversations = result[:conversations] @conversations = result[:conversations]
@conversations_count = result[:count] @conversations_count = result[:count]
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end end
def mute def mute

View File

@ -0,0 +1,86 @@
module FilterHelper
def build_condition_query(model_filters, query_hash, current_index)
current_filter = model_filters[query_hash['attribute_key']]
# Throw InvalidOperator Error if the attribute is a standard attribute
# and the operator is not allowed in the config
if current_filter.present? && current_filter['filter_operators'].exclude?(query_hash[:filter_operator])
raise CustomExceptions::CustomFilter::InvalidOperator.new(
attribute_name: query_hash['attribute_key'],
allowed_keys: current_filter['filter_operators']
)
end
# Every other filter expects a value to be present
if %w[is_present is_not_present].exclude?(query_hash[:filter_operator]) && query_hash['values'].blank?
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: query_hash['attribute_key'])
end
condition_query = build_condition_query_string(current_filter, query_hash, current_index)
# The query becomes empty only when it doesn't match to any supported
# standard attribute or custom attribute defined in the account.
if condition_query.empty?
raise CustomExceptions::CustomFilter::InvalidAttribute.new(key: query_hash['attribute_key'],
allowed_keys: model_filters.keys)
end
condition_query
end
def build_condition_query_string(current_filter, query_hash, current_index)
filter_operator_value = filter_operation(query_hash, current_index)
return handle_nil_filter(query_hash, current_index) if current_filter.nil?
case current_filter['attribute_type']
when 'additional_attributes'
handle_additional_attributes(query_hash, filter_operator_value)
else
handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value)
end
end
def handle_nil_filter(query_hash, current_index)
attribute_type = "#{filter_config[:entity].downcase}_attribute"
custom_attribute_query(query_hash, attribute_type, current_index)
end
# TODO: Change the reliance on entity instead introduce datatype text_case_insensive
# Then we can remove the condition for Contact
def handle_additional_attributes(query_hash, filter_operator_value)
if filter_config[:entity] == 'Contact'
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
"#{filter_operator_value} #{query_hash[:query_operator]}"
else
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
"#{filter_operator_value} #{query_hash[:query_operator]} "
end
end
def handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value)
case current_filter['data_type']
when 'date'
date_filter(current_filter, query_hash, filter_operator_value)
when 'labels'
tag_filter_query(query_hash, current_index)
else
default_filter(query_hash, filter_operator_value)
end
end
def date_filter(current_filter, query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
end
# TODO: Change the reliance on entity instead introduce datatype text_case_insensive
# Then we can remove the condition for Contact
def default_filter(query_hash, filter_operator_value)
if filter_config[:entity] == 'Contact'
"LOWER(#{filter_config[:table_name]}.#{query_hash[:attribute_key]}) " \
"#{filter_operator_value} #{query_hash[:query_operator]}"
else
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
end
end
end

View File

@ -5,8 +5,8 @@ class AutomationRules::ConditionValidationService
@rule = rule @rule = rule
@account = rule.account @account = rule.account
file = File.read('./lib/filters/filter_keys.json') file = File.read('./lib/filters/filter_keys.yml')
@filters = JSON.parse(file) @filters = YAML.safe_load(file)
@conversation_filters = @filters['conversations'] @conversation_filters = @filters['conversations']
@contact_filters = @filters['contacts'] @contact_filters = @filters['contacts']

View File

@ -11,8 +11,9 @@ class AutomationRules::ConditionsFilterService < FilterService
@account = conversation.account @account = conversation.account
# setup filters from json file # setup filters from json file
file = File.read('./lib/filters/filter_keys.json') file = File.read('./lib/filters/filter_keys.yml')
@filters = JSON.parse(file) @filters = YAML.safe_load(file)
@conversation_filters = @filters['conversations'] @conversation_filters = @filters['conversations']
@contact_filters = @filters['contacts'] @contact_filters = @filters['contacts']
@message_filters = @filters['messages'] @message_filters = @filters['messages']

View File

@ -2,7 +2,7 @@ class Contacts::FilterService < FilterService
ATTRIBUTE_MODEL = 'contact_attribute'.freeze ATTRIBUTE_MODEL = 'contact_attribute'.freeze
def perform def perform
@contacts = contact_query_builder @contacts = query_builder(@filters['contacts'])
{ {
contacts: @contacts, contacts: @contacts,
@ -10,38 +10,6 @@ class Contacts::FilterService < FilterService
} }
end end
def contact_query_builder
contact_filters = @filters['contacts']
@params[:payload].each_with_index do |query_hash, current_index|
current_filter = contact_filters[query_hash['attribute_key']]
@query_string += contact_query_string(current_filter, query_hash, current_index)
end
base_relation.where(@query_string, @filter_values.with_indifferent_access)
end
def contact_query_string(current_filter, query_hash, current_index)
attribute_key = query_hash[:attribute_key]
query_operator = query_hash[:query_operator]
filter_operator_value = filter_operation(query_hash, current_index)
return custom_attribute_query(query_hash, 'contact_attribute', current_index) if current_filter.nil?
case current_filter['attribute_type']
when 'additional_attributes'
" LOWER(contacts.additional_attributes ->> '#{attribute_key}') #{filter_operator_value} #{query_operator} "
when 'date_attributes'
" (contacts.#{attribute_key})::#{current_filter['data_type']} #{filter_operator_value}#{current_filter['data_type']} #{query_operator} "
when 'standard'
if attribute_key == 'labels'
" #{tag_filter_query('Contact', 'contacts', query_hash, current_index)} "
else
" LOWER(contacts.#{attribute_key}) #{filter_operator_value} #{query_operator} "
end
end
end
def filter_values(query_hash) def filter_values(query_hash)
current_val = query_hash['values'][0] current_val = query_hash['values'][0]
if query_hash['attribute_key'] == 'phone_number' if query_hash['attribute_key'] == 'phone_number'
@ -57,6 +25,13 @@ class Contacts::FilterService < FilterService
Current.account.contacts Current.account.contacts
end end
def filter_config
{
entity: 'Contact',
table_name: 'contacts'
}
end
private private
def equals_to_filter_string(filter_operator, current_index) def equals_to_filter_string(filter_operator, current_index)

View File

@ -7,7 +7,7 @@ class Conversations::FilterService < FilterService
end end
def perform def perform
@conversations = conversation_query_builder @conversations = query_builder(@filters['conversations'])
mine_count, unassigned_count, all_count, = set_count_for_all_conversations mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count assigned_count = all_count - unassigned_count
@ -22,37 +22,6 @@ class Conversations::FilterService < FilterService
} }
end end
def conversation_query_builder
conversation_filters = @filters['conversations']
@params[:payload].each_with_index do |query_hash, current_index|
current_filter = conversation_filters[query_hash['attribute_key']]
@query_string += conversation_query_string(current_filter, query_hash, current_index)
end
base_relation.where(@query_string, @filter_values.with_indifferent_access)
end
def conversation_query_string(current_filter, query_hash, current_index)
attribute_key = query_hash[:attribute_key]
query_operator = query_hash[:query_operator]
filter_operator_value = filter_operation(query_hash, current_index)
return custom_attribute_query(query_hash, 'conversation_attribute', current_index) if current_filter.nil?
case current_filter['attribute_type']
when 'additional_attributes'
" conversations.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} "
when 'date_attributes'
" (conversations.#{attribute_key})::#{current_filter['data_type']} #{filter_operator_value}#{current_filter['data_type']} #{query_operator} "
when 'standard'
if attribute_key == 'labels'
" #{tag_filter_query('Conversation', 'conversations', query_hash, current_index)} "
else
" conversations.#{attribute_key} #{filter_operator_value} #{query_operator} "
end
end
end
def base_relation def base_relation
@account.conversations.includes( @account.conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
@ -63,6 +32,13 @@ class Conversations::FilterService < FilterService
@params[:page] || 1 @params[:page] || 1
end end
def filter_config
{
entity: 'Conversation',
table_name: 'conversations'
}
end
def conversations def conversations
@conversations.sort_on_last_activity_at.page(current_page) @conversations.sort_on_last_activity_at.page(current_page)
end end

View File

@ -1,6 +1,9 @@
require 'json' require 'json'
class FilterService class FilterService
include FilterHelper
include CustomExceptions::CustomFilter
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
ATTRIBUTE_TYPES = { ATTRIBUTE_TYPES = {
date: 'date', text: 'text', number: 'numeric', link: 'text', list: 'text', checkbox: 'boolean' date: 'date', text: 'text', number: 'numeric', link: 'text', list: 'text', checkbox: 'boolean'
@ -9,8 +12,8 @@ class FilterService
def initialize(params, user) def initialize(params, user)
@params = params @params = params
@user = user @user = user
file = File.read('./lib/filters/filter_keys.json') file = File.read('./lib/filters/filter_keys.yml')
@filters = JSON.parse(file) @filters = YAML.safe_load(file)
@query_string = '' @query_string = ''
@filter_values = {} @filter_values = {}
end end
@ -106,7 +109,9 @@ class FilterService
] ]
end end
def tag_filter_query(model_name, table_name, query_hash, current_index) def tag_filter_query(query_hash, current_index)
model_name = filter_config[:entity]
table_name = filter_config[:table_name]
query_operator = query_hash[:query_operator] query_operator = query_hash[:query_operator]
@filter_values["value_#{current_index}"] = filter_values(query_hash) @filter_values["value_#{current_index}"] = filter_values(query_hash)
@ -130,10 +135,8 @@ class FilterService
def custom_attribute_query(query_hash, custom_attribute_type, current_index) def custom_attribute_query(query_hash, custom_attribute_type, current_index)
@attribute_key = query_hash[:attribute_key] @attribute_key = query_hash[:attribute_key]
@custom_attribute_type = custom_attribute_type @custom_attribute_type = custom_attribute_type
attribute_data_type attribute_data_type
return '' if @custom_attribute.blank?
return ' ' if @custom_attribute.blank?
build_custom_attr_query(query_hash, current_index) build_custom_attr_query(query_hash, current_index)
end end
@ -155,9 +158,9 @@ class FilterService
table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts' table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts'
query = if attribute_data_type == 'text' query = if attribute_data_type == 'text'
" LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} " "LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
else else
" (#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} " "(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
end end
query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type) query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
@ -194,4 +197,11 @@ class FilterService
"NOT LIKE :value_#{current_index}" "NOT LIKE :value_#{current_index}"
end end
def query_builder(model_filters)
@params[:payload].each_with_index do |query_hash, current_index|
@query_string += " #{build_condition_query(model_filters, query_hash, current_index).strip}"
end
base_relation.where(@query_string, @filter_values.with_indifferent_access)
end
end end

View File

@ -77,7 +77,9 @@ en:
name: should not start or end with symbols, and it should not have < > / \ @ characters. name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters: custom_filters:
number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50. number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
reports: reports:
period: Reporting period %{since} to %{until} period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone utc_warning: The report generated is in UTC timezone

View File

@ -1,195 +0,0 @@
{
"conversations": {
"status": {
"attribute_name": "Status",
"input_type": "multi_select",
"table_name": "conversations",
"filter_operators": [ "equal_to", "not_equal_to" ],
"attribute_type": "standard"
},
"assignee_id": {
"attribute_name": "Assignee Name",
"input_type": "search_box with name tags/plain text",
"table_name": "conversations",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"contact_id": {
"attribute_name": "Contact Name",
"input_type": "plain_text",
"table_name": "conversations",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"inbox_id": {
"attribute_name": "Inbox Name",
"input_type": "search_box",
"table_name": "conversations",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"team_id": {
"attribute_name": "Team Name",
"input_type": "search_box",
"table_name": "conversations",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"id": {
"attribute_name": "Conversation Identifier",
"input_type": "textbox",
"table_name": "conversations",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"campaign_id": {
"attribute_name": "Campaign Name",
"input_type": "textbox",
"data_type": "Number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"labels": {
"attribute_name": "Labels",
"input_type": "tags",
"data_type": "text",
"filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
},
"browser_language": {
"attribute_name": "Browser Language",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
"conversation_language": {
"attribute_name": "Conversation Language",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to" ],
"attribute_type": "additional_attributes"
},
"mail_subject": {
"attribute_name": "Email Subject",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
"country_code": {
"attribute_name": "Country Name",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
},
"referer": {
"attribute_name": "Referer link",
"input_type": "textbox",
"data_type": "link",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
},
"plan": {
"attribute_name": "Plan",
"input_type": "multi_select",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
}
},
"contacts": {
"assignee_id": {
"attribute_name": "Assignee Name",
"input_type": "search_box with name tags/plain text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"phone_number": {
"attribute_name": "Phone Number",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "starts_with" ],
"attribute_type": "standard"
},
"contact_id": {
"attribute_name": "Contact Name",
"input_type": "plain_text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"inbox_id": {
"attribute_name": "Inbox Name",
"input_type": "search_box",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"team_id": {
"attribute_name": "Team Name",
"input_type": "search_box",
"data_type": "number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"id": {
"attribute_name": "Conversation Identifier",
"input_type": "textbox",
"data_type": "Number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"campaign_id": {
"attribute_name": "Campaign Name",
"input_type": "textbox",
"data_type": "Number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"labels": {
"attribute_name": "Labels",
"input_type": "tags",
"data_type": "text",
"filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
},
"browser_language": {
"attribute_name": "Browser Language",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
"mail_subject": {
"attribute_name": "Email Subject",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
"email": {
"attribute_name": "Email",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
},
"country_code": {
"attribute_name": "Country Name",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
},
"referer": {
"attribute_name": "Referer link",
"input_type": "textbox",
"data_type": "link",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
}
}
}

View File

@ -0,0 +1,19 @@
module CustomExceptions::CustomFilter
class InvalidAttribute < CustomExceptions::Base
def message
I18n.t('errors.custom_filters.invalid_attribute', key: @data[:key], allowed_keys: @data[:allowed_keys].join(','))
end
end
class InvalidOperator < CustomExceptions::Base
def message
I18n.t('errors.custom_filters.invalid_operator', attribute_name: @data[:attribute_name], allowed_keys: @data[:allowed_keys].join(','))
end
end
class InvalidValue < CustomExceptions::Base
def message
I18n.t('errors.custom_filters.invalid_value', attribute_name: @data[:attribute_name])
end
end
end

View File

@ -1,92 +0,0 @@
{
"conversations": [
{
"attribute_key": "status",
"attribute_name": "Status",
"input_type": "multi_select",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to" ],
"attribute_type": "standard"
},
{
"attribute_key": "assigne",
"attribute_name": "Assignee Name",
"input_type": "search_box with name tags/plain text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
{
"attribute_key": "contact",
"attribute_name": "Contact Name",
"input_type": "plain_text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
{
"attribute_key": "inbox",
"attribute_name": "Inbox Name",
"input_type": "search_box",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
{
"attribute_key": "team_id",
"attribute_name": "Team Name",
"input_type": "search_box",
"data_type": "number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
{
"attribute_key": "id",
"attribute_name": "Conversation Identifier",
"input_type": "textbox",
"data_type": "Number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
{
"attribute_key": "campaign_id",
"attribute_name": "Campaign Name",
"input_type": "textbox",
"data_type": "Number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
{
"attribute_key": "labels",
"attribute_name": "Labels",
"input_type": "tags",
"data_type": "text",
"filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
},
{
"attribute_key": "browser_language",
"attribute_name": "Browser Language",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
{
"attribute_key": "country_code",
"attribute_name": "Country Name",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
},
{
"attribute_key": "referer",
"attribute_name": "Referer link",
"input_type": "textbox",
"data_type": "link",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
}
]
}

View File

@ -1,204 +0,0 @@
{
"conversations": {
"status": {
"attribute_name": "Status",
"input_type": "multi_select",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to" ],
"attribute_type": "standard"
},
"assignee_id": {
"attribute_name": "Assignee Name",
"input_type": "search_box with name tags/plain text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"contact_id": {
"attribute_name": "Contact Name",
"input_type": "plain_text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"inbox_id": {
"attribute_name": "Inbox Name",
"input_type": "search_box",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"team_id": {
"attribute_name": "Team Name",
"input_type": "search_box",
"data_type": "number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"display_id": {
"attribute_name": "Conversation Identifier",
"input_type": "textbox",
"data_type": "Number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"campaign_id": {
"attribute_name": "Campaign Name",
"input_type": "textbox",
"data_type": "Number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"labels": {
"attribute_name": "Labels",
"input_type": "tags",
"data_type": "text",
"filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
},
"browser_language": {
"attribute_name": "Browser Language",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
"conversation_language": {
"attribute_name": "Conversation Language",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to" ],
"attribute_type": "additional_attributes"
},
"country_code": {
"attribute_name": "Country Name",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
},
"referer": {
"attribute_name": "Referer link",
"input_type": "textbox",
"data_type": "link",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
},
"created_at": {
"attribute_name": "Created At",
"input_type": "date",
"data_type": "date",
"filter_operators": [ "is_greater_than", "is_less_than", "days_before" ],
"attribute_type": "date_attributes"
},
"last_activity_at": {
"attribute_name": "Created At",
"input_type": "date",
"data_type": "date",
"filter_operators": [ "is_greater_than", "is_less_than", "days_before" ],
"attribute_type": "date_attributes"
},
"mail_subject": {
"attribute_name": "Email Subject",
"input_type": "text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain"],
"attribute_type": "additional_attributes"
}
},
"contacts": {
"name": {
"attribute_name": "Name",
"input_type": "search_box with name tags/plain text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
},
"phone_number": {
"attribute_name": "Phone Number",
"input_type": "text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "starts_with"],
"attribute_type": "standard"
},
"email": {
"attribute_name": "Email",
"input_type": "search_box with name tags/plain text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
},
"identifier": {
"attribute_name": "Contact Identifier",
"input_type": "search_box with name tags/plain text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to" ],
"attribute_type": "standard"
},
"country_code": {
"attribute_name": "Country",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to" ],
"attribute_type": "additional_attributes"
},
"city": {
"attribute_name": "City",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
"browser_language": {
"attribute_name": "Browser Language",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
"company": {
"attribute_name": "Company",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
"labels": {
"attribute_name": "Labels",
"input_type": "tags",
"data_type": "text",
"filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
},
"created_at": {
"attribute_name": "Created At",
"input_type": "date",
"data_type": "date",
"filter_operators": [ "is_greater_than", "is_less_than", "days_before" ],
"attribute_type": "date_attributes"
},
"last_activity_at": {
"attribute_name": "Created At",
"input_type": "date",
"data_type": "date",
"filter_operators": [ "is_greater_than", "is_less_than", "days_before" ],
"attribute_type": "date_attributes"
}
},
"messages": {
"message_type": {
"attribute_name": "Message Type",
"input_type": "search_box with name tags/plain text",
"data_type": "numeric",
"filter_operators": [ "equal_to", "not_equal_to" ],
"attribute_type": "standard"
},
"content": {
"attribute_name": "Message Content",
"input_type": "search_box with name tags/plain text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
}
}
}

220
lib/filters/filter_keys.yml Normal file
View File

@ -0,0 +1,220 @@
## This file contains the filter configurations which we use for the following
# 1. Conversation Filters (app/services/filter_service.rb)
# 2. Contact Filters (app/services/filter_service.rb)
# 3. Automation Filters (app/services/automation_rules/conditions_filter_service.rb), (app/services/automation_rules/condition_validation_service.rb)
# Format
# - Parent Key (conversation, contact, messages)
# - Key (attribute_name)
# - attribute_type: "standard" : supported ["standard", "additional_attributes (only for conversations and messages)"]
# - data_type: "text" : supported ["text", "number", "labels", "date", "link"]
# - filter_operators: ["equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present", "is_greater_than", "is_less_than", "days_before", "starts_with"]
### ----- Conversation Filters ----- ###
conversations:
status:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
assignee_id:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
inbox_id:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
team_id:
attribute_type: "standard"
data_type: "number"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
display_id:
attribute_type: "standard"
data_type: "Number"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
campaign_id:
attribute_type: "standard"
data_type: "Number"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
labels:
attribute_type: "standard"
data_type: "labels"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
browser_language:
attribute_type: "additional_attributes"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
conversation_language:
attribute_type: "additional_attributes"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
country_code:
attribute_type: "additional_attributes"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
referer:
attribute_type: "additional_attributes"
data_type: "link"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
created_at:
attribute_type: "standard"
data_type: "date"
filter_operators:
- "is_greater_than"
- "is_less_than"
- "days_before"
last_activity_at:
attribute_type: "standard"
data_type: "date"
filter_operators:
- "is_greater_than"
- "is_less_than"
- "days_before"
mail_subject:
attribute_type: "additional_attributes"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
### ----- End of Conversation Filters ----- ###
### ----- Contact Filters ----- ###
contacts:
name:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
phone_number:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
- "starts_with"
email:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
identifier:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
country_code:
attribute_type: "additional_attributes"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
city:
attribute_type: "additional_attributes"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
company:
attribute_type: "additional_attributes"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
labels:
attribute_type: "standard"
data_type: "labels"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
created_at:
attribute_type: "standard"
data_type: "date"
filter_operators:
- "is_greater_than"
- "is_less_than"
- "days_before"
last_activity_at:
attribute_type: "standard"
data_type: "date"
filter_operators:
- "is_greater_than"
- "is_less_than"
- "days_before"
### ----- End of Contact Filters ----- ###
### ----- Message Filters ----- ###
messages:
message_type:
attribute_type: "standard"
data_type: "numeric"
filter_operators:
- "equal_to"
- "not_equal_to"
content:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
### ----- End of Message Filters ----- ###

View File

@ -338,14 +338,18 @@ RSpec.describe 'Contacts API', type: :request do
context 'when it is an authenticated user' do context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) } let(:admin) { create(:user, account: account, role: :administrator) }
let!(:contact1) { create(:contact, :with_email, account: account) } let!(:contact1) { create(:contact, :with_email, account: account, additional_attributes: { country_code: 'US' }) }
let!(:contact2) { create(:contact, :with_email, name: 'testcontact', account: account, email: 'test@test.com') } let!(:contact2) do
create(:contact, :with_email, name: 'testcontact', account: account, email: 'test@test.com', additional_attributes: { country_code: 'US' })
end
it 'returns all contacts when query is empty' do it 'returns all contacts when query is empty' do
post "/api/v1/accounts/#{account.id}/contacts/filter", post "/api/v1/accounts/#{account.id}/contacts/filter",
params: { params: { payload: [
payload: [] attribute_key: 'country_code',
}, filter_operator: 'equal_to',
values: ['US']
] },
headers: admin.create_new_auth_token, headers: admin.create_new_auth_token,
as: :json as: :json
@ -353,6 +357,34 @@ RSpec.describe 'Contacts API', type: :request do
expect(response.body).to include(contact2.email) expect(response.body).to include(contact2.email)
expect(response.body).to include(contact1.email) expect(response.body).to include(contact1.email)
end end
it 'returns error the query operator is invalid' do
post "/api/v1/accounts/#{account.id}/contacts/filter",
params: { payload: [
attribute_key: 'country_code',
filter_operator: 'eq',
values: ['US']
] },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('Invalid operator. The allowed operators for country_code are [equal_to,not_equal_to]')
end
it 'returns error the query value is invalid' do
post "/api/v1/accounts/#{account.id}/contacts/filter",
params: { payload: [
attribute_key: 'country_code',
filter_operator: 'equal_to',
values: []
] },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('Invalid value. The values provided for country_code are invalid"')
end
end end
end end

View File

@ -152,17 +152,56 @@ RSpec.describe 'Conversations API', type: :request do
create(:inbox_member, user: agent, inbox: conversation.inbox) create(:inbox_member, user: agent, inbox: conversation.inbox)
end end
it 'returns all conversations with empty query' do it 'returns all conversations matching the query' do
post "/api/v1/accounts/#{account.id}/conversations/filter", post "/api/v1/accounts/#{account.id}/conversations/filter",
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
params: { payload: [] }, params: {
payload: [{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open']
}]
},
as: :json as: :json
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true) response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data.count).to eq(2) expect(response_data.count).to eq(2)
end end
it 'returns error if the filters contain invalid attributes' do
post "/api/v1/accounts/#{account.id}/conversations/filter",
headers: agent.create_new_auth_token,
params: {
payload: [{
attribute_key: 'phone_number',
filter_operator: 'equal_to',
values: ['open']
}]
},
as: :json
expect(response).to have_http_status(:unprocessable_entity)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data[:error]).to include('Invalid attribute key - [phone_number]')
end
it 'returns error if the filters contain invalid operator' do
post "/api/v1/accounts/#{account.id}/conversations/filter",
headers: agent.create_new_auth_token,
params: {
payload: [{
attribute_key: 'status',
filter_operator: 'eq',
values: ['open']
}]
},
as: :json
expect(response).to have_http_status(:unprocessable_entity)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data[:error]).to eq('Invalid operator. The allowed operators for status are [equal_to,not_equal_to].')
end
end end
end end

View File

@ -7,9 +7,9 @@ describe Contacts::FilterService do
let!(:first_user) { create(:user, account: account) } let!(:first_user) { create(:user, account: account) }
let!(:second_user) { create(:user, account: account) } let!(:second_user) { create(:user, account: account) }
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) } let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
let(:en_contact) { create(:contact, account: account, additional_attributes: { 'browser_language': 'en' }) } let!(:en_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'uk' }) }
let(:el_contact) { create(:contact, account: account, additional_attributes: { 'browser_language': 'el' }) } let!(:el_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'gr' }) }
let(:cs_contact) { create(:contact, account: account, additional_attributes: { 'browser_language': 'cs' }) } let!(:cs_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'cz' }) }
before do before do
create(:inbox_member, user: first_user, inbox: inbox) create(:inbox_member, user: first_user, inbox: inbox)
@ -51,9 +51,9 @@ describe Contacts::FilterService do
let(:payload) do let(:payload) do
[ [
{ {
attribute_key: 'browser_language', attribute_key: 'country_code',
filter_operator: 'equal_to', filter_operator: 'equal_to',
values: ['en'], values: ['uk'],
query_operator: nil query_operator: nil
}.with_indifferent_access }.with_indifferent_access
] ]
@ -181,9 +181,9 @@ describe Contacts::FilterService do
query_operator: 'AND' query_operator: 'AND'
}.with_indifferent_access, }.with_indifferent_access,
{ {
attribute_key: 'browser_language', attribute_key: 'country_code',
filter_operator: 'equal_to', filter_operator: 'equal_to',
values: ['el'], values: ['GR'],
query_operator: 'AND' query_operator: 'AND'
}.with_indifferent_access, }.with_indifferent_access,
{ {

View File

@ -55,7 +55,7 @@ describe Conversations::FilterService do
[ [
{ {
attribute_key: 'browser_language', attribute_key: 'browser_language',
filter_operator: 'contains', filter_operator: 'equal_to',
values: 'en', values: 'en',
query_operator: 'AND', query_operator: 'AND',
custom_attribute_type: '' custom_attribute_type: ''
@ -88,7 +88,7 @@ describe Conversations::FilterService do
it 'filters items with contains filter_operator with values being an array' do it 'filters items with contains filter_operator with values being an array' do
params[:payload] = [{ params[:payload] = [{
attribute_key: 'browser_language', attribute_key: 'browser_language',
filter_operator: 'contains', filter_operator: 'equal_to',
values: %w[tr fr], values: %w[tr fr],
query_operator: '', query_operator: '',
custom_attribute_type: '' custom_attribute_type: ''
@ -106,7 +106,7 @@ describe Conversations::FilterService do
it 'filters items with does not contain filter operator with values being an array' do it 'filters items with does not contain filter operator with values being an array' do
params[:payload] = [{ params[:payload] = [{
attribute_key: 'browser_language', attribute_key: 'browser_language',
filter_operator: 'does_not_contain', filter_operator: 'not_equal_to',
values: %w[tr en], values: %w[tr en],
query_operator: '', query_operator: '',
custom_attribute_type: '' custom_attribute_type: ''
@ -291,6 +291,11 @@ describe Conversations::FilterService do
end end
it 'filter by custom_attributes and additional_attributes' do it 'filter by custom_attributes and additional_attributes' do
conversations = user_1.conversations
conversations[0].update!(additional_attributes: { 'browser_language': 'en' }, custom_attributes: { conversation_type: 'silver' })
conversations[1].update!(additional_attributes: { 'browser_language': 'en' }, custom_attributes: { conversation_type: 'platinum' })
conversations[2].update!(additional_attributes: { 'browser_language': 'tr' }, custom_attributes: { conversation_type: 'platinum' })
params[:payload] = [ params[:payload] = [
{ {
attribute_key: 'conversation_type', attribute_key: 'conversation_type',
@ -301,7 +306,7 @@ describe Conversations::FilterService do
}.with_indifferent_access, }.with_indifferent_access,
{ {
attribute_key: 'browser_language', attribute_key: 'browser_language',
filter_operator: 'is_equal_to', filter_operator: 'not_equal_to',
values: 'en', values: 'en',
query_operator: nil, query_operator: nil,
custom_attribute_type: '' custom_attribute_type: ''