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:
+22
@@ -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 {}
|
||||
+139
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+222
@@ -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}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user