diff --git a/packages/twenty-server/src/engine/workspace-manager/utils/__tests__/get-ts-vectors-column-expression.utils.spec.ts b/packages/twenty-server/src/engine/workspace-manager/utils/__tests__/get-ts-vectors-column-expression.utils.spec.ts index 029736fdd5..336c73fa1c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/utils/__tests__/get-ts-vectors-column-expression.utils.spec.ts +++ b/packages/twenty-server/src/engine/workspace-manager/utils/__tests__/get-ts-vectors-column-expression.utils.spec.ts @@ -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); + }); }); }); diff --git a/packages/twenty-server/src/engine/workspace-manager/utils/get-ts-vector-column-expression.util.ts b/packages/twenty-server/src/engine/workspace-manager/utils/get-ts-vector-column-expression.util.ts index 5cbc0e73ff..0a96d787f9 100644 --- a/packages/twenty-server/src/engine/workspace-manager/utils/get-ts-vector-column-expression.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/utils/get-ts-vector-column-expression.util.ts @@ -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; diff --git a/packages/twenty-server/test/integration/constants/test-person-ids.constants.ts b/packages/twenty-server/test/integration/constants/test-person-ids.constants.ts index b79fdea2c9..c2e40a3488 100644 --- a/packages/twenty-server/test/integration/constants/test-person-ids.constants.ts +++ b/packages/twenty-server/test/integration/constants/test-person-ids.constants.ts @@ -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'; diff --git a/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts index 38ccd3ce48..7698569c15 100644 --- a/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts @@ -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))(