From b75ea7a7623a4dfb93aea1152285ab8faa417d1d Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 25 Sep 2025 15:26:44 +0530 Subject: [PATCH] feat: Use resolved contacts as base relation for filtering (#12520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR has two changes to speed up contact filtering ### Updated Base Relation Update the `base_relation` to use resolved contacts scope to improve perf when filtering conversations. This narrows the search space drastically, and what is usually a sequential scan becomes a index scan for that `account_id` ref: https://github.com/chatwoot/chatwoot/pull/9347 ref: https://github.com/chatwoot/chatwoot/pull/7175/ Result: https://explain.dalibo.com/plan/c8a8gb17f0275fgf#plan ## Selective filtering in Compose New Conversation We also cost of filtering in compose new conversation dialog by reducing the search space based on the search candidate. For instance, a search term that obviously can’t be a phone, we exclude that from the filter. Similarly we skip name lookups for email-shaped queries. Removing the phone number took the query times from 50 seconds to under 1 seconds ### Comparison 1. Only Email: https://explain.dalibo.com/plan/h91a6844a4438a6a 2. Email + Name: https://explain.dalibo.com/plan/beg3aah05ch9ade0 3. Email + Name + Phone: https://explain.dalibo.com/plan/c8a8gb17f0275fgf --- .../components/ComposeNewConversationForm.vue | 17 +++++++++-- app/services/contacts/filter_service.rb | 3 +- spec/jobs/account/contacts_export_job_spec.rb | 6 ++-- spec/services/contacts/filter_service_spec.rb | 29 ++++++++++++++++--- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 41732ac2b..4d6d41dac 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -148,10 +148,21 @@ const isAnyDropdownActive = computed(() => { const handleContactSearch = value => { showContactsDropdown.value = true; - emit('searchContacts', { - keys: ['email', 'phone_number', 'name'], - query: value, + const query = typeof value === 'string' ? value.trim() : ''; + const hasAlphabet = Array.from(query).some(char => { + const lower = char.toLowerCase(); + const upper = char.toUpperCase(); + return lower !== upper; }); + const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query); + + const keys = ['email', 'phone_number', 'name'].filter(key => { + if (key === 'phone_number' && hasAlphabet) return false; + if (key === 'name' && isEmailLike) return false; + return true; + }); + + emit('searchContacts', { keys, query: value }); }; const handleDropdownUpdate = (type, value) => { diff --git a/app/services/contacts/filter_service.rb b/app/services/contacts/filter_service.rb index 9d017ea75..f951eb618 100644 --- a/app/services/contacts/filter_service.rb +++ b/app/services/contacts/filter_service.rb @@ -29,9 +29,8 @@ class Contacts::FilterService < FilterService end end - # TODO: @account.contacts.resolved_contacts ? to stay consistant with the behavior in ui def base_relation - @account.contacts + @account.contacts.resolved_contacts(use_crm_v2: @account.feature_enabled?('crm_v2')) end def filter_config diff --git a/spec/jobs/account/contacts_export_job_spec.rb b/spec/jobs/account/contacts_export_job_spec.rb index e6d3fda6a..9b9d74675 100644 --- a/spec/jobs/account/contacts_export_job_spec.rb +++ b/spec/jobs/account/contacts_export_job_spec.rb @@ -103,13 +103,11 @@ RSpec.describe Account::ContactsExportJob do expect(csv_data.length).to eq(1) end - # TODO: This returns unresolved contacts as well since filter service returns the same - # Change this when we make changes to filter service and ensure only resolved contacts are returned - it 'returns filtered data which inclues unresolved contacts when filter is provided' do + it 'returns filtered data limited to resolved contacts when filter is provided' do create(:contact, account: account, email: nil, phone_number: nil, additional_attributes: { :country_code => 'India' }) described_class.perform_now(account.id, user.id, [], { :payload => [city_filter.merge(:query_operator => nil)] }.with_indifferent_access) csv_data = CSV.parse(account.contacts_export.download, headers: true) - expect(csv_data.length).to eq(5) + expect(csv_data.length).to eq(4) end it 'returns filtered data when multiple filters are provided' do diff --git a/spec/services/contacts/filter_service_spec.rb b/spec/services/contacts/filter_service_spec.rb index 77d3a49f3..540f1dfd8 100644 --- a/spec/services/contacts/filter_service_spec.rb +++ b/spec/services/contacts/filter_service_spec.rb @@ -7,9 +7,25 @@ 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: { 'country_code': 'uk' }) } - let!(:el_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'gr' }) } - let!(:cs_contact) { create(:contact, :with_phone_number, account: account, additional_attributes: { 'country_code': 'cz' }) } + let!(:en_contact) do + create(:contact, + account: account, + email: Faker::Internet.unique.email, + additional_attributes: { 'country_code': 'uk' }) + end + let!(:el_contact) do + create(:contact, + account: account, + email: Faker::Internet.unique.email, + additional_attributes: { 'country_code': 'gr' }) + end + let!(:cs_contact) do + create(:contact, + :with_phone_number, + account: account, + email: Faker::Internet.unique.email, + additional_attributes: { 'country_code': 'cz' }) + end before do create(:inbox_member, user: first_user, inbox: inbox) @@ -103,7 +119,12 @@ describe Contacts::FilterService do context 'with standard attributes - blocked' do it 'filter contacts by blocked' do - blocked_contact = create(:contact, account: account, blocked: true) + blocked_contact = create( + :contact, + account: account, + blocked: true, + email: Faker::Internet.unique.email + ) params = { payload: [{ attribute_key: 'blocked', filter_operator: 'equal_to', values: ['true'], query_operator: nil }.with_indifferent_access] } result = filter_service.new(account, first_user, params).perform