Enables phone number search in the global search (Command Menu) for person records. (#14636)
## Changes Made - Added phone fields to search indexing: Extended searchable field types to include `FieldMetadataType.PHONES` - Updated person entity search configuration: Added `phones` to the fields indexed for person records - Enhanced search format support: Phone numbers are now indexed in multiple formats: - Raw number: `2071234567` - International with plus: `+442071234567` - International without plus: `442071234567` - Optimized for phone data: Removed unnecessary text processing (e.g. unaccenting) for numeric phone fields - Created workspace migration: New command to regenerate search vectors for existing workspaces ## Technical Details The implementation modifies PostgreSQL `tsvector` generation to index both `primaryPhoneNumber` and `primaryPhoneCallingCode` fields, combining them into international formats. This enables users to search phone numbers using the formats they naturally type. ### Modified Files - `is-searchable-field.util.ts` – Added `PHONES` to searchable types - `person.workspace-entity.ts` – Included `phones` in person search fields - `get-ts-vector-column-expression.util.ts` – Enhanced expression generation to support multiple phone number formats - `is-searchable-subfield.util.ts` – Added subfield filtering logic for phone fields ## Testing - **Unit tests**: Validated `tsvector` expression generation and phone-specific logic - **Integration tests**: Covered phone search scenarios across multiple formats ## Migration Includes the `upgrade:1-7:regenerate-person-search-vector-with-phones` command, which safely updates existing workspaces by dropping and recreating search vectors with phone indexing support. ## Note Frontend and Backend are both storing normalized phone numbers, as they should. The issue turned out to be with the seed file instead, which contained outdated records. I relied on the database as the source of truth without testing via the creation of a new record and it was an incorrect evaluation on my part. Note taken, I will be more comprehensive with my analysis from here on since I now understand I must check comprehensively before reaching a conclusion.
This commit is contained in:
+1200
-1200
File diff suppressed because it is too large
Load Diff
+40
@@ -12,6 +12,7 @@ const nameFullNameField = {
|
||||
};
|
||||
const jobTitleTextField = { name: 'jobTitle', type: FieldMetadataType.TEXT };
|
||||
const emailsEmailsField = { name: 'emails', type: FieldMetadataType.EMAILS };
|
||||
const phonesPhonesField = { name: 'phones', type: FieldMetadataType.PHONES };
|
||||
|
||||
describe('getTsVectorColumnExpressionFromFields', () => {
|
||||
it('should generate correct expression for simple text field', () => {
|
||||
@@ -69,4 +70,43 @@ describe('getTsVectorColumnExpressionFromFields', () => {
|
||||
"to_tsvector('simple', COALESCE(public.unaccent_immutable(\"bodyV2Markdown\"), ''))",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle phone fields without unaccenting', () => {
|
||||
const fields = [phonesPhonesField] as FieldTypeAndNameMetadata[];
|
||||
const result = getTsVectorColumnExpressionFromFields(fields);
|
||||
|
||||
expect(result).toContain('COALESCE("phonesPrimaryPhoneNumber", \'\')');
|
||||
expect(result).toContain('COALESCE("phonesPrimaryPhoneCallingCode", \'\')');
|
||||
expect(result).not.toContain('unaccent_immutable');
|
||||
});
|
||||
|
||||
it('should generate international format expressions for phone fields', () => {
|
||||
const fields = [phonesPhonesField] as FieldTypeAndNameMetadata[];
|
||||
const result = getTsVectorColumnExpressionFromFields(fields);
|
||||
|
||||
expect(result).toContain(
|
||||
'COALESCE("phonesPrimaryPhoneCallingCode" || "phonesPrimaryPhoneNumber", \'\')',
|
||||
);
|
||||
expect(result).toContain(
|
||||
"COALESCE(REPLACE(\"phonesPrimaryPhoneCallingCode\", '+', '') || \"phonesPrimaryPhoneNumber\", '')",
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate trunk prefix format expression for phone fields', () => {
|
||||
const fields = [phonesPhonesField] as FieldTypeAndNameMetadata[];
|
||||
const result = getTsVectorColumnExpressionFromFields(fields);
|
||||
|
||||
expect(result).toContain(
|
||||
"COALESCE('0' || \"phonesPrimaryPhoneNumber\", '')",
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly index phone subfields', () => {
|
||||
const fields = [phonesPhonesField] as FieldTypeAndNameMetadata[];
|
||||
const result = getTsVectorColumnExpressionFromFields(fields);
|
||||
|
||||
expect(result).toContain('phonesPrimaryPhoneNumber');
|
||||
expect(result).toContain('phonesPrimaryPhoneCallingCode');
|
||||
expect(result).not.toContain('phonesAdditionalPhones');
|
||||
});
|
||||
});
|
||||
|
||||
+19
-1
@@ -55,7 +55,7 @@ const getColumnExpressionsFromField = (
|
||||
);
|
||||
}
|
||||
|
||||
return compositeType.properties
|
||||
const baseExpressions = compositeType.properties
|
||||
.filter((property) =>
|
||||
isSearchableSubfield(compositeType.type, property.type, property.name),
|
||||
)
|
||||
@@ -67,6 +67,21 @@ const getColumnExpressionsFromField = (
|
||||
|
||||
return getColumnExpression(columnName, fieldMetadataTypeAndName.type);
|
||||
});
|
||||
|
||||
if (fieldMetadataTypeAndName.type === FieldMetadataType.PHONES) {
|
||||
const phoneNumberColumn = `"${fieldMetadataTypeAndName.name}PrimaryPhoneNumber"`;
|
||||
const callingCodeColumn = `"${fieldMetadataTypeAndName.name}PrimaryPhoneCallingCode"`;
|
||||
|
||||
const internationalFormats = [
|
||||
`COALESCE(${callingCodeColumn} || ${phoneNumberColumn}, '')`,
|
||||
`COALESCE(REPLACE(${callingCodeColumn}, '+', '') || ${phoneNumberColumn}, '')`,
|
||||
`COALESCE('0' || ${phoneNumberColumn}, '')`,
|
||||
];
|
||||
|
||||
return [...baseExpressions, ...internationalFormats];
|
||||
}
|
||||
|
||||
return baseExpressions;
|
||||
}
|
||||
const columnName = computeColumnName(fieldMetadataTypeAndName.name);
|
||||
|
||||
@@ -85,6 +100,9 @@ const getColumnExpression = (
|
||||
COALESCE(public.unaccent_immutable(${quotedColumnName}), '') || ' ' ||
|
||||
COALESCE(public.unaccent_immutable(SPLIT_PART(${quotedColumnName}, '@', 2)), '')`;
|
||||
|
||||
case FieldMetadataType.PHONES:
|
||||
return `COALESCE(${quotedColumnName}, '')`;
|
||||
|
||||
default:
|
||||
return `COALESCE(public.unaccent_immutable(${quotedColumnName}), '')`;
|
||||
}
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ const SEARCHABLE_FIELD_TYPES = [
|
||||
FieldMetadataType.EMAILS,
|
||||
FieldMetadataType.ADDRESS,
|
||||
FieldMetadataType.LINKS,
|
||||
FieldMetadataType.PHONES,
|
||||
FieldMetadataType.RICH_TEXT,
|
||||
FieldMetadataType.RICH_TEXT_V2,
|
||||
] as const;
|
||||
|
||||
+4
@@ -11,6 +11,10 @@ export const isSearchableSubfield = (
|
||||
switch (compositeFieldMetadataType) {
|
||||
case FieldMetadataType.RICH_TEXT_V2:
|
||||
return ['markdown'].includes(subFieldName);
|
||||
case FieldMetadataType.PHONES:
|
||||
return ['primaryPhoneNumber', 'primaryPhoneCallingCode'].includes(
|
||||
subFieldName,
|
||||
);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user