From bd97226c95ce536d082326b8a0852da21a025a89 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 19 Mar 2024 18:48:59 +0530 Subject: [PATCH 01/60] fix: Locale not correct in root url when accessing help center with custom domain (#9110) - Ensuring that SwitchLocale concern handles the case of custom domain for portals and set locale according to that Co-authored-by: Sojan Jose --- app/controllers/concerns/domain_helper.rb | 5 +++++ app/controllers/concerns/switch_locale.rb | 15 +++++++++++++++ app/controllers/dashboard_controller.rb | 1 + .../public/api/v1/portals/base_controller.rb | 2 +- app/controllers/public_controller.rb | 3 +-- app/views/public/api/v1/portals/_header.html.erb | 2 +- 6 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 app/controllers/concerns/domain_helper.rb diff --git a/app/controllers/concerns/domain_helper.rb b/app/controllers/concerns/domain_helper.rb new file mode 100644 index 000000000..1b7d8f187 --- /dev/null +++ b/app/controllers/concerns/domain_helper.rb @@ -0,0 +1,5 @@ +module DomainHelper + def self.chatwoot_domain?(domain = request.host) + [URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain) + end +end diff --git a/app/controllers/concerns/switch_locale.rb b/app/controllers/concerns/switch_locale.rb index 744a70da9..3013ff3cc 100644 --- a/app/controllers/concerns/switch_locale.rb +++ b/app/controllers/concerns/switch_locale.rb @@ -6,6 +6,7 @@ module SwitchLocale def switch_locale(&) # priority is for locale set in query string (mostly for widget/from js sdk) locale ||= locale_from_params + locale ||= locale_from_custom_domain # if locale is not set in account, let's use DEFAULT_LOCALE env variable locale ||= locale_from_env_variable set_locale(locale, &) @@ -16,6 +17,20 @@ module SwitchLocale set_locale(locale, &) end + # If the request is coming from a custom domain, it should be for a helpcenter portal + # We will use the portal locale in such cases + def locale_from_custom_domain(&) + return if params[:locale] + + domain = request.host + return if DomainHelper.chatwoot_domain?(domain) + + @portal = Portal.find_by(custom_domain: domain) + return unless @portal + + @portal.default_locale + end + def set_locale(locale, &) # if locale is empty, use default_locale locale ||= I18n.default_locale diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 0aea9df83..047fd10c3 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -18,6 +18,7 @@ class DashboardController < ActionController::Base 'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL', 'INSTALLATION_NAME', 'WIDGET_BRAND_URL', 'TERMS_URL', + 'BRAND_URL', 'BRAND_NAME', 'PRIVACY_URL', 'DISPLAY_MANIFEST', 'CREATE_NEW_ACCOUNT_FROM_DASHBOARD', diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 4d3cc56b8..f6c10f7c4 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -47,7 +47,7 @@ class Public::Api::V1::Portals::BaseController < PublicController @locale = if article.category.present? article.category.locale else - 'en' + article.portal.default_locale end I18n.with_locale(@locale, &) diff --git a/app/controllers/public_controller.rb b/app/controllers/public_controller.rb index 0c3f52ff6..3b83a2210 100644 --- a/app/controllers/public_controller.rb +++ b/app/controllers/public_controller.rb @@ -8,8 +8,7 @@ class PublicController < ActionController::Base def ensure_custom_domain_request domain = request.host - - return if [URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain) + return if DomainHelper.chatwoot_domain?(domain) @portal = ::Portal.find_by(custom_domain: domain) return if @portal.present? diff --git a/app/views/public/api/v1/portals/_header.html.erb b/app/views/public/api/v1/portals/_header.html.erb index 9b875fcee..544fa1ba8 100644 --- a/app/views/public/api/v1/portals/_header.html.erb +++ b/app/views/public/api/v1/portals/_header.html.erb @@ -83,7 +83,7 @@ class="w-24 overflow-hidden text-sm font-medium leading-tight bg-white appearance-none cursor-pointer dark:bg-slate-900 text-ellipsis whitespace-nowrap focus:outline-none focus:shadow-outline locale-switcher" > <% @portal.config["allowed_locales"].each do |locale| %> - + <% end %> <%= render partial: 'icons/chevron-down' %> From b017d05ed93ff73ba1b968505c7117f64c95c92b Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Wed, 20 Mar 2024 11:59:37 +0530 Subject: [PATCH 02/60] feat: add sla events table (#9126) * feat: add sla events table * chore: refactor to EE namespace * chore: refactor * chore: fix spec * chore: add references to account,inbox,sla_policy * chore: update specs * chore: update spec to check backfilling id's * Update spec/enterprise/models/sla_event_spec.rb --- .../20240319062553_create_sla_events.rb | 16 ++++++ db/schema.rb | 19 ++++++- enterprise/app/models/applied_sla.rb | 2 + enterprise/app/models/sla_event.rb | 52 +++++++++++++++++++ spec/enterprise/models/sla_event_spec.rb | 28 ++++++++++ spec/factories/sla_events.rb | 10 ++++ 6 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240319062553_create_sla_events.rb create mode 100644 enterprise/app/models/sla_event.rb create mode 100644 spec/enterprise/models/sla_event_spec.rb create mode 100644 spec/factories/sla_events.rb diff --git a/db/migrate/20240319062553_create_sla_events.rb b/db/migrate/20240319062553_create_sla_events.rb new file mode 100644 index 000000000..a6f1a5de2 --- /dev/null +++ b/db/migrate/20240319062553_create_sla_events.rb @@ -0,0 +1,16 @@ +class CreateSlaEvents < ActiveRecord::Migration[7.0] + def change + create_table :sla_events do |t| + t.references :applied_sla, null: false + t.references :conversation, null: false + t.references :account, null: false + t.references :sla_policy, null: false + t.references :inbox, null: false + + t.integer :event_type + t.jsonb :meta, default: {} + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index d0499cb7b..498ad13d3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_03_06_201954) do +ActiveRecord::Schema[7.0].define(version: 2024_03_19_062553) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -842,6 +842,23 @@ ActiveRecord::Schema[7.0].define(version: 2024_03_06_201954) do t.index ["user_id"], name: "index_reporting_events_on_user_id" end + create_table "sla_events", force: :cascade do |t| + t.bigint "applied_sla_id", null: false + t.bigint "conversation_id", null: false + t.bigint "account_id", null: false + t.bigint "sla_policy_id", null: false + t.bigint "inbox_id", null: false + t.integer "event_type" + t.jsonb "meta", default: {} + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_sla_events_on_account_id" + t.index ["applied_sla_id"], name: "index_sla_events_on_applied_sla_id" + t.index ["conversation_id"], name: "index_sla_events_on_conversation_id" + t.index ["inbox_id"], name: "index_sla_events_on_inbox_id" + t.index ["sla_policy_id"], name: "index_sla_events_on_sla_policy_id" + end + create_table "sla_policies", force: :cascade do |t| t.string "name", null: false t.float "first_response_time_threshold" diff --git a/enterprise/app/models/applied_sla.rb b/enterprise/app/models/applied_sla.rb index 48fd852e4..c2dde8377 100644 --- a/enterprise/app/models/applied_sla.rb +++ b/enterprise/app/models/applied_sla.rb @@ -22,6 +22,8 @@ class AppliedSla < ApplicationRecord belongs_to :sla_policy belongs_to :conversation + has_many :sla_events, dependent: :destroy + validates :account_id, uniqueness: { scope: %i[sla_policy_id conversation_id] } before_validation :ensure_account_id diff --git a/enterprise/app/models/sla_event.rb b/enterprise/app/models/sla_event.rb new file mode 100644 index 000000000..28f709346 --- /dev/null +++ b/enterprise/app/models/sla_event.rb @@ -0,0 +1,52 @@ +# == Schema Information +# +# Table name: sla_events +# +# id :bigint not null, primary key +# event_type :integer +# meta :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# applied_sla_id :bigint not null +# conversation_id :bigint not null +# inbox_id :bigint not null +# sla_policy_id :bigint not null +# +# Indexes +# +# index_sla_events_on_account_id (account_id) +# index_sla_events_on_applied_sla_id (applied_sla_id) +# index_sla_events_on_conversation_id (conversation_id) +# index_sla_events_on_inbox_id (inbox_id) +# index_sla_events_on_sla_policy_id (sla_policy_id) +# +class SlaEvent < ApplicationRecord + belongs_to :account + belongs_to :inbox + belongs_to :conversation + belongs_to :sla_policy + belongs_to :applied_sla + + enum event_type: { frt: 0, nrt: 1, rt: 2 } + + before_validation :ensure_applied_sla_id, :ensure_account_id, :ensure_inbox_id, :ensure_sla_policy_id + + private + + def ensure_applied_sla_id + self.applied_sla_id ||= AppliedSla.find_by(conversation_id: conversation_id)&.last&.id + end + + def ensure_account_id + self.account_id ||= conversation&.account_id + end + + def ensure_inbox_id + self.inbox_id ||= conversation&.inbox_id + end + + def ensure_sla_policy_id + self.sla_policy_id ||= applied_sla&.sla_policy_id + end +end diff --git a/spec/enterprise/models/sla_event_spec.rb b/spec/enterprise/models/sla_event_spec.rb new file mode 100644 index 000000000..862b22e6c --- /dev/null +++ b/spec/enterprise/models/sla_event_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +RSpec.describe SlaEvent, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:applied_sla) } + it { is_expected.to belong_to(:conversation) } + it { is_expected.to belong_to(:account) } + it { is_expected.to belong_to(:sla_policy) } + it { is_expected.to belong_to(:inbox) } + end + + describe 'validates_factory' do + it 'creates valid sla event object' do + sla_event = create(:sla_event) + expect(sla_event.event_type).to eq 'frt' + end + end + + describe 'backfilling ids' do + it 'automatically backfills account_id, inbox_id, and sla_id upon creation' do + sla_event = create(:sla_event) + + expect(sla_event.account_id).to eq sla_event.conversation.account_id + expect(sla_event.inbox_id).to eq sla_event.conversation.inbox_id + expect(sla_event.sla_policy_id).to eq sla_event.applied_sla.sla_policy_id + end + end +end diff --git a/spec/factories/sla_events.rb b/spec/factories/sla_events.rb new file mode 100644 index 000000000..12be18ede --- /dev/null +++ b/spec/factories/sla_events.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :sla_event do + applied_sla + conversation + event_type { 'frt' } + account { conversation.account } + inbox { conversation.inbox } + sla_policy { applied_sla.sla_policy } + end +end From f78f278e2fd2f30f829c9cb6df631661787de020 Mon Sep 17 00:00:00 2001 From: Pranav Date: Wed, 20 Mar 2024 03:59:36 -0700 Subject: [PATCH 03/60] 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 --- .../api/v1/accounts/contacts_controller.rb | 4 + .../v1/accounts/conversations_controller.rb | 4 + app/helpers/filter_helper.rb | 86 +++++++ .../condition_validation_service.rb | 4 +- .../conditions_filter_service.rb | 5 +- app/services/contacts/filter_service.rb | 41 +--- app/services/conversations/filter_service.rb | 40 +--- app/services/filter_service.rb | 26 ++- config/locales/en.yml | 4 +- lib/automation_rules/conditions.json | 195 ---------------- lib/custom_exceptions/custom_filter.rb | 19 ++ lib/filters/conversation_filters.json | 92 -------- lib/filters/filter_keys.json | 204 ---------------- lib/filters/filter_keys.yml | 220 ++++++++++++++++++ .../v1/accounts/contacts_controller_spec.rb | 42 +++- .../accounts/conversations_controller_spec.rb | 45 +++- spec/services/contacts/filter_service_spec.rb | 14 +- .../conversations/filter_service_spec.rb | 13 +- 18 files changed, 470 insertions(+), 588 deletions(-) create mode 100644 app/helpers/filter_helper.rb delete mode 100644 lib/automation_rules/conditions.json create mode 100644 lib/custom_exceptions/custom_filter.rb delete mode 100644 lib/filters/conversation_filters.json delete mode 100644 lib/filters/filter_keys.json create mode 100644 lib/filters/filter_keys.yml diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 71e9100e7..729db34b5 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -65,6 +65,10 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController contacts = result[:contacts] @contacts_count = result[:count] @contacts = fetch_contacts(contacts) + rescue CustomExceptions::CustomFilter::InvalidAttribute, + CustomExceptions::CustomFilter::InvalidOperator, + CustomExceptions::CustomFilter::InvalidValue => e + render_could_not_create_error(e.message) end def contactable_inboxes diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index d0d8f6d5b..2aedf1928 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -44,6 +44,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro result = ::Conversations::FilterService.new(params.permit!, current_user).perform @conversations = result[:conversations] @conversations_count = result[:count] + rescue CustomExceptions::CustomFilter::InvalidAttribute, + CustomExceptions::CustomFilter::InvalidOperator, + CustomExceptions::CustomFilter::InvalidValue => e + render_could_not_create_error(e.message) end def mute diff --git a/app/helpers/filter_helper.rb b/app/helpers/filter_helper.rb new file mode 100644 index 000000000..b3efc393a --- /dev/null +++ b/app/helpers/filter_helper.rb @@ -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 diff --git a/app/services/automation_rules/condition_validation_service.rb b/app/services/automation_rules/condition_validation_service.rb index 6f9f9a7c3..63e58b5bc 100644 --- a/app/services/automation_rules/condition_validation_service.rb +++ b/app/services/automation_rules/condition_validation_service.rb @@ -5,8 +5,8 @@ class AutomationRules::ConditionValidationService @rule = rule @account = rule.account - file = File.read('./lib/filters/filter_keys.json') - @filters = JSON.parse(file) + file = File.read('./lib/filters/filter_keys.yml') + @filters = YAML.safe_load(file) @conversation_filters = @filters['conversations'] @contact_filters = @filters['contacts'] diff --git a/app/services/automation_rules/conditions_filter_service.rb b/app/services/automation_rules/conditions_filter_service.rb index 44b72db9e..daf9cb7e2 100644 --- a/app/services/automation_rules/conditions_filter_service.rb +++ b/app/services/automation_rules/conditions_filter_service.rb @@ -11,8 +11,9 @@ class AutomationRules::ConditionsFilterService < FilterService @account = conversation.account # setup filters from json file - file = File.read('./lib/filters/filter_keys.json') - @filters = JSON.parse(file) + file = File.read('./lib/filters/filter_keys.yml') + @filters = YAML.safe_load(file) + @conversation_filters = @filters['conversations'] @contact_filters = @filters['contacts'] @message_filters = @filters['messages'] diff --git a/app/services/contacts/filter_service.rb b/app/services/contacts/filter_service.rb index 70062cad0..d3b24e4e2 100644 --- a/app/services/contacts/filter_service.rb +++ b/app/services/contacts/filter_service.rb @@ -2,7 +2,7 @@ class Contacts::FilterService < FilterService ATTRIBUTE_MODEL = 'contact_attribute'.freeze def perform - @contacts = contact_query_builder + @contacts = query_builder(@filters['contacts']) { contacts: @contacts, @@ -10,38 +10,6 @@ class Contacts::FilterService < FilterService } 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) current_val = query_hash['values'][0] if query_hash['attribute_key'] == 'phone_number' @@ -57,6 +25,13 @@ class Contacts::FilterService < FilterService Current.account.contacts end + def filter_config + { + entity: 'Contact', + table_name: 'contacts' + } + end + private def equals_to_filter_string(filter_operator, current_index) diff --git a/app/services/conversations/filter_service.rb b/app/services/conversations/filter_service.rb index 87384017c..3a06959b8 100644 --- a/app/services/conversations/filter_service.rb +++ b/app/services/conversations/filter_service.rb @@ -7,7 +7,7 @@ class Conversations::FilterService < FilterService end def perform - @conversations = conversation_query_builder + @conversations = query_builder(@filters['conversations']) mine_count, unassigned_count, all_count, = set_count_for_all_conversations assigned_count = all_count - unassigned_count @@ -22,37 +22,6 @@ class Conversations::FilterService < FilterService } 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 @account.conversations.includes( :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 end + def filter_config + { + entity: 'Conversation', + table_name: 'conversations' + } + end + def conversations @conversations.sort_on_last_activity_at.page(current_page) end diff --git a/app/services/filter_service.rb b/app/services/filter_service.rb index f1a699811..c545ac7d8 100644 --- a/app/services/filter_service.rb +++ b/app/services/filter_service.rb @@ -1,6 +1,9 @@ require 'json' class FilterService + include FilterHelper + include CustomExceptions::CustomFilter + ATTRIBUTE_MODEL = 'conversation_attribute'.freeze ATTRIBUTE_TYPES = { date: 'date', text: 'text', number: 'numeric', link: 'text', list: 'text', checkbox: 'boolean' @@ -9,8 +12,8 @@ class FilterService def initialize(params, user) @params = params @user = user - file = File.read('./lib/filters/filter_keys.json') - @filters = JSON.parse(file) + file = File.read('./lib/filters/filter_keys.yml') + @filters = YAML.safe_load(file) @query_string = '' @filter_values = {} end @@ -106,7 +109,9 @@ class FilterService ] 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] @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) @attribute_key = query_hash[:attribute_key] @custom_attribute_type = custom_attribute_type - attribute_data_type - - return ' ' if @custom_attribute.blank? + return '' if @custom_attribute.blank? build_custom_attr_query(query_hash, current_index) end @@ -155,9 +158,9 @@ class FilterService table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts' 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 - " (#{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 query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type) @@ -194,4 +197,11 @@ class FilterService "NOT LIKE :value_#{current_index}" 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 diff --git a/config/locales/en.yml b/config/locales/en.yml index c19499fa5..4c4f0483c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -77,7 +77,9 @@ en: name: should not start or end with symbols, and it should not have < > / \ @ characters. custom_filters: 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: period: Reporting period %{since} to %{until} utc_warning: The report generated is in UTC timezone diff --git a/lib/automation_rules/conditions.json b/lib/automation_rules/conditions.json deleted file mode 100644 index ba4bd4b5b..000000000 --- a/lib/automation_rules/conditions.json +++ /dev/null @@ -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" - } - } -} diff --git a/lib/custom_exceptions/custom_filter.rb b/lib/custom_exceptions/custom_filter.rb new file mode 100644 index 000000000..03ff9ec7a --- /dev/null +++ b/lib/custom_exceptions/custom_filter.rb @@ -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 diff --git a/lib/filters/conversation_filters.json b/lib/filters/conversation_filters.json deleted file mode 100644 index 39f58f5c6..000000000 --- a/lib/filters/conversation_filters.json +++ /dev/null @@ -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" - } - ] -} diff --git a/lib/filters/filter_keys.json b/lib/filters/filter_keys.json deleted file mode 100644 index 9266d9bea..000000000 --- a/lib/filters/filter_keys.json +++ /dev/null @@ -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" - } - } -} diff --git a/lib/filters/filter_keys.yml b/lib/filters/filter_keys.yml new file mode 100644 index 000000000..afdcc6be7 --- /dev/null +++ b/lib/filters/filter_keys.yml @@ -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 ----- ### diff --git a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb index d2527e6a9..37e64357f 100644 --- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb @@ -338,14 +338,18 @@ RSpec.describe 'Contacts API', type: :request do context 'when it is an authenticated user' do let(:admin) { create(:user, account: account, role: :administrator) } - let!(:contact1) { create(:contact, :with_email, account: account) } - let!(:contact2) { create(:contact, :with_email, name: 'testcontact', account: account, email: 'test@test.com') } + let!(:contact1) { create(:contact, :with_email, account: account, additional_attributes: { country_code: 'US' }) } + 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 post "/api/v1/accounts/#{account.id}/contacts/filter", - params: { - payload: [] - }, + params: { payload: [ + attribute_key: 'country_code', + filter_operator: 'equal_to', + values: ['US'] + ] }, headers: admin.create_new_auth_token, 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(contact1.email) 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 diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index 70f5b82ee..d93886fc3 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -152,17 +152,56 @@ RSpec.describe 'Conversations API', type: :request do create(:inbox_member, user: agent, inbox: conversation.inbox) 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", headers: agent.create_new_auth_token, - params: { payload: [] }, + params: { + payload: [{ + attribute_key: 'status', + filter_operator: 'equal_to', + values: ['open'] + }] + }, as: :json expect(response).to have_http_status(:success) response_data = JSON.parse(response.body, symbolize_names: true) - expect(response_data.count).to eq(2) 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 diff --git a/spec/services/contacts/filter_service_spec.rb b/spec/services/contacts/filter_service_spec.rb index 572d2b205..e8c0ff184 100644 --- a/spec/services/contacts/filter_service_spec.rb +++ b/spec/services/contacts/filter_service_spec.rb @@ -7,9 +7,9 @@ describe Contacts::FilterService do let!(:first_user) { create(:user, account: account) } let!(:second_user) { create(:user, account: account) } let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) } - let(:en_contact) { create(:contact, account: account, additional_attributes: { 'browser_language': 'en' }) } - let(:el_contact) { create(:contact, account: account, additional_attributes: { 'browser_language': 'el' }) } - let(:cs_contact) { create(:contact, account: account, additional_attributes: { 'browser_language': 'cs' }) } + let!(:en_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'uk' }) } + let!(:el_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'gr' }) } + let!(:cs_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'cz' }) } before do create(:inbox_member, user: first_user, inbox: inbox) @@ -51,9 +51,9 @@ describe Contacts::FilterService do let(:payload) do [ { - attribute_key: 'browser_language', + attribute_key: 'country_code', filter_operator: 'equal_to', - values: ['en'], + values: ['uk'], query_operator: nil }.with_indifferent_access ] @@ -181,9 +181,9 @@ describe Contacts::FilterService do query_operator: 'AND' }.with_indifferent_access, { - attribute_key: 'browser_language', + attribute_key: 'country_code', filter_operator: 'equal_to', - values: ['el'], + values: ['GR'], query_operator: 'AND' }.with_indifferent_access, { diff --git a/spec/services/conversations/filter_service_spec.rb b/spec/services/conversations/filter_service_spec.rb index cb2279acf..2fbaf5f61 100644 --- a/spec/services/conversations/filter_service_spec.rb +++ b/spec/services/conversations/filter_service_spec.rb @@ -55,7 +55,7 @@ describe Conversations::FilterService do [ { attribute_key: 'browser_language', - filter_operator: 'contains', + filter_operator: 'equal_to', values: 'en', query_operator: 'AND', custom_attribute_type: '' @@ -88,7 +88,7 @@ describe Conversations::FilterService do it 'filters items with contains filter_operator with values being an array' do params[:payload] = [{ attribute_key: 'browser_language', - filter_operator: 'contains', + filter_operator: 'equal_to', values: %w[tr fr], query_operator: '', 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 params[:payload] = [{ attribute_key: 'browser_language', - filter_operator: 'does_not_contain', + filter_operator: 'not_equal_to', values: %w[tr en], query_operator: '', custom_attribute_type: '' @@ -291,6 +291,11 @@ describe Conversations::FilterService do end 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] = [ { attribute_key: 'conversation_type', @@ -301,7 +306,7 @@ describe Conversations::FilterService do }.with_indifferent_access, { attribute_key: 'browser_language', - filter_operator: 'is_equal_to', + filter_operator: 'not_equal_to', values: 'en', query_operator: nil, custom_attribute_type: '' From 1303469087d80706b732e8f4135c138bf7a913ac Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 20 Mar 2024 18:11:50 +0530 Subject: [PATCH 04/60] feat: Ability filter blocked contacts (#9048) - This PR introduces the ability to filter blocked contacts from the contacts filter UI --- app/helpers/filter_helper.rb | 24 +- .../i18n/locale/en/contactFilters.json | 3 +- .../components/ContactsAdvancedFilters.vue | 2 +- .../contacts/contactFilterItems/index.js | 12 + lib/filters/filter_keys.yml | 22 +- spec/services/contacts/filter_service_spec.rb | 304 ++++++++++-------- 6 files changed, 204 insertions(+), 163 deletions(-) diff --git a/app/helpers/filter_helper.rb b/app/helpers/filter_helper.rb index b3efc393a..bce2de5ea 100644 --- a/app/helpers/filter_helper.rb +++ b/app/helpers/filter_helper.rb @@ -34,7 +34,7 @@ module FilterHelper case current_filter['attribute_type'] when 'additional_attributes' - handle_additional_attributes(query_hash, filter_operator_value) + handle_additional_attributes(query_hash, filter_operator_value, current_filter['data_type']) else handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value) end @@ -45,10 +45,8 @@ module FilterHelper 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' + def handle_additional_attributes(query_hash, filter_operator_value, data_type) + if data_type == 'text_case_insensitive' "LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \ "#{filter_operator_value} #{query_hash[:query_operator]}" else @@ -63,6 +61,8 @@ module FilterHelper date_filter(current_filter, query_hash, filter_operator_value) when 'labels' tag_filter_query(query_hash, current_index) + when 'text_case_insensitive' + text_case_insensitive_filter(query_hash, filter_operator_value) else default_filter(query_hash, filter_operator_value) end @@ -73,14 +73,12 @@ module FilterHelper "#{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 text_case_insensitive_filter(query_hash, filter_operator_value) + "LOWER(#{filter_config[:table_name]}.#{query_hash[:attribute_key]}) " \ + "#{filter_operator_value} #{query_hash[:query_operator]}" + end + 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 + "#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}" end end diff --git a/app/javascript/dashboard/i18n/locale/en/contactFilters.json b/app/javascript/dashboard/i18n/locale/en/contactFilters.json index 09a543984..02d5dcf89 100644 --- a/app/javascript/dashboard/i18n/locale/en/contactFilters.json +++ b/app/javascript/dashboard/i18n/locale/en/contactFilters.json @@ -44,7 +44,8 @@ "CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox", "CREATED_AT": "Created At", "LAST_ACTIVITY": "Last Activity", - "REFERER_LINK": "Referrer link" + "REFERER_LINK": "Referrer link", + "BLOCKED": "Blocked" }, "GROUPS": { "STANDARD_FILTERS": "Standard Filters", diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue index 41ceb25e5..6f5e92017 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue @@ -243,7 +243,7 @@ export default { attr.attribute_display_type === 'checkbox' ); }); - if (isCustomAttributeCheckbox) { + if (isCustomAttributeCheckbox || type === 'blocked') { return [ { id: true, diff --git a/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js b/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js index 2d54c37bc..59376f8ef 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js +++ b/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js @@ -76,6 +76,14 @@ const filterTypes = [ filterOperators: OPERATOR_TYPES_5, attributeModel: 'standard', }, + { + attributeKey: 'blocked', + attributeI18nKey: 'BLOCKED', + inputType: 'search_select', + dataType: 'text', + filterOperators: OPERATOR_TYPES_1, + attributeModel: 'standard', + }, ]; export const filterAttributeGroups = [ @@ -115,6 +123,10 @@ export const filterAttributeGroups = [ key: 'last_activity_at', i18nKey: 'LAST_ACTIVITY', }, + { + key: 'blocked', + i18nKey: 'BLOCKED', + }, ], }, ]; diff --git a/lib/filters/filter_keys.yml b/lib/filters/filter_keys.yml index afdcc6be7..598ab84d6 100644 --- a/lib/filters/filter_keys.yml +++ b/lib/filters/filter_keys.yml @@ -8,7 +8,7 @@ # - 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"] +# - data_type: "text" : supported ["text", "text_case_insensitive", "number", "boolean", "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 ----- ### @@ -124,7 +124,7 @@ conversations: contacts: name: attribute_type: "standard" - data_type: "text" + data_type: "text_case_insensitive" filter_operators: - "equal_to" - "not_equal_to" @@ -132,7 +132,7 @@ contacts: - "does_not_contain" phone_number: attribute_type: "standard" - data_type: "text" + data_type: "text_case_insensitive" filter_operators: - "equal_to" - "not_equal_to" @@ -141,7 +141,7 @@ contacts: - "starts_with" email: attribute_type: "standard" - data_type: "text" + data_type: "text_case_insensitive" filter_operators: - "equal_to" - "not_equal_to" @@ -149,19 +149,19 @@ contacts: - "does_not_contain" identifier: attribute_type: "standard" - data_type: "text" + data_type: "text_case_insensitive" filter_operators: - "equal_to" - "not_equal_to" country_code: attribute_type: "additional_attributes" - data_type: "text" + data_type: "text_case_insensitive" filter_operators: - "equal_to" - "not_equal_to" city: attribute_type: "additional_attributes" - data_type: "text" + data_type: "text_case_insensitive" filter_operators: - "equal_to" - "not_equal_to" @@ -169,7 +169,7 @@ contacts: - "does_not_contain" company: attribute_type: "additional_attributes" - data_type: "text" + data_type: "text_case_insensitive" filter_operators: - "equal_to" - "not_equal_to" @@ -197,6 +197,12 @@ contacts: - "is_greater_than" - "is_less_than" - "days_before" + blocked: + attribute_type: "standard" + data_type: "boolean" + filter_operators: + - "equal_to" + - "not_equal_to" ### ----- End of Contact Filters ----- ### diff --git a/spec/services/contacts/filter_service_spec.rb b/spec/services/contacts/filter_service_spec.rb index e8c0ff184..a373cf81e 100644 --- a/spec/services/contacts/filter_service_spec.rb +++ b/spec/services/contacts/filter_service_spec.rb @@ -37,6 +37,8 @@ describe Contacts::FilterService do end describe '#perform' do + let!(:params) { { payload: [], page: 1 } } + before do en_contact.update_labels(%w[random_label support]) cs_contact.update_labels('support') @@ -46,90 +48,7 @@ describe Contacts::FilterService do cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19' }) end - context 'with query present' do - let!(:params) { { payload: [], page: 1 } } - let(:payload) do - [ - { - attribute_key: 'country_code', - filter_operator: 'equal_to', - values: ['uk'], - query_operator: nil - }.with_indifferent_access - ] - end - - context 'with label filter' do - it 'returns equal_to filter results properly' do - params[:payload] = [ - { - attribute_key: 'labels', - filter_operator: 'equal_to', - values: ['support'], - query_operator: nil - }.with_indifferent_access - ] - - result = filter_service.new(params, first_user).perform - expect(result[:contacts].length).to be 2 - expect(result[:contacts].first.label_list).to include('support') - expect(result[:contacts].last.label_list).to include('support') - end - - it 'returns not_equal_to filter results properly' do - params[:payload] = [ - { - attribute_key: 'labels', - filter_operator: 'not_equal_to', - values: ['support'], - query_operator: nil - }.with_indifferent_access - ] - - result = filter_service.new(params, first_user).perform - expect(result[:contacts].length).to be 1 - expect(result[:contacts].first.id).to eq el_contact.id - end - - it 'returns is_present filter results properly' do - params[:payload] = [ - { - attribute_key: 'labels', - filter_operator: 'is_present', - values: [], - query_operator: nil - }.with_indifferent_access - ] - - result = filter_service.new(params, first_user).perform - expect(result[:contacts].length).to be 2 - expect(result[:contacts].first.label_list).to include('support') - expect(result[:contacts].last.label_list).to include('support') - end - - it 'returns is_not_present filter results properly' do - params[:payload] = [ - { - attribute_key: 'labels', - filter_operator: 'is_not_present', - values: [], - query_operator: nil - }.with_indifferent_access - ] - - result = filter_service.new(params, first_user).perform - expect(result[:contacts].length).to be 1 - expect(result[:contacts].first.id).to eq el_contact.id - end - end - - it 'filter contacts by additional_attributes' do - params[:payload] = payload - result = filter_service.new(params, first_user).perform - expect(result[:count]).to be 1 - expect(result[:contacts].first.id).to eq(en_contact.id) - end - + context 'with standard attributes - name' do it 'filter contacts by name' do params[:payload] = [ { @@ -145,7 +64,168 @@ describe Contacts::FilterService do expect(result[:contacts].length).to be 1 expect(result[:contacts].first.name).to eq(en_contact.name) end + end + context 'with standard attributes - blocked' do + it 'filter contacts by blocked' do + blocked_contact = create(:contact, account: account, blocked: true) + params = { payload: [{ attribute_key: 'blocked', filter_operator: 'equal_to', values: ['true'], + query_operator: nil }.with_indifferent_access] } + result = filter_service.new(params, first_user).perform + expect(result[:count]).to be 1 + expect(result[:contacts].first.id).to eq(blocked_contact.id) + end + + it 'filter contacts by not_blocked' do + params = { payload: [{ attribute_key: 'blocked', filter_operator: 'equal_to', values: [false], + query_operator: nil }.with_indifferent_access] } + result = filter_service.new(params, first_user).perform + # existing contacts are not blocked + expect(result[:count]).to be 3 + end + end + + context 'with standard attributes - label' do + it 'returns equal_to filter results properly' do + params[:payload] = [ + { + attribute_key: 'labels', + filter_operator: 'equal_to', + values: ['support'], + query_operator: nil + }.with_indifferent_access + ] + + result = filter_service.new(params, first_user).perform + expect(result[:contacts].length).to be 2 + expect(result[:contacts].first.label_list).to include('support') + expect(result[:contacts].last.label_list).to include('support') + end + + it 'returns not_equal_to filter results properly' do + params[:payload] = [ + { + attribute_key: 'labels', + filter_operator: 'not_equal_to', + values: ['support'], + query_operator: nil + }.with_indifferent_access + ] + + result = filter_service.new(params, first_user).perform + expect(result[:contacts].length).to be 1 + expect(result[:contacts].first.id).to eq el_contact.id + end + + it 'returns is_present filter results properly' do + params[:payload] = [ + { + attribute_key: 'labels', + filter_operator: 'is_present', + values: [], + query_operator: nil + }.with_indifferent_access + ] + + result = filter_service.new(params, first_user).perform + expect(result[:contacts].length).to be 2 + expect(result[:contacts].first.label_list).to include('support') + expect(result[:contacts].last.label_list).to include('support') + end + + it 'returns is_not_present filter results properly' do + params[:payload] = [ + { + attribute_key: 'labels', + filter_operator: 'is_not_present', + values: [], + query_operator: nil + }.with_indifferent_access + ] + + result = filter_service.new(params, first_user).perform + expect(result[:contacts].length).to be 1 + expect(result[:contacts].first.id).to eq el_contact.id + end + end + + context 'with standard attributes - last_activity_at' do + before do + Time.zone = 'UTC' + el_contact.update(last_activity_at: (Time.zone.today - 4.days)) + cs_contact.update(last_activity_at: (Time.zone.today - 5.days)) + en_contact.update(last_activity_at: (Time.zone.today - 2.days)) + end + + it 'filter by last_activity_at 3_days_before and custom_attributes' do + params[:payload] = [ + { + attribute_key: 'last_activity_at', + filter_operator: 'days_before', + values: [3], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'contact_additional_information', + filter_operator: 'equal_to', + values: ['test custom data'], + query_operator: nil + }.with_indifferent_access + ] + + expected_count = Contact.where( + "last_activity_at < ? AND + custom_attributes->>'contact_additional_information' = ?", + (Time.zone.today - 3.days), + 'test custom data' + ).count + + result = filter_service.new(params, first_user).perform + expect(result[:contacts].length).to be expected_count + expect(result[:contacts].first.id).to eq(el_contact.id) + end + + it 'filter by last_activity_at 2_days_before and custom_attributes' do + params[:payload] = [ + { + attribute_key: 'last_activity_at', + filter_operator: 'days_before', + values: [2], + query_operator: nil + }.with_indifferent_access + ] + + expected_count = Contact.where('last_activity_at < ?', (Time.zone.today - 2.days)).count + + result = filter_service.new(params, first_user).perform + expect(result[:contacts].length).to be expected_count + expect(result[:contacts].pluck(:id)).to include(el_contact.id) + expect(result[:contacts].pluck(:id)).to include(cs_contact.id) + expect(result[:contacts].pluck(:id)).not_to include(en_contact.id) + end + end + + context 'with additional attributes' do + let(:payload) do + [ + { + attribute_key: 'country_code', + filter_operator: 'equal_to', + values: ['uk'], + query_operator: nil + }.with_indifferent_access + ] + end + + it 'filter contacts by additional_attributes' do + params[:payload] = payload + result = filter_service.new(params, first_user).perform + expect(result[:count]).to be 1 + expect(result[:contacts].first.id).to eq(en_contact.id) + end + end + + context 'with custom attributes' do it 'filter by custom_attributes and labels' do params[:payload] = [ { @@ -220,62 +300,6 @@ describe Contacts::FilterService do expect(result[:contacts].length).to be expected_count expect(result[:contacts].pluck(:id)).to include(el_contact.id) end - - context 'with x_days_before filter' do - before do - Time.zone = 'UTC' - el_contact.update(last_activity_at: (Time.zone.today - 4.days)) - cs_contact.update(last_activity_at: (Time.zone.today - 5.days)) - en_contact.update(last_activity_at: (Time.zone.today - 2.days)) - end - - it 'filter by last_activity_at 3_days_before and custom_attributes' do - params[:payload] = [ - { - attribute_key: 'last_activity_at', - filter_operator: 'days_before', - values: [3], - query_operator: 'AND' - }.with_indifferent_access, - { - attribute_key: 'contact_additional_information', - filter_operator: 'equal_to', - values: ['test custom data'], - query_operator: nil - }.with_indifferent_access - ] - - expected_count = Contact.where( - "last_activity_at < ? AND - custom_attributes->>'contact_additional_information' = ?", - (Time.zone.today - 3.days), - 'test custom data' - ).count - - result = filter_service.new(params, first_user).perform - expect(result[:contacts].length).to be expected_count - expect(result[:contacts].first.id).to eq(el_contact.id) - end - - it 'filter by last_activity_at 2_days_before and custom_attributes' do - params[:payload] = [ - { - attribute_key: 'last_activity_at', - filter_operator: 'days_before', - values: [2], - query_operator: nil - }.with_indifferent_access - ] - - expected_count = Contact.where('last_activity_at < ?', (Time.zone.today - 2.days)).count - - result = filter_service.new(params, first_user).perform - expect(result[:contacts].length).to be expected_count - expect(result[:contacts].pluck(:id)).to include(el_contact.id) - expect(result[:contacts].pluck(:id)).to include(cs_contact.id) - expect(result[:contacts].pluck(:id)).not_to include(en_contact.id) - end - end end end end From 44956176a17f397dbb56655deef9b33d3b3d1f99 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 21 Mar 2024 09:34:44 +0530 Subject: [PATCH 05/60] feat: Add SLA header component (#9129) Co-authored-by: Shivam Mishra Co-authored-by: Pranav --- .../dashboard/i18n/locale/en/sla.json | 12 ++- .../dashboard/settings/SettingsWrapper.vue | 21 ++++ .../components/BaseSettingsHeader.vue | 100 ++++++++++++++++++ .../settings/sla/components/SLAHeader.vue | 23 ++++ .../shared/components/EmojiOrIcon.vue | 1 + .../FluentIcon/dashboard-icons.json | 2 + 6 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAHeader.vue diff --git a/app/javascript/dashboard/i18n/locale/en/sla.json b/app/javascript/dashboard/i18n/locale/en/sla.json index 228f87066..d2a4f1d2a 100644 --- a/app/javascript/dashboard/i18n/locale/en/sla.json +++ b/app/javascript/dashboard/i18n/locale/en/sla.json @@ -1,6 +1,9 @@ { "SLA": { "HEADER": "SLA", + "ADD_ACTION": "Add SLA", + "DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.", + "LEARN_MORE": "Learn more about SLA", "HEADER_BTN_TXT": "Add SLA", "LOADING": "Fetching SLAs", "SEARCH_404": "There are no items matching this query", @@ -9,7 +12,14 @@ "404": "There are no SLAs available in this account.", "TITLE": "Manage SLA", "DESC": "SLAs: Friendly promises for great service!", - "TABLE_HEADER": ["Name", "Description", "FRT", "NRT", "RT", "Business Hours"] + "TABLE_HEADER": [ + "Name", + "Description", + "FRT", + "NRT", + "RT", + "Business Hours" + ] }, "FORM": { "NAME": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue b/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue new file mode 100644 index 000000000..293a093af --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue @@ -0,0 +1,21 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue new file mode 100644 index 000000000..a888b5093 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue @@ -0,0 +1,100 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAHeader.vue new file mode 100644 index 000000000..8b3236836 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAHeader.vue @@ -0,0 +1,23 @@ + + diff --git a/app/javascript/shared/components/EmojiOrIcon.vue b/app/javascript/shared/components/EmojiOrIcon.vue index 5cde463fe..2a93dc06f 100644 --- a/app/javascript/shared/components/EmojiOrIcon.vue +++ b/app/javascript/shared/components/EmojiOrIcon.vue @@ -4,6 +4,7 @@ v-else-if="showIcon" :size="iconSize" :icon="icon" + class="flex-shrink-0" :class="className" /> diff --git a/app/javascript/shared/components/FluentIcon/dashboard-icons.json b/app/javascript/shared/components/FluentIcon/dashboard-icons.json index e1235bf51..efff6fddf 100644 --- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json +++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json @@ -9,6 +9,7 @@ "arrow-clockwise-outline": "M12 4.75a7.25 7.25 0 1 0 7.201 6.406c-.068-.588.358-1.156.95-1.156.515 0 .968.358 1.03.87a9.25 9.25 0 1 1-3.432-6.116V4.25a1 1 0 1 1 2.001 0v2.698l.034.052h-.034v.25a1 1 0 0 1-1 1h-3a1 1 0 1 1 0-2h.666A7.219 7.219 0 0 0 12 4.75Z", "arrow-download-outline": "M18.25 20.5a.75.75 0 1 1 0 1.5l-13 .004a.75.75 0 1 1 0-1.5l13-.004ZM11.648 2.012l.102-.007a.75.75 0 0 1 .743.648l.007.102-.001 13.685 3.722-3.72a.75.75 0 0 1 .976-.073l.085.073a.75.75 0 0 1 .072.976l-.073.084-4.997 4.997a.75.75 0 0 1-.976.073l-.085-.073-5.003-4.996a.75.75 0 0 1 .976-1.134l.084.072 3.719 3.714L11 2.755a.75.75 0 0 1 .648-.743l.102-.007-.102.007Z", "arrow-expand-outline": "M7.669 14.923a1 1 0 0 1 1.414 1.414l-2.668 2.667H8a1 1 0 0 1 .993.884l.007.116a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4a1 1 0 1 1 2 0v1.587l2.669-2.668Zm8.336 6.081a1 1 0 1 1 0-2h1.583l-2.665-2.667a1 1 0 0 1-.083-1.32l.083-.094a1 1 0 0 1 1.414 0l2.668 2.67v-1.589a1 1 0 0 1 .883-.993l.117-.007a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4ZM8 3a1 1 0 0 1 0 2H6.417l2.665 2.668a1 1 0 0 1 .083 1.32l-.083.094a1 1 0 0 1-1.414 0L5 6.412V8a1 1 0 0 1-.883.993L4 9a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h4Zm12.005 0a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V6.412l-2.668 2.67a1 1 0 0 1-1.32.083l-.094-.083a1 1 0 0 1 0-1.414L17.589 5h-1.584a1 1 0 0 1-.993-.883L15.005 4a1 1 0 0 1 1-1h4Z", + "arrow-outwards-outline": "m16 8.4l-8.875 8.9q-.3.3-.713.3t-.712-.3q-.3-.3-.3-.713t.3-.712L14.6 7H7q-.425 0-.713-.288T6 6q0-.425.288-.713T7 5h10q.425 0 .713.288T18 6v10q0 .425-.288.713T17 17q-.425 0-.713-.288T16 16V8.4Z", "arrow-redo-outline": "M19.25 2a.75.75 0 0 0-.743.648l-.007.102v5.69l-4.574-4.56a6.41 6.41 0 0 0-8.878-.179l-.186.18a6.41 6.41 0 0 0 0 9.063l8.845 8.84a.75.75 0 0 0 1.06-1.062l-8.845-8.838a4.91 4.91 0 0 1 6.766-7.112l.178.17L17.438 9.5H11.75a.75.75 0 0 0-.743.648L11 10.25c0 .38.282.694.648.743l.102.007h7.5a.75.75 0 0 0 .743-.648L20 10.25v-7.5a.75.75 0 0 0-.75-.75Z", "arrow-right-import-outline": "M21.25 4.5a.75.75 0 0 1 .743.648L22 5.25v13.004a.75.75 0 0 1-1.493.102l-.007-.102V5.25a.75.75 0 0 1 .75-.75Zm-8.603 1.804l.072-.084a.75.75 0 0 1 .977-.073l.084.073l4.997 4.997a.75.75 0 0 1 .073.976l-.073.085l-4.997 5.003a.75.75 0 0 1-1.133-.976l.072-.084l3.711-3.717H2.75a.75.75 0 0 1-.743-.647L2 11.755a.75.75 0 0 1 .648-.743l.102-.007l13.693-.001l-3.724-3.724a.75.75 0 0 1-.072-.976l.072-.084l-.072.084Z", "arrow-reply-outline": "M9.277 16.221a.75.75 0 0 1-1.061 1.06l-4.997-5.003a.75.75 0 0 1 0-1.06L8.217 6.22a.75.75 0 0 1 1.061 1.06L5.557 11h7.842c1.595 0 2.81.242 3.889.764l.246.126a6.203 6.203 0 0 1 2.576 2.576c.61 1.14.89 2.418.89 4.135a.75.75 0 0 1-1.5 0c0-1.484-.228-2.52-.713-3.428a4.702 4.702 0 0 0-1.96-1.96c-.838-.448-1.786-.676-3.094-.709L13.4 12.5H5.562l3.715 3.721Z", @@ -166,6 +167,7 @@ "person-outline": "M17.754 14a2.249 2.249 0 0 1 2.25 2.249v.575c0 .894-.32 1.76-.902 2.438-1.57 1.834-3.957 2.739-7.102 2.739-3.146 0-5.532-.905-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.577a2.249 2.249 0 0 1 2.249-2.25h11.501Zm0 1.5H6.253a.749.749 0 0 0-.75.749v.577c0 .536.192 1.054.54 1.461 1.253 1.468 3.219 2.214 5.957 2.214s4.706-.746 5.962-2.214a2.25 2.25 0 0 0 .541-1.463v-.575a.749.749 0 0 0-.749-.75ZM12 2.004a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z", "person-filled": "M17.754 14a2.249 2.249 0 0 1 2.249 2.25v.918a2.75 2.75 0 0 1-.513 1.598c-1.545 2.164-4.07 3.235-7.49 3.235c-3.421 0-5.944-1.072-7.486-3.236a2.75 2.75 0 0 1-.51-1.596v-.92A2.249 2.249 0 0 1 6.251 14h11.502ZM12 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Z", "play-circle-outline": "M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12Zm8.856-3.845A1.25 1.25 0 0 0 9 9.248v5.504a1.25 1.25 0 0 0 1.856 1.093l5.757-3.189a.75.75 0 0 0 0-1.312l-5.757-3.189Z", + "plus-sign-outline": "M12 19q-.425 0-.713-.288T11 18v-5H6q-.425 0-.713-.288T5 12q0-.425.288-.713T6 11h5V6q0-.425.288-.713T12 5q.425 0 .713.288T13 6v5h5q.425 0 .713.288T19 12q0 .425-.288.713T18 13h-5v5q0 .425-.288.713T12 19Z", "power-outline": "M8.204 4.82a.75.75 0 0 1 .634 1.36A7.51 7.51 0 0 0 4.5 12.991c0 4.148 3.358 7.51 7.499 7.51s7.499-3.362 7.499-7.51a7.51 7.51 0 0 0-4.323-6.804.75.75 0 1 1 .637-1.358 9.01 9.01 0 0 1 5.186 8.162c0 4.976-4.029 9.01-9 9.01C7.029 22 3 17.966 3 12.99a9.01 9.01 0 0 1 5.204-8.17ZM12 2.496a.75.75 0 0 1 .743.648l.007.102v7.5a.75.75 0 0 1-1.493.102l-.007-.102v-7.5a.75.75 0 0 1 .75-.75Z", "quote-outline": "M7.5 6a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.555-1.24 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.64-1.737 2.66-3.674 3.077-5.859A2.5 2.5 0 1 1 7.5 6Zm9 0a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.56-1.238 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.643-1.738 2.662-3.672 3.078-5.859A2.5 2.5 0 1 1 16.5 6Zm-9 1.5a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Zm9 0a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Z", "repeat-outline": "m14.712 2.289l-.087-.078a1 1 0 0 0-1.327.078l-.078.087a.999.999 0 0 0 .078 1.326l1.299 1.297H8.999l-.24.004A6.997 6.997 0 0 0 2 11.993a6.94 6.94 0 0 0 1.189 3.899a.999.999 0 0 0 1.626-1.163l-.135-.218A4.997 4.997 0 0 1 9 6.998h5.595l-1.297 1.297l-.078.087a.999.999 0 0 0 1.492 1.326l3.006-3.003l.077-.087a.999.999 0 0 0-.078-1.326l-3.005-3.003Zm6.075 5.771A.999.999 0 0 0 19 8.677c0 .209.064.402.172.561a4.997 4.997 0 0 1-4.17 7.75H9.414l1.294-1.29l.083-.096a1 1 0 0 0-.006-1.23l-.077-.088l-.095-.084a1.001 1.001 0 0 0-1.232.006l-.088.078l-3.005 3.003l-.083.095a1 1 0 0 0 .006 1.231l.077.087l3.005 3.003l.095.084a1 1 0 0 0 1.397-1.41l-.077-.087l-1.304-1.303H15l.24-.003a6.997 6.997 0 0 0 5.546-10.927v.003Z", From 762a39330a678d309fb00e32bb7088d290c3ead7 Mon Sep 17 00:00:00 2001 From: Ryan Kon Date: Thu, 21 Mar 2024 06:14:04 -0700 Subject: [PATCH 06/60] fix: use safe nav when downcasing email in from_email (#9139) Use safe nav when downcasing email in from_email --- app/models/contact.rb | 2 +- app/models/user.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/contact.rb b/app/models/contact.rb index a60e9f4d2..95ee69c75 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -169,7 +169,7 @@ class Contact < ApplicationRecord end def self.from_email(email) - find_by(email: email.downcase) + find_by(email: email&.downcase) end private diff --git a/app/models/user.rb b/app/models/user.rb index 3fdaf7f25..faadb3271 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -157,7 +157,7 @@ class User < ApplicationRecord end def self.from_email(email) - find_by(email: email.downcase) + find_by(email: email&.downcase) end private From c51492c6747f06f373e59feec9d167e284c366d8 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 21 Mar 2024 19:30:11 +0530 Subject: [PATCH 07/60] feat: SLA List Item component (#9135) - Base settings list and list item components. - SLA list item component. Fixes: https://linear.app/chatwoot/issue/CW-3126/create-a-sla-list-item-component-with-the-new-design Co-authored-by: Shivam Mishra Co-authored-by: Pranav --- .../dashboard/assets/scss/_layout.scss | 5 +- .../dashboard/i18n/locale/en/sla.json | 14 +++- .../dashboard/settings/SettingsLayout.vue | 6 ++ .../dashboard/settings/SettingsWrapper.vue | 2 +- .../components/BaseSettingsHeader.vue | 8 ++- .../components/BaseSettingsListItem.vue | 53 +++++++++++++++ .../sla/components/SLABusinessHoursLabel.vue | 39 +++++++++++ .../settings/sla/components/SLAListItem.vue | 64 +++++++++++++++++++ .../sla/components/SLAResponseTime.vue | 36 +++++++++++ .../FluentIcon/dashboard-icons.json | 3 + tailwind.config.js | 5 ++ 11 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsListItem.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/sla/components/SLABusinessHoursLabel.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItem.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAResponseTime.vue diff --git a/app/javascript/dashboard/assets/scss/_layout.scss b/app/javascript/dashboard/assets/scss/_layout.scss index ea40c1f3a..54a03c403 100644 --- a/app/javascript/dashboard/assets/scss/_layout.scss +++ b/app/javascript/dashboard/assets/scss/_layout.scss @@ -1,11 +1,10 @@ // scss-lint:disable SpaceAfterPropertyColon -// @import 'shared/assets/fonts/inter'; - +@import 'shared/assets/fonts/inter'; +// Inter, html, body { font-family: 'PlusJakarta', - Inter, -apple-system, system-ui, BlinkMacSystemFont, diff --git a/app/javascript/dashboard/i18n/locale/en/sla.json b/app/javascript/dashboard/i18n/locale/en/sla.json index d2a4f1d2a..dcf8d2dca 100644 --- a/app/javascript/dashboard/i18n/locale/en/sla.json +++ b/app/javascript/dashboard/i18n/locale/en/sla.json @@ -19,7 +19,19 @@ "NRT", "RT", "Business Hours" - ] + ], + "BUSINESS_HOURS_ON": "Business hours on", + "BUSINESS_HOURS_OFF": "Business hours off", + "RESPONSE_TYPES": { + "FRT": "First response time threshold", + "NRT": "Next response time threshold", + "RT": "Resolution time threshold", + "SHORT_HAND": { + "FRT": "FRT", + "NRT": "NRT", + "RT": "RT" + } + } }, "FORM": { "NAME": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue b/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue new file mode 100644 index 000000000..6dd3c306b --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue @@ -0,0 +1,6 @@ + diff --git a/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue b/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue index 293a093af..3f5bf16f9 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue @@ -9,7 +9,7 @@ defineProps({