Fix phone unique constraints (#20261)

## Summary

Closes #20195

Fix phone field unique constraints so phone numbers are considered
unique by both `primaryPhoneNumber` and `primaryPhoneCallingCode`.

- Include `primaryPhoneCallingCode` in the shared phone composite unique
constraint metadata
- Align the frontend settings composite field config with the backend
metadata
- Return all included unique composite subfields when building
create-many conflict fields
- Match composite unique conflict fields as a group during create-many
upserts

## Root Cause

Phone composite metadata only marked `primaryPhoneNumber` as part of the
unique constraint. That made different international phone numbers with
the same national number conflict, for example `+1 123456789` and `+32
123456789`.

## Test Plan

- `yarn workspace twenty-shared build`
- `jest --runTestsByPath <index action handler and create-many utility
specs>`
- `prettier --check <touched files>`
- `oxlint --type-aware <touched files>`
- `nx run twenty-shared:typecheck`
- `nx run twenty-server:typecheck`
- `nx run twenty-front:typecheck`

---------

Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
MkDev11
2026-05-13 04:57:19 -07:00
committed by GitHub
parent ac653182b2
commit bbc55193f5
58 changed files with 1669 additions and 286 deletions
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { RebuildUniquePhoneIndexesCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000000000-rebuild-unique-phone-indexes.command';
import { NormalizeCompositeFieldDefaultsCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000001000-normalize-composite-field-defaults.command';
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [
WorkspaceCacheModule,
WorkspaceIteratorModule,
WorkspaceSchemaManagerModule,
WorkspaceMigrationModule,
],
providers: [
RebuildUniquePhoneIndexesCommand,
NormalizeCompositeFieldDefaultsCommand,
],
})
export class V2_5_UpgradeVersionCommandModule {}
@@ -0,0 +1,139 @@
import { Command } from 'nest-commander';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import {
createIndexInWorkspaceSchema,
dropIndexFromWorkspaceSchema,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/index-action-handler.utils';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
@RegisteredWorkspaceCommand('2.5.0', 1778000000000)
@Command({
name: 'upgrade:2-5:rebuild-unique-phone-indexes',
description:
'Rebuild unique phone field indexes to include the phone calling code column.',
})
export class RebuildUniquePhoneIndexesCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceSchemaManagerService: WorkspaceSchemaManagerService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
dataSource,
options,
}: RunOnWorkspaceArgs): Promise<void> {
if (!dataSource) {
this.logger.log(`No data source for workspace ${workspaceId}, skipping`);
return;
}
const { flatFieldMetadataMaps, flatIndexMaps, flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
'flatIndexMaps',
'flatObjectMetadataMaps',
]);
const uniquePhoneIndexes = Object.values(
flatIndexMaps.byUniversalIdentifier,
).filter((flatIndex): flatIndex is FlatIndexMetadata => {
if (!isDefined(flatIndex) || !flatIndex.isUnique) {
return false;
}
return flatIndex.flatIndexFieldMetadatas.some((indexField) => {
const relatedField = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: indexField.fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
return relatedField?.type === FieldMetadataType.PHONES;
});
});
if (uniquePhoneIndexes.length === 0) {
this.logger.log(
`No unique phone indexes found for workspace ${workspaceId}, skipping`,
);
return;
}
if (options.dryRun) {
this.logger.log(
`[DRY RUN] Would rebuild ${uniquePhoneIndexes.length} unique phone indexes for workspace ${workspaceId}: ${uniquePhoneIndexes
.map((index) => index.name)
.join(', ')}`,
);
return;
}
const schemaName = getWorkspaceSchemaName(workspaceId);
const queryRunner = dataSource.createQueryRunner();
let isQueryRunnerConnected = false;
let isTransactionStarted = false;
try {
await queryRunner.connect();
isQueryRunnerConnected = true;
await queryRunner.startTransaction();
isTransactionStarted = true;
for (const uniquePhoneIndex of uniquePhoneIndexes) {
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: uniquePhoneIndex.objectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
await dropIndexFromWorkspaceSchema({
indexName: uniquePhoneIndex.name,
workspaceSchemaManagerService: this.workspaceSchemaManagerService,
queryRunner,
schemaName,
});
await createIndexInWorkspaceSchema({
flatIndexMetadata: uniquePhoneIndex,
flatObjectMetadata,
flatFieldMetadataMaps,
workspaceSchemaManagerService: this.workspaceSchemaManagerService,
queryRunner,
workspaceId,
});
this.logger.log(
`Rebuilt unique phone index ${uniquePhoneIndex.name} for workspace ${workspaceId}`,
);
}
await queryRunner.commitTransaction();
} catch (error) {
if (isTransactionStarted) {
await queryRunner.rollbackTransaction();
}
throw error;
} finally {
if (isQueryRunnerConnected) {
await queryRunner.release();
}
}
}
}
@@ -0,0 +1,222 @@
import { Command } from 'nest-commander';
import {
FieldMetadataType,
compositeTypeDefinitions,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { nullifyEmptyCompositeDefaultValue } from 'src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-composite-default-value.util';
import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type';
@RegisteredWorkspaceCommand('2.5.0', 1778000001000)
@Command({
name: 'upgrade:2-5:normalize-composite-field-defaults',
description:
'Normalize composite field default values: remove empty-string values from metadata and backfill workspace data with NULL.',
})
export class NormalizeCompositeFieldDefaultsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
dataSource,
options,
}: RunOnWorkspaceArgs): Promise<void> {
if (!dataSource) {
this.logger.log(`No data source for workspace ${workspaceId}, skipping`);
return;
}
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
'flatObjectMetadataMaps',
]);
const affectedFields = Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((field) => isCompositeFieldMetadataType(field.type))
.filter((field) => {
const compositeType = compositeTypeDefinitions.get(
field.type as FieldMetadataType,
);
if (!isDefined(compositeType)) {
return false;
}
const normalizedDefaultValue = nullifyEmptyCompositeDefaultValue({
defaultValue: field.defaultValue,
fieldType: field.type as CompositeFieldMetadataType,
});
for (const property of compositeType.properties) {
if (
normalizedDefaultValue?.[
property.name as keyof typeof normalizedDefaultValue
] !==
field.defaultValue?.[
property.name as keyof typeof field.defaultValue
]
) {
return true;
}
}
});
if (affectedFields.length === 0) {
this.logger.log(
`No composite fields with non-null default values found for workspace ${workspaceId}, skipping`,
);
return;
}
if (options.dryRun) {
this.logger.log(
`[DRY RUN] Would normalize ${affectedFields.length} composite field(s) for workspace ${workspaceId}: ${affectedFields.map((f) => f.name).join(', ')}`,
);
return;
}
const schemaName = getWorkspaceSchemaName(workspaceId);
const backfillTargets: Array<{ tableName: string; columnName: string }> =
[];
for (const field of affectedFields) {
const flatObjectMetadata =
flatObjectMetadataMaps.byUniversalIdentifier[
field.objectMetadataUniversalIdentifier
];
if (!isDefined(flatObjectMetadata)) {
this.logger.warn(
`Object metadata not found for field ${field.name} (${field.id}), skipping data backfill for this field`,
);
continue;
}
const tableName = computeObjectTargetTable(flatObjectMetadata);
const compositeType = compositeTypeDefinitions.get(
field.type as FieldMetadataType,
);
if (!isDefined(compositeType)) {
continue;
}
const normalizedDefaultValue = nullifyEmptyCompositeDefaultValue({
defaultValue: field.defaultValue,
fieldType: field.type as CompositeFieldMetadataType,
});
for (const property of compositeType.properties) {
if (
normalizedDefaultValue?.[
property.name as keyof typeof normalizedDefaultValue
] !==
field.defaultValue?.[property.name as keyof typeof field.defaultValue]
) {
backfillTargets.push({
tableName,
columnName: computeCompositeColumnName(field.name, property),
});
}
}
}
const fieldsByApplication = affectedFields.reduce<
Map<string, typeof affectedFields>
>((acc, field) => {
const key = field.applicationUniversalIdentifier;
const group = acc.get(key) ?? [];
group.push(field);
acc.set(key, group);
return acc;
}, new Map());
for (const [
applicationUniversalIdentifier,
fields,
] of fieldsByApplication) {
const flatFieldMetadatasToUpdate = fields.map((field) => ({
...field,
defaultValue: nullifyEmptyCompositeDefaultValue({
defaultValue: field.defaultValue,
fieldType: field.type as CompositeFieldMetadataType,
}),
}));
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
fieldMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: flatFieldMetadatasToUpdate,
},
},
workspaceId,
isSystemBuild: true,
applicationUniversalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while normalizing composite field defaults',
);
}
for (const field of fields) {
this.logger.log(
`Normalized defaultValue for composite field "${field.name}" (${field.id}) in workspace ${workspaceId}`,
);
}
}
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'flatFieldMetadataMaps',
]);
for (const { tableName, columnName } of backfillTargets) {
await dataSource.query(
`UPDATE "${schemaName}"."${tableName}"
SET "${columnName}" = NULL
WHERE "${columnName}"::text IN ('', '""')`,
undefined,
undefined,
{ shouldBypassPermissionChecks: true },
);
this.logger.log(
`Backfilled NULL for "${schemaName}"."${tableName}"."${columnName}"`,
);
}
}
}
@@ -8,6 +8,7 @@ import { V2_1_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
import { V2_2_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-2/2-2-upgrade-version-command.module';
import { V2_3_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-3/2-3-upgrade-version-command.module';
import { V2_4_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-4/2-4-upgrade-version-command.module';
import { V2_5_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-5/2-5-upgrade-version-command.module';
@Module({
imports: [
@@ -19,6 +20,7 @@ import { V2_4_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
V2_2_UpgradeVersionCommandModule,
V2_3_UpgradeVersionCommandModule,
V2_4_UpgradeVersionCommandModule,
V2_5_UpgradeVersionCommandModule,
],
})
export class WorkspaceCommandProviderModule {}