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
@@ -0,0 +1,118 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { DataSource, Repository } from 'typeorm';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
type RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { SEARCH_FIELDS_FOR_PERSON } from 'src/modules/person/standard-objects/person.workspace-entity';
@Command({
name: 'upgrade:1-7:regenerate-person-search-vector-with-phones',
description:
'Regenerate person search vector to include phone number indexing for existing workspaces',
})
export class RegeneratePersonSearchVectorWithPhonesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(Workspace)
protected readonly workspaceRepository: Repository<Workspace>,
@InjectDataSource()
private readonly coreDataSource: DataSource,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
workspaceId,
}: RunOnWorkspaceArgs): Promise<void> {
const schemaName = getWorkspaceSchemaName(workspaceId);
this.logger.log(
`Regenerating person search vector for workspace ${workspaceId} in schema ${schemaName}`,
);
const personTableExists = await this.coreDataSource.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = '${schemaName}'
AND table_name = 'person'
);
`);
if (!personTableExists[0]?.exists) {
this.logger.log(
`Person table does not exist in workspace ${workspaceId}, skipping`,
);
return;
}
const searchVectorColumnExists = await this.coreDataSource.query(`
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = '${schemaName}'
AND table_name = 'person'
AND column_name = 'searchVector'
);
`);
if (!searchVectorColumnExists[0]?.exists) {
this.logger.log(
`searchVector column does not exist in workspace ${workspaceId}, skipping`,
);
return;
}
try {
const newSearchVectorExpression = getTsVectorColumnExpressionFromFields(
SEARCH_FIELDS_FOR_PERSON,
);
this.logger.log(
`Dropping existing searchVector column for workspace ${workspaceId}`,
);
await this.coreDataSource.query(`
ALTER TABLE "${schemaName}"."person"
DROP COLUMN "searchVector"
`);
this.logger.log(
`Creating new searchVector column with phone indexing for workspace ${workspaceId}`,
);
await this.coreDataSource.query(`
ALTER TABLE "${schemaName}"."person"
ADD COLUMN "searchVector" tsvector
GENERATED ALWAYS AS (${newSearchVectorExpression}) STORED
`);
this.logger.log(
`Recreating GIN index on searchVector for workspace ${workspaceId}`,
);
await this.coreDataSource.query(`
CREATE INDEX "IDX_person_searchVector"
ON "${schemaName}"."person"
USING GIN ("searchVector")
`);
this.logger.log(
`Successfully regenerated person search vector for workspace ${workspaceId}`,
);
} catch (error) {
this.logger.error(
`Failed to regenerate person search vector for workspace ${workspaceId}: ${error.message}`,
);
throw error;
}
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RegeneratePersonSearchVectorWithPhonesCommand } from 'src/database/commands/upgrade-version-command/1-7/1-7-regenerate-person-search-vector-with-phones.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
@Module({
imports: [TypeOrmModule.forFeature([Workspace]), WorkspaceDataSourceModule],
providers: [RegeneratePersonSearchVectorWithPhonesCommand],
exports: [RegeneratePersonSearchVectorWithPhonesCommand],
})
export class V1_7_UpgradeVersionCommandModule {}
@@ -8,6 +8,7 @@ import { V1_2_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
import { V1_3_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-3/1-3-upgrade-version-command.module';
import { V1_5_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-5/1-5-upgrade-version-command.module';
import { V1_6_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-6/1-6-upgrade-version-command.module';
import { V1_7_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-7/1-7-upgrade-version-command.module';
import { UpgradeCommand } from 'src/database/commands/upgrade-version-command/upgrade.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module';
@@ -22,6 +23,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp
V1_3_UpgradeVersionCommandModule,
V1_5_UpgradeVersionCommandModule,
V1_6_UpgradeVersionCommandModule,
V1_7_UpgradeVersionCommandModule,
WorkspaceSyncMetadataModule,
],
providers: [UpgradeCommand],