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;