From 372bd75028d9fcfdcfa5a2ad2cc1b79184b9fb8a Mon Sep 17 00:00:00 2001 From: Tejaswini Chile Date: Mon, 1 Nov 2021 13:57:04 +0530 Subject: [PATCH] feat: Add filter APIs for Contacts and Conversations (#3264) --- .../api/v1/accounts/contacts_controller.rb | 5 +- .../v1/accounts/conversations_controller.rb | 4 +- app/services/contacts/filter_service.rb | 44 +++++ app/services/conversations/filter_service.rb | 59 +++++++ app/services/filter_service.rb | 50 ++++++ .../conversations/filter.json.jbuilder | 11 +- config/routes.rb | 4 +- lib/filters/conversation_filters.json | 4 +- lib/filters/filter_keys.json | 153 ++++++++++++++++++ .../v1/accounts/contacts_controller_spec.rb | 10 +- .../accounts/conversations_controller_spec.rb | 13 +- spec/services/contacts/filter_service_spec.rb | 56 +++++++ .../conversations/filter_service_spec.rb | 76 +++++++++ 13 files changed, 470 insertions(+), 19 deletions(-) create mode 100644 app/services/contacts/filter_service.rb create mode 100644 app/services/conversations/filter_service.rb create mode 100644 app/services/filter_service.rb create mode 100644 lib/filters/filter_keys.json create mode 100644 spec/services/contacts/filter_service_spec.rb create mode 100644 spec/services/conversations/filter_service_spec.rb diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 0c9341b08..090827d8f 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -51,7 +51,10 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController def show; end def filter - @contacts = Current.account.contacts.limit(10) + result = ::Contacts::FilterService.new(params.permit!, current_user).perform + contacts = result[:contacts] + @contacts_count = result[:count] + @contacts = fetch_contacts_with_conversation_count(contacts) 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 601f63604..2d91e1cbd 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -32,7 +32,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def show; end def filter - @conversations = Current.account.conversations.limit(10) + result = ::Conversations::FilterService.new(params.permit!, current_user).perform + @conversations = result[:conversations] + @conversations_count = result[:count] end def mute diff --git a/app/services/contacts/filter_service.rb b/app/services/contacts/filter_service.rb new file mode 100644 index 000000000..879295c8f --- /dev/null +++ b/app/services/contacts/filter_service.rb @@ -0,0 +1,44 @@ +class Contacts::FilterService < FilterService + def perform + @contacts = contact_query_builder + + { + contacts: @contacts, + count: { + all_count: @contacts.count + } + } + 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) + + case current_filter['attribute_type'] + when 'additional_attributes' + " contacts.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} " + when 'standard' + if attribute_key == 'labels' + " tags.id #{filter_operator_value} #{query_operator} " + else + " contacts.#{attribute_key} #{filter_operator_value} #{query_operator} " + end + end + end + + def base_relation + Current.account.contacts.left_outer_joins(:labels) + end +end diff --git a/app/services/conversations/filter_service.rb b/app/services/conversations/filter_service.rb new file mode 100644 index 000000000..5f12bbb6e --- /dev/null +++ b/app/services/conversations/filter_service.rb @@ -0,0 +1,59 @@ +class Conversations::FilterService < FilterService + def perform + @conversations = conversation_query_builder + mine_count, unassigned_count, all_count, = set_count_for_all_conversations + assigned_count = all_count - unassigned_count + + { + conversations: conversations, + count: { + mine_count: mine_count, + assigned_count: assigned_count, + unassigned_count: unassigned_count, + all_count: all_count + } + } + 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) + + case current_filter['attribute_type'] + when 'additional_attributes' + " conversations.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} " + when 'standard' + if attribute_key == 'labels' + " tags.id #{filter_operator_value} #{query_operator} " + else + " conversations.#{attribute_key} #{filter_operator_value} #{query_operator} " + end + end + end + + def base_relation + Current.account.conversations.left_outer_joins(:labels) + end + + def current_page + @params[:page] || 1 + end + + def conversations + @conversations = @conversations.includes( + :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team + ) + @conversations.latest.page(current_page) + end +end diff --git a/app/services/filter_service.rb b/app/services/filter_service.rb new file mode 100644 index 000000000..e855ea52c --- /dev/null +++ b/app/services/filter_service.rb @@ -0,0 +1,50 @@ +require 'json' + +class FilterService + def initialize(params, user) + @params = params + @user = user + file = File.read('./lib/filters/filter_keys.json') + @filters = JSON.parse(file) + @query_string = '' + @filter_values = {} + end + + def perform; end + + def filter_operation(query_hash, current_index) + case query_hash[:filter_operator] + when 'equal_to' + @filter_values["value_#{current_index}"] = filter_values(query_hash) + "IN (:value_#{current_index})" + when 'not_equal_to' + @filter_values["value_#{current_index}"] = filter_values(query_hash) + "NOT IN (:value_#{current_index})" + when 'contains' + @filter_values["value_#{current_index}"] = "%#{filter_values(query_hash)}%" + "LIKE :value_#{current_index}" + when 'does_not_contain' + @filter_values["value_#{current_index}"] = "%#{filter_values(query_hash)}%" + "NOT LIKE :value_#{current_index}" + else + @filter_values["value_#{current_index}"] = filter_values(query_hash).to_s + "= :value_#{current_index}" + end + end + + def filter_values(query_hash) + if query_hash['attribute_key'] == 'status' + query_hash['values'].map { |x| Conversation.statuses[x.to_sym] } + else + query_hash['values'] + end + end + + def set_count_for_all_conversations + [ + @conversations.assigned_to(@user).count, + @conversations.unassigned.count, + @conversations.count + ] + end +end diff --git a/app/views/api/v1/accounts/conversations/filter.json.jbuilder b/app/views/api/v1/accounts/conversations/filter.json.jbuilder index 6b8baf3ce..c1d98e2c2 100644 --- a/app/views/api/v1/accounts/conversations/filter.json.jbuilder +++ b/app/views/api/v1/accounts/conversations/filter.json.jbuilder @@ -1,3 +1,10 @@ -json.array! @conversations do |conversation| - json.partial! 'api/v1/models/conversation.json.jbuilder', conversation: conversation +json.meta do + json.mine_count @conversations_count[:mine_count] + json.unassigned_count @conversations_count[:unassigned_count] + json.all_count @conversations_count[:all_count] +end +json.payload do + json.array! @conversations do |conversation| + json.partial! 'api/v1/conversations/partials/conversation.json.jbuilder', conversation: conversation + end end diff --git a/config/routes.rb b/config/routes.rb index 3149f7a47..fe8a53385 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -61,7 +61,7 @@ Rails.application.routes.draw do collection do get :meta get :search - get :filter + post :filter end scope module: :conversations do resources :messages, only: [:index, :create, :destroy] @@ -83,7 +83,7 @@ Rails.application.routes.draw do collection do get :active get :search - get :filter + post :filter post :import end member do diff --git a/lib/filters/conversation_filters.json b/lib/filters/conversation_filters.json index 6e493e57a..39f58f5c6 100644 --- a/lib/filters/conversation_filters.json +++ b/lib/filters/conversation_filters.json @@ -65,8 +65,8 @@ "attribute_type": "standard" }, { - "attribute_key": "browser", - "attribute_name": "browser", + "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" ], diff --git a/lib/filters/filter_keys.json b/lib/filters/filter_keys.json new file mode 100644 index 000000000..4edcd389e --- /dev/null +++ b/lib/filters/filter_keys.json @@ -0,0 +1,153 @@ +{ + "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" + }, + "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" + }, + "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" + } + }, + "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" + }, + "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" + }, + "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/spec/controllers/api/v1/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb index dc7d81146..14f83d35b 100644 --- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb @@ -233,10 +233,12 @@ RSpec.describe 'Contacts API', type: :request do let!(:contact2) { create(:contact, :with_email, name: 'testcontact', account: account, email: 'test@test.com') } it 'returns all contacts when query is empty' do - get "/api/v1/accounts/#{account.id}/contacts/filter", - params: { q: [] }, - headers: admin.create_new_auth_token, - as: :json + post "/api/v1/accounts/#{account.id}/contacts/filter", + params: { + payload: [] + }, + headers: admin.create_new_auth_token, + as: :json expect(response).to have_http_status(:success) expect(response.body).to include(contact2.email) diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index 2663d0a23..56b82484f 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -112,7 +112,7 @@ RSpec.describe 'Conversations API', type: :request do describe 'GET /api/v1/accounts/{account.id}/conversations/filter' do context 'when it is an unauthenticated user' do it 'returns unauthorized' do - get "/api/v1/accounts/#{account.id}/conversations/filter", params: { q: 'test' } + post "/api/v1/accounts/#{account.id}/conversations/filter", params: { q: 'test' } expect(response).to have_http_status(:unauthorized) end @@ -129,16 +129,15 @@ RSpec.describe 'Conversations API', type: :request do end it 'returns all conversations with empty query' do - get "/api/v1/accounts/#{account.id}/conversations/filter", - headers: agent.create_new_auth_token, - params: { q: 'test1' }, - as: :json + post "/api/v1/accounts/#{account.id}/conversations/filter", + headers: agent.create_new_auth_token, + params: { payload: [] }, + as: :json expect(response).to have_http_status(:success) response_data = JSON.parse(response.body, symbolize_names: true) - expect(response_data.count).to eq(1) - expect(response_data[0][:messages][0][:content]).to include(Message.first.content) + expect(response_data.count).to eq(2) end end end diff --git a/spec/services/contacts/filter_service_spec.rb b/spec/services/contacts/filter_service_spec.rb new file mode 100644 index 000000000..0ba1d1f7e --- /dev/null +++ b/spec/services/contacts/filter_service_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' + +describe ::Contacts::FilterService do + subject(:filter_service) { described_class } + + let!(:account) { create(:account) } + let!(:user_1) { create(:user, account: account) } + let!(:user_2) { create(:user, account: account) } + let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) } + let!(:contact) { create(:contact, account: account, additional_attributes: { 'browser_language': 'en' }) } + + before do + create(:inbox_member, user: user_1, inbox: inbox) + create(:inbox_member, user: user_2, inbox: inbox) + create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: contact) + create(:conversation, account: account, inbox: inbox) + Current.account = account + end + + describe '#perform' do + context 'with query present' do + let!(:params) { { payload: [], page: 1 } } + let(:payload) do + [ + { + attribute_key: 'browser_language', + filter_operator: 'equal_to', + values: ['en'], + query_operator: nil + }.with_indifferent_access + ] + end + + it 'filter contacts by additional_attributes' do + params[:payload] = payload + result = filter_service.new(params, user_1).perform + expect(result.length).to be 2 + end + + it 'filter conversations by tags' do + Contact.last.update_labels('support') + params[:payload] = [ + { + attribute_key: 'labels', + filter_operator: 'equal_to', + values: [1], + query_operator: nil + }.with_indifferent_access + ] + + result = filter_service.new(params, user_1).perform + expect(result.length).to be 2 + end + end + end +end diff --git a/spec/services/conversations/filter_service_spec.rb b/spec/services/conversations/filter_service_spec.rb new file mode 100644 index 000000000..02430c197 --- /dev/null +++ b/spec/services/conversations/filter_service_spec.rb @@ -0,0 +1,76 @@ +require 'rails_helper' + +describe ::Conversations::FilterService do + subject(:filter_service) { described_class } + + let!(:account) { create(:account) } + let!(:user_1) { create(:user, account: account) } + let!(:user_2) { create(:user, account: account) } + let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) } + + before do + create(:inbox_member, user: user_1, inbox: inbox) + create(:inbox_member, user: user_2, inbox: inbox) + create(:conversation, account: account, inbox: inbox, assignee: user_1) + create(:conversation, account: account, inbox: inbox, assignee: user_1, + status: 'pending', additional_attributes: { 'browser_language': 'en' }) + create(:conversation, account: account, inbox: inbox, assignee: user_1, + status: 'pending', additional_attributes: { 'browser_language': 'en' }) + create(:conversation, account: account, inbox: inbox, assignee: user_2) + # unassigned conversation + create(:conversation, account: account, inbox: inbox) + Current.account = account + end + + describe '#perform' do + context 'with query present' do + let!(:params) { { payload: [], page: 1 } } + let(:payload) do + [ + { + attribute_key: 'browser_language', + filter_operator: 'equal_to', + values: ['en'], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: %w[open pending], + query_operator: nil + }.with_indifferent_access + ] + end + + it 'filter conversations by custom_attributes and status' do + params[:payload] = payload + result = filter_service.new(params, user_1).perform + conversations = Conversation.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2]) + expect(result.length).to be conversations.count + end + + it 'filter conversations by tags' do + Conversation.last.update_labels('support') + params[:payload] = [ + { + attribute_key: 'assignee_id', + filter_operator: 'equal_to', + values: [ + user_1.id, + user_2.id + ], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'labels', + filter_operator: 'equal_to', + values: [1], + query_operator: nil + }.with_indifferent_access + ] + result = filter_service.new(params, user_1).perform + expect(result.length).to be 2 + end + end + end +end