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:
Abdullah.
2025-09-25 23:18:49 +05:00
committed by GitHub
parent 1fdfac4f0d
commit 5a56d73fb2
10 changed files with 1623 additions and 1203 deletions
@@ -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');
});
});
@@ -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}), '')`;
}
@@ -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;
@@ -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;
}