feat: Use resolved contacts as base relation for filtering (#12520)

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
This commit is contained in:
Shivam Mishra
2025-09-25 15:26:44 +05:30
committed by GitHub
parent f44e47a624
commit b75ea7a762
4 changed files with 42 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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