fix(search): add support for searching by additional emails, phones, and secondary links (#17034)

## Summary
- Add `additionalEmails` (EMAILS type) to tsvector search expression
- Add `additionalPhones` (PHONES type) to tsvector search expression  
- Add `secondaryLinks` (LINKS type) to tsvector search expression

This enables searching for people/companies by their secondary contact
information, not just primary values.

## Test plan
- [x] Unit tests for all three composite field types (16 tests passing)
- [x] Integration tests for searching by secondary email, work email,
partial domain
- [x] Integration tests for searching by additional phone numbers
- [x] Integration test for searching by secondary link URL
- [x] Lint passes

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
salmonumbrella
2026-01-10 07:24:46 -08:00
committed by GitHub
parent 936ec06fe8
commit 20f62e05f5
4 changed files with 414 additions and 7 deletions
@@ -13,6 +13,7 @@ const nameFullNameField = {
const jobTitleTextField = { name: 'jobTitle', type: FieldMetadataType.TEXT };
const emailsEmailsField = { name: 'emails', type: FieldMetadataType.EMAILS };
const phonesPhonesField = { name: 'phones', type: FieldMetadataType.PHONES };
const linksLinksField = { name: 'domainName', type: FieldMetadataType.LINKS };
describe('getTsVectorColumnExpressionFromFields', () => {
it('should generate correct expression for simple text field', () => {
@@ -101,12 +102,121 @@ describe('getTsVectorColumnExpressionFromFields', () => {
);
});
it('should properly index phone subfields', () => {
it('should properly index phone subfields including additional phones', () => {
const fields = [phonesPhonesField] as FieldTypeAndNameMetadata[];
const result = getTsVectorColumnExpressionFromFields(fields);
expect(result).toContain('phonesPrimaryPhoneNumber');
expect(result).toContain('phonesPrimaryPhoneCallingCode');
expect(result).not.toContain('phonesAdditionalPhones');
expect(result).toContain('phonesAdditionalPhones');
expect(result).toContain(
"COALESCE(TRANSLATE(regexp_replace(\"phonesAdditionalPhones\"::text, '\"(number|countryCode|callingCode)\"\\s*:\\s*', '', 'g'), '[]{}\",:',",
);
});
it('should strip additional phone key names before indexing', () => {
const fields = [phonesPhonesField] as FieldTypeAndNameMetadata[];
const result = getTsVectorColumnExpressionFromFields(fields);
expect(result).toContain(
"regexp_replace(\"phonesAdditionalPhones\"::text, '\"(number|countryCode|callingCode)\"\\s*:\\s*', '', 'g')",
);
});
it('should include additional emails in search expression', () => {
const fields = [emailsEmailsField] as FieldTypeAndNameMetadata[];
const result = getTsVectorColumnExpressionFromFields(fields);
expect(result).toContain('emailsPrimaryEmail');
expect(result).toContain('emailsAdditionalEmails');
expect(result).toContain(
"COALESCE(public.unaccent_immutable(TRANSLATE(\"emailsAdditionalEmails\"::text, '[]\",', ' ')), '')",
);
expect(result).toContain(
"COALESCE(public.unaccent_immutable(TRANSLATE(REPLACE(\"emailsAdditionalEmails\"::text, '@', ' '), '[]\",', ' ')), '')",
);
});
it('should include secondary links in search expression for LINKS type', () => {
const fields = [linksLinksField] as FieldTypeAndNameMetadata[];
const result = getTsVectorColumnExpressionFromFields(fields);
expect(result).toContain('domainNamePrimaryLinkLabel');
expect(result).toContain('domainNamePrimaryLinkUrl');
expect(result).toContain('domainNameSecondaryLinks');
expect(result).toContain(
"COALESCE(public.unaccent_immutable(TRANSLATE(regexp_replace(\"domainNameSecondaryLinks\"::text, '\"(label|url)\"\\s*:\\s*', '', 'g'), '[]{}\",:',",
);
});
it('should strip secondary link key names before indexing', () => {
const fields = [linksLinksField] as FieldTypeAndNameMetadata[];
const result = getTsVectorColumnExpressionFromFields(fields);
expect(result).toContain(
"regexp_replace(\"domainNameSecondaryLinks\"::text, '\"(label|url)\"\\s*:\\s*', '', 'g')",
);
});
describe('NULL/empty JSON column handling', () => {
it('should wrap additionalEmails JSON column with COALESCE for NULL safety', () => {
const fields = [emailsEmailsField] as FieldTypeAndNameMetadata[];
const result = getTsVectorColumnExpressionFromFields(fields);
expect(result).toContain(
'COALESCE(public.unaccent_immutable(TRANSLATE("emailsAdditionalEmails"::text',
);
expect(result).toMatch(
/COALESCE\(public\.unaccent_immutable\(TRANSLATE\("emailsAdditionalEmails"::text.*\), ''\)/,
);
});
it('should wrap additionalPhones JSON column with COALESCE for NULL safety', () => {
const fields = [phonesPhonesField] as FieldTypeAndNameMetadata[];
const result = getTsVectorColumnExpressionFromFields(fields);
expect(result).toContain(
'COALESCE(TRANSLATE(regexp_replace("phonesAdditionalPhones"::text',
);
expect(result).toMatch(
/COALESCE\(TRANSLATE\(regexp_replace\("phonesAdditionalPhones"::text.*\), ''\)/,
);
});
it('should wrap secondaryLinks JSON column with COALESCE for NULL safety', () => {
const fields = [linksLinksField] as FieldTypeAndNameMetadata[];
const result = getTsVectorColumnExpressionFromFields(fields);
expect(result).toContain(
'COALESCE(public.unaccent_immutable(TRANSLATE(regexp_replace("domainNameSecondaryLinks"::text',
);
expect(result).toMatch(
/COALESCE\(public\.unaccent_immutable\(TRANSLATE\(regexp_replace\("domainNameSecondaryLinks"::text.*\), ''\)/,
);
});
it('should use empty string fallback for all JSON array columns', () => {
const fields = [
emailsEmailsField,
phonesPhonesField,
linksLinksField,
] as FieldTypeAndNameMetadata[];
const result = getTsVectorColumnExpressionFromFields(fields);
const additionalEmailsCoalesce = result.includes(
"COALESCE(public.unaccent_immutable(TRANSLATE(\"emailsAdditionalEmails\"::text, '[]\",', ' ')), '')",
);
const additionalPhonesCoalesce = result.includes(
"COALESCE(TRANSLATE(regexp_replace(\"phonesAdditionalPhones\"::text, '\"(number|countryCode|callingCode)\"\\s*:\\s*', '', 'g'), '[]{}\",:',",
);
const secondaryLinksCoalesce = result.includes(
"COALESCE(public.unaccent_immutable(TRANSLATE(regexp_replace(\"domainNameSecondaryLinks\"::text, '\"(label|url)\"\\s*:\\s*', '', 'g'), '[]{}\",:',",
);
expect(additionalEmailsCoalesce).toBe(true);
expect(additionalPhonesCoalesce).toBe(true);
expect(secondaryLinksCoalesce).toBe(true);
});
});
});
@@ -35,6 +35,7 @@ export const getTsVectorColumnExpressionFromFields = (
);
const concatenatedExpression = columnExpressions.join(" || ' ' || ");
// Note: changing this expression requires reindexing/backfilling existing searchVector values.
return `to_tsvector('simple', ${concatenatedExpression})`;
};
@@ -68,6 +69,7 @@ const getColumnExpressionsFromField = (
if (fieldMetadataTypeAndName.type === FieldMetadataType.PHONES) {
const phoneNumberColumn = `"${fieldMetadataTypeAndName.name}PrimaryPhoneNumber"`;
const callingCodeColumn = `"${fieldMetadataTypeAndName.name}PrimaryPhoneCallingCode"`;
const additionalPhonesColumn = `"${fieldMetadataTypeAndName.name}AdditionalPhones"`;
const internationalFormats = [
`COALESCE(${callingCodeColumn} || ${phoneNumberColumn}, '')`,
@@ -75,7 +77,29 @@ const getColumnExpressionsFromField = (
`COALESCE('0' || ${phoneNumberColumn}, '')`,
];
return [...baseExpressions, ...internationalFormats];
const additionalPhonesExpression = `COALESCE(TRANSLATE(regexp_replace(${additionalPhonesColumn}::text, '"(number|countryCode|callingCode)"\\s*:\\s*', '', 'g'), '[]{}",:', ' '), '')`;
return [
...baseExpressions,
...internationalFormats,
additionalPhonesExpression,
];
}
if (fieldMetadataTypeAndName.type === FieldMetadataType.LINKS) {
const secondaryLinksColumn = `"${fieldMetadataTypeAndName.name}SecondaryLinks"`;
const secondaryLinksExpression = `COALESCE(public.unaccent_immutable(TRANSLATE(regexp_replace(${secondaryLinksColumn}::text, '"(label|url)"\\s*:\\s*', '', 'g'), '[]{}",:', ' ')), '')`;
return [...baseExpressions, secondaryLinksExpression];
}
if (fieldMetadataTypeAndName.type === FieldMetadataType.EMAILS) {
const additionalEmailsColumn = `"${fieldMetadataTypeAndName.name}AdditionalEmails"`;
const additionalEmailsExpression = `COALESCE(public.unaccent_immutable(TRANSLATE(${additionalEmailsColumn}::text, '[]",', ' ')), '') || ' ' || COALESCE(public.unaccent_immutable(TRANSLATE(REPLACE(${additionalEmailsColumn}::text, '@', ' '), '[]",', ' ')), '')`;
return [...baseExpressions, additionalEmailsExpression];
}
return baseExpressions;
@@ -5,5 +5,7 @@ export const TEST_PERSON_4_ID = '777a8457-eb2d-40ac-a707-551b615b6983';
export const TEST_PERSON_5_ID = '777a8457-eb2d-40ac-a707-551b615b6984';
export const TEST_PERSON_6_ID = '777a8457-eb2d-40ac-a707-551b615b6985';
export const TEST_PERSON_7_ID = '777a8457-eb2d-40ac-a707-551b615b6986';
export const TEST_PERSON_8_ID = '777a8457-eb2d-40ac-a707-551b615b6987';
export const TEST_PERSON_9_ID = '777a8457-eb2d-40ac-a707-551b615b6988';
export const NOT_EXISTING_TEST_PERSON_ID =
'777a8457-eb2d-40ac-a707-551b615b6990';
@@ -13,6 +13,8 @@ import {
TEST_PERSON_5_ID,
TEST_PERSON_6_ID,
TEST_PERSON_7_ID,
TEST_PERSON_8_ID,
TEST_PERSON_9_ID,
} from 'test/integration/constants/test-person-ids.constants';
import {
TEST_PET_ID_1,
@@ -54,6 +56,7 @@ describe('SearchResolver', () => {
primaryPhoneNumber: '5551234567',
primaryPhoneCallingCode: '+1',
primaryPhoneCountryCode: 'US',
additionalPhones: [],
},
},
{ id: TEST_PERSON_3_ID, name: { firstName: 'searchInput3' } },
@@ -86,11 +89,55 @@ describe('SearchResolver', () => {
jobTitle: 'Manager',
emails: { primaryEmail: 'francois@naive.com' },
},
{
id: TEST_PERSON_8_ID,
name: { firstName: 'MultiEmail', lastName: 'Person' },
emails: {
primaryEmail: 'primary@example.com',
additionalEmails: ['secondary@example.com', 'work@company.org'],
},
},
{
id: TEST_PERSON_9_ID,
name: { firstName: 'MultiPhone', lastName: 'Person' },
emails: {
primaryEmail: 'empty@arrays.com',
additionalEmails: [],
},
phones: {
primaryPhoneNumber: '9998887777',
primaryPhoneCallingCode: '+1',
primaryPhoneCountryCode: 'US',
additionalPhones: [
{ number: '1112223333', countryCode: 'US', callingCode: '+1' },
{ number: '4445556666', countryCode: 'GB', callingCode: '+44' },
],
},
},
];
const companies = [
{ id: TEST_COMPANY_1_ID, name: 'Café Corp' },
{ id: TEST_COMPANY_2_ID, name: 'Naïve Solutions' },
{
id: TEST_COMPANY_1_ID,
name: 'Café Corp',
domainName: {
primaryLinkLabel: 'Main Site',
primaryLinkUrl: 'https://links.example.com',
secondaryLinks: [
{ label: 'DocsPortal', url: 'docs.links.example.com' },
{ label: 'SupportHub', url: 'support.links.example.com' },
],
},
},
{
id: TEST_COMPANY_2_ID,
name: 'Naïve Solutions',
domainName: {
primaryLinkLabel: 'NaivePortal',
primaryLinkUrl: 'https://naive.portal.example',
secondaryLinks: [],
},
},
];
const pets = [
@@ -108,6 +155,8 @@ describe('SearchResolver', () => {
francoisPerson,
josePersonNoAccent,
francoisPersonNoAccent,
multiEmailPerson,
multiPhonePerson,
] = persons;
const [cafeCorp, naiveCorp] = companies;
const [searchInput1Pet, searchInput2Pet, cafePet, naivePet] = pets;
@@ -177,6 +226,8 @@ describe('SearchResolver', () => {
francoisPerson.id,
josePersonNoAccent.id,
francoisPersonNoAccent.id,
multiEmailPerson.id,
multiPhonePerson.id,
naiveCorp.id,
cafeCorp.id,
searchInput1Pet.id,
@@ -189,7 +240,7 @@ describe('SearchResolver', () => {
decodedEndCursor: {
lastRanks: { tsRank: 0, tsRankCD: 0 },
lastRecordIdsPerObject: {
person: francoisPersonNoAccent.id,
person: multiPhonePerson.id,
company: cafeCorp.id,
pet: naivePet.id,
},
@@ -639,9 +690,9 @@ describe('SearchResolver', () => {
},
eval: {
orderedRecordIds: [
naiveCorp.id,
francoisPerson.id,
francoisPersonNoAccent.id,
naiveCorp.id,
naivePet.id,
],
pageInfo: {
@@ -905,6 +956,226 @@ describe('SearchResolver', () => {
},
},
},
{
title: 'should find person by additional email (secondary email)',
context: {
input: {
searchInput: 'secondary@example.com',
excludedObjectNameSingulars: ['workspaceMember'],
limit: 50,
},
eval: {
orderedRecordIds: [multiEmailPerson.id],
pageInfo: {
hasNextPage: false,
decodedEndCursor: {
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
person: multiEmailPerson.id,
},
},
},
},
},
},
{
title: 'should find person by additional email (work email)',
context: {
input: {
searchInput: 'work@company.org',
excludedObjectNameSingulars: ['workspaceMember'],
limit: 50,
},
eval: {
orderedRecordIds: [multiEmailPerson.id],
pageInfo: {
hasNextPage: false,
decodedEndCursor: {
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
person: multiEmailPerson.id,
},
},
},
},
},
},
{
title: 'should find person by partial additional email',
context: {
input: {
searchInput: 'company.org',
excludedObjectNameSingulars: ['workspaceMember'],
limit: 50,
},
eval: {
orderedRecordIds: [multiEmailPerson.id],
pageInfo: {
hasNextPage: false,
decodedEndCursor: {
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
person: multiEmailPerson.id,
},
},
},
},
},
},
{
title: 'should find person by additional phone number',
context: {
input: {
searchInput: '1112223333',
excludedObjectNameSingulars: ['workspaceMember'],
limit: 50,
},
eval: {
orderedRecordIds: [multiPhonePerson.id],
pageInfo: {
hasNextPage: false,
decodedEndCursor: {
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
person: multiPhonePerson.id,
},
},
},
},
},
},
{
title: 'should find person by second additional phone number',
context: {
input: {
searchInput: '4445556666',
excludedObjectNameSingulars: ['workspaceMember'],
limit: 50,
},
eval: {
orderedRecordIds: [multiPhonePerson.id],
pageInfo: {
hasNextPage: false,
decodedEndCursor: {
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
person: multiPhonePerson.id,
},
},
},
},
},
},
{
title: 'should find company by secondary link url',
context: {
input: {
searchInput: 'docs.links.example.com',
excludedObjectNameSingulars: ['workspaceMember'],
limit: 50,
},
eval: {
orderedRecordIds: [cafeCorp.id],
pageInfo: {
hasNextPage: false,
decodedEndCursor: {
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
company: cafeCorp.id,
},
},
},
},
},
},
{
title: 'should find company by secondary link label',
context: {
input: {
searchInput: 'DocsPortal',
excludedObjectNameSingulars: ['workspaceMember'],
limit: 50,
},
eval: {
orderedRecordIds: [cafeCorp.id],
pageInfo: {
hasNextPage: false,
decodedEndCursor: {
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
company: cafeCorp.id,
},
},
},
},
},
},
{
title: 'should handle empty additional emails array',
context: {
input: {
searchInput: 'empty@arrays.com',
excludedObjectNameSingulars: ['workspaceMember'],
limit: 50,
},
eval: {
orderedRecordIds: [multiPhonePerson.id],
pageInfo: {
hasNextPage: false,
decodedEndCursor: {
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
person: multiPhonePerson.id,
},
},
},
},
},
},
{
title: 'should handle empty additional phones array',
context: {
input: {
searchInput: '5551234567',
excludedObjectNameSingulars: ['workspaceMember'],
limit: 50,
},
eval: {
orderedRecordIds: [searchInput2Person.id],
pageInfo: {
hasNextPage: false,
decodedEndCursor: {
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
person: searchInput2Person.id,
},
},
},
},
},
},
{
title: 'should handle empty secondary links array',
context: {
input: {
searchInput: 'NaivePortal',
excludedObjectNameSingulars: ['workspaceMember'],
limit: 50,
},
eval: {
orderedRecordIds: [naiveCorp.id],
pageInfo: {
hasNextPage: false,
decodedEndCursor: {
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
company: naiveCorp.id,
},
},
},
},
},
},
];
it.each(eachTestingContextFilter(testsUseCases))(