From bbc55193f5c0d1e46850cf8fb6edb7397fb42ab4 Mon Sep 17 00:00:00 2001 From: MkDev11 <94194147+MkDev11@users.noreply.github.com> Date: Wed, 13 May 2026 04:57:19 -0700 Subject: [PATCH] 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 ` - `prettier --check ` - `oxlint --type-aware ` - `nx run twenty-shared:typecheck` - `nx run twenty-server:typecheck` - `nx run twenty-front:typecheck` --------- Co-authored-by: mkdev11 Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> Co-authored-by: prastoin --- .../hooks/useFieldMetadataItem.ts | 1 + .../SettingsCompositeFieldTypeConfigs.ts | 4 +- .../2-5/2-5-upgrade-version-command.module.ts | 22 ++ ...00-rebuild-unique-phone-indexes.command.ts | 139 +++++++++++ ...malize-composite-field-defaults.command.ts | 222 ++++++++++++++++++ .../workspace-command-provider.module.ts | 2 + ...s-null-equivalent-text-field-value.util.ts | 7 +- ...common-create-many-query-runner.service.ts | 20 +- .../types/conflicting-field-group.type.ts | 9 + .../build-where-conditions.util.spec.ts | 69 ++++-- .../__tests__/categorize-records.util.spec.ts | 25 +- .../get-conflicting-fields.util.spec.ts | 79 ++++++- .../get-matching-record-id.util.spec.ts | 148 ++++++++++-- .../utils/build-where-conditions.util.ts | 17 +- .../utils/categorize-records.util.ts | 9 +- .../utils/get-conflicting-fields.util.ts | 42 ++-- .../utils/get-matching-record-id.util.ts | 61 +++-- .../deprecated-generate-default-value.ts | 61 ----- .../nullify-empty-actor-default-value.spec.ts | 30 +++ ...ullify-empty-address-default-value.spec.ts | 66 ++++++ ...llify-empty-currency-default-value.spec.ts | 30 +++ ...nullify-empty-emails-default-value.spec.ts | 21 ++ ...lify-empty-full-name-default-value.spec.ts | 15 ++ .../nullify-empty-links-default-value.spec.ts | 27 +++ ...nullify-empty-phones-default-value.spec.ts | 30 +++ ...lify-empty-rich-text-default-value.spec.ts | 15 ++ ...d-metadata-from-create-field-input.util.ts | 11 +- ...null-equivalent-text-default-value.util.ts | 2 + .../nullify-empty-actor-default-value.util.ts | 37 +++ ...ullify-empty-address-default-value.util.ts | 68 ++++++ ...lify-empty-composite-default-value.util.ts | 45 ++++ ...llify-empty-currency-default-value.util.ts | 28 +++ ...nullify-empty-emails-default-value.util.ts | 32 +++ ...lify-empty-full-name-default-value.util.ts | 30 +++ .../nullify-empty-links-default-value.util.ts | 40 ++++ ...nullify-empty-phones-default-value.util.ts | 56 +++++ ...lify-empty-rich-text-default-value.util.ts | 30 +++ .../utils/sanitize-raw-update-field-input.ts | 13 + .../types/entity-schema-metadata.type.ts | 2 + ...value-compatible-with-unique-index.util.ts | 42 ++++ .../flat-index-metadata-validator.service.ts | 27 ++- ...flat-index-field-column-names.util.spec.ts | 44 ++++ .../generate-column-definitions.util.spec.ts | 84 +++++++ .../utils/generate-column-definitions.util.ts | 10 +- ...field-null-equivalence.integration-spec.ts | 76 +++++- ...address-field-metadata.integration-spec.ts | 29 +-- ...urrency-field-metadata.integration-spec.ts | 5 +- ...-emails-field-metadata.integration-spec.ts | 5 +- ...ll-name-field-metadata.integration-spec.ts | 7 +- ...e-links-field-metadata.integration-spec.ts | 6 +- ...ch-text-field-metadata.integration-spec.ts | 5 +- ...address-field-metadata.integration-spec.ts | 18 +- ...urrency-field-metadata.integration-spec.ts | 5 +- ...-emails-field-metadata.integration-spec.ts | 5 +- ...ll-name-field-metadata.integration-spec.ts | 7 +- ...e-links-field-metadata.integration-spec.ts | 6 +- ...-phones-field-metadata.integration-spec.ts | 7 +- .../composite-types/phones.composite-type.ts | 2 + 58 files changed, 1669 insertions(+), 286 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-upgrade-version-command.module.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000000000-rebuild-unique-phone-indexes.command.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000001000-normalize-composite-field-defaults.command.ts create mode 100644 packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/deprecated-generate-default-value.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-actor-default-value.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-address-default-value.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-currency-default-value.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-emails-default-value.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-full-name-default-value.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-links-default-value.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-phones-default-value.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-rich-text-default-value.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/is-null-equivalent-text-default-value.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-actor-default-value.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-address-default-value.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-composite-default-value.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-currency-default-value.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-emails-default-value.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-full-name-default-value.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-links-default-value.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-phones-default-value.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-rich-text-default-value.util.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/utils/is-composite-field-default-value-compatible-with-unique-index.util.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/__tests__/compute-flat-index-field-column-names.util.spec.ts diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useFieldMetadataItem.ts index 92e50b56da..4e543d49b0 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useFieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useFieldMetadataItem.ts @@ -24,6 +24,7 @@ export const useFieldMetadataItem = () => { | 'options' | 'settings' | 'isLabelSyncedWithName' + | 'isUnique' > & { objectMetadataId: string; relationCreationPayload?: RelationCreationPayload; diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts index 7cd02af8cc..0d326c0a00 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts @@ -209,7 +209,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = { .primaryPhoneCallingCode, isImportable: true, isFilterable: true, - isIncludedInUniqueConstraint: false, + isIncludedInUniqueConstraint: true, }, { subFieldName: @@ -220,7 +220,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = { .primaryPhoneCountryCode, isImportable: true, isFilterable: false, - isIncludedInUniqueConstraint: false, + isIncludedInUniqueConstraint: true, }, { subFieldName: diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-upgrade-version-command.module.ts new file mode 100644 index 0000000000..6f29e4466b --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-upgrade-version-command.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000000000-rebuild-unique-phone-indexes.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000000000-rebuild-unique-phone-indexes.command.ts new file mode 100644 index 0000000000..9b6cdca5df --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000000000-rebuild-unique-phone-indexes.command.ts @@ -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 { + 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(); + } + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000001000-normalize-composite-field-defaults.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000001000-normalize-composite-field-defaults.command.ts new file mode 100644 index 0000000000..414fffdda0 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000001000-normalize-composite-field-defaults.command.ts @@ -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 { + 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 + >((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}"`, + ); + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts index 41d77961cf..16a3dac032 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-text-field-value.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-text-field-value.util.ts index 326025a4d5..57b04773d6 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-text-field-value.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-text-field-value.util.ts @@ -1,12 +1,9 @@ -import { isNull } from '@sniptt/guards'; +import { isNull, isString } from '@sniptt/guards'; import { DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE } from 'src/engine/api/common/common-args-processors/data-arg-processor/constants/null-equivalent-values.constant'; export const isNullEquivalentTextFieldValue = (value: unknown): boolean => { if (isNull(value)) return true; - return ( - typeof value === 'string' && - value === DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE - ); + return isString(value) && value === DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE; }; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts index cd1dbc3932..baa70b864f 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts @@ -8,6 +8,7 @@ import { FindOptionsRelations, In, InsertResult, ObjectLiteral } from 'typeorm'; import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service'; import { PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type'; +import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type'; import { buildWhereConditions } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util'; import { categorizeRecords } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/categorize-records.util'; import { getConflictingFields } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util'; @@ -231,7 +232,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer args: CreateManyQueryArgs; selectedFieldsResult: CommonSelectedFieldsResult; }): Promise { - const conflictingFields = getConflictingFields( + const conflictingFieldGroups = getConflictingFields( flatObjectMetadata, flatFieldMetadataMaps, ); @@ -240,12 +241,12 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer flatObjectMetadata, flatFieldMetadataMaps, args, - conflictingFields, + conflictingFieldGroups, }); const { recordsToUpdate, recordsToInsert } = categorizeRecords( args.data, - conflictingFields, + conflictingFieldGroups, existingRecords, ); @@ -289,23 +290,22 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer flatObjectMetadata, flatFieldMetadataMaps, args, - conflictingFields, + conflictingFieldGroups, }: { repository: WorkspaceRepository; flatObjectMetadata: FlatObjectMetadata; flatFieldMetadataMaps: FlatEntityMaps; args: CreateManyQueryArgs; - conflictingFields: { - baseField: string; - fullPath: string; - column: string; - }[]; + conflictingFieldGroups: ConflictingFieldGroup[]; }): Promise { const queryBuilder = repository.createQueryBuilder( flatObjectMetadata.nameSingular, ); - const whereConditions = buildWhereConditions(args.data, conflictingFields); + const whereConditions = buildWhereConditions( + args.data, + conflictingFieldGroups, + ); if (whereConditions.length === 0) { return []; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type.ts new file mode 100644 index 0000000000..43263204f7 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type.ts @@ -0,0 +1,9 @@ +export type ConflictingProperty = { + fullPath: string; + column: string; +}; + +export type ConflictingFieldGroup = { + baseField: string; + conflictingProperties: ConflictingProperty[]; +}; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/build-where-conditions.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/build-where-conditions.util.spec.ts index 892e82e3be..058c569c26 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/build-where-conditions.util.spec.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/build-where-conditions.util.spec.ts @@ -1,5 +1,6 @@ import { type ObjectRecord } from 'twenty-shared/types'; +import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type'; import { buildWhereConditions } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util'; describe('buildWhereConditions', () => { @@ -28,9 +29,16 @@ describe('buildWhereConditions', () => { }); it('builds a single where condition for a flat field using all defined values', () => { - const where = buildWhereConditions(records, [ - { baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' }, - ]); + const groups: ConflictingFieldGroup[] = [ + { + baseField: 'uniqueText', + conflictingProperties: [ + { fullPath: 'uniqueText', column: 'uniqueText' }, + ], + }, + ]; + + const where = buildWhereConditions(records, groups); expect(where).toHaveLength(1); const condition = where[0]; @@ -44,28 +52,34 @@ describe('buildWhereConditions', () => { }); it('skips adding a condition when all values for a field are undefined', () => { - const where = buildWhereConditions( - [{ id: '1' }, { id: '2' }], - [ - { - baseField: 'uniqueText', - fullPath: 'uniqueText', - column: 'uniqueText', - }, - ], - ); + const groups: ConflictingFieldGroup[] = [ + { + baseField: 'uniqueText', + conflictingProperties: [ + { fullPath: 'uniqueText', column: 'uniqueText' }, + ], + }, + ]; + + const where = buildWhereConditions([{ id: '1' }, { id: '2' }], groups); expect(where).toEqual([]); }); it('builds conditions for nested paths', () => { - const where = buildWhereConditions(records, [ + const groups: ConflictingFieldGroup[] = [ { baseField: 'emailsField', - fullPath: 'emailsField.primaryEmail', - column: 'emailsFieldPrimaryEmail', + conflictingProperties: [ + { + fullPath: 'emailsField.primaryEmail', + column: 'emailsFieldPrimaryEmail', + }, + ], }, - ]); + ]; + + const where = buildWhereConditions(records, groups); expect(where).toHaveLength(1); const condition = where[0]; @@ -79,14 +93,25 @@ describe('buildWhereConditions', () => { }); it('builds multiple conditions when multiple conflicting fields are provided', () => { - const where = buildWhereConditions(records, [ - { baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' }, + const groups: ConflictingFieldGroup[] = [ + { + baseField: 'uniqueText', + conflictingProperties: [ + { fullPath: 'uniqueText', column: 'uniqueText' }, + ], + }, { baseField: 'emailsField', - fullPath: 'emailsField.primaryEmail', - column: 'emailsFieldPrimaryEmail', + conflictingProperties: [ + { + fullPath: 'emailsField.primaryEmail', + column: 'emailsFieldPrimaryEmail', + }, + ], }, - ]); + ]; + + const where = buildWhereConditions(records, groups); expect(where).toHaveLength(2); diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/categorize-records.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/categorize-records.util.spec.ts index b739e4d768..c266e7caa8 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/categorize-records.util.spec.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/categorize-records.util.spec.ts @@ -1,20 +1,27 @@ import { type ObjectRecord } from 'twenty-shared/types'; +import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type'; import { type PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type'; import { categorizeRecords } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/categorize-records.util'; describe('categorizeRecords', () => { - const conflictingFields = [ - { baseField: 'id', fullPath: 'id', column: 'id' }, + const conflictingFieldGroups: ConflictingFieldGroup[] = [ + { + baseField: 'id', + conflictingProperties: [{ fullPath: 'id', column: 'id' }], + }, { baseField: 'uniqueText', - fullPath: 'uniqueText', - column: 'uniqueText', + conflictingProperties: [{ fullPath: 'uniqueText', column: 'uniqueText' }], }, { baseField: 'emailsField', - fullPath: 'emailsField.primaryEmail', - column: 'emailsFieldPrimaryEmail', + conflictingProperties: [ + { + fullPath: 'emailsField.primaryEmail', + column: 'emailsFieldPrimaryEmail', + }, + ], }, ]; @@ -39,7 +46,7 @@ describe('categorizeRecords', () => { const { recordsToInsert, recordsToUpdate } = categorizeRecords( records, - conflictingFields, + conflictingFieldGroups, existingRecords, ); @@ -56,7 +63,7 @@ describe('categorizeRecords', () => { const { recordsToInsert, recordsToUpdate } = categorizeRecords( records, - conflictingFields, + conflictingFieldGroups, existingRecords, ); @@ -88,7 +95,7 @@ describe('categorizeRecords', () => { const { recordsToInsert, recordsToUpdate } = categorizeRecords( records, - conflictingFields, + conflictingFieldGroups, existingRecords, ); diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-conflicting-fields.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-conflicting-fields.util.spec.ts index 9d72bc0e65..c4b9adb7a9 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-conflicting-fields.util.spec.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-conflicting-fields.util.spec.ts @@ -54,6 +54,13 @@ describe('getConflictingFields', () => { isUnique: true, }); + const phonesUniqueField = createMockField({ + id: 'phones-unique-id', + name: 'phonesField', + type: FieldMetadataType.PHONES, + isUnique: true, + }); + const phonesNotUniqueField = createMockField({ id: 'phones-not-unique-id', name: 'phonesField', @@ -125,11 +132,15 @@ describe('getConflictingFields', () => { expect(result).toEqual( expect.arrayContaining([ - { baseField: 'id', fullPath: 'id', column: 'id' }, + { + baseField: 'id', + conflictingProperties: [{ fullPath: 'id', column: 'id' }], + }, { baseField: 'uniqueText', - fullPath: 'uniqueText', - column: 'uniqueText', + conflictingProperties: [ + { fullPath: 'uniqueText', column: 'uniqueText' }, + ], }, ]), ); @@ -147,16 +158,58 @@ describe('getConflictingFields', () => { expect(result).toEqual( expect.arrayContaining([ - { baseField: 'id', fullPath: 'id', column: 'id' }, + { + baseField: 'id', + conflictingProperties: [{ fullPath: 'id', column: 'id' }], + }, { baseField: 'emailsField', - fullPath: 'emailsField.primaryEmail', - column: 'emailsFieldPrimaryEmail', + conflictingProperties: [ + { + fullPath: 'emailsField.primaryEmail', + column: 'emailsFieldPrimaryEmail', + }, + ], }, ]), ); }); + it('returns every included unique property for phone composite fields', () => { + const fields = [idField, phonesUniqueField]; + const flatObjectMetadata = buildFlatObjectMetadata(fields); + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fields); + + const result = getConflictingFields( + flatObjectMetadata, + flatFieldMetadataMaps, + ); + + expect(result).toEqual([ + { + baseField: 'id', + conflictingProperties: [{ fullPath: 'id', column: 'id' }], + }, + { + baseField: 'phonesField', + conflictingProperties: [ + { + fullPath: 'phonesField.primaryPhoneNumber', + column: 'phonesFieldPrimaryPhoneNumber', + }, + { + fullPath: 'phonesField.primaryPhoneCountryCode', + column: 'phonesFieldPrimaryPhoneCountryCode', + }, + { + fullPath: 'phonesField.primaryPhoneCallingCode', + column: 'phonesFieldPrimaryPhoneCallingCode', + }, + ], + }, + ]); + }); + it('does not include composite fields without included unique property', () => { const fields = [idField, addressUniqueFieldNoIncludedProp]; const flatObjectMetadata = buildFlatObjectMetadata(fields); @@ -167,7 +220,12 @@ describe('getConflictingFields', () => { flatFieldMetadataMaps, ); - expect(result).toEqual([{ baseField: 'id', fullPath: 'id', column: 'id' }]); + expect(result).toEqual([ + { + baseField: 'id', + conflictingProperties: [{ fullPath: 'id', column: 'id' }], + }, + ]); }); it('ignores non-unique fields', () => { @@ -180,6 +238,11 @@ describe('getConflictingFields', () => { flatFieldMetadataMaps, ); - expect(result).toEqual([{ baseField: 'id', fullPath: 'id', column: 'id' }]); + expect(result).toEqual([ + { + baseField: 'id', + conflictingProperties: [{ fullPath: 'id', column: 'id' }], + }, + ]); }); }); diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-matching-record-id.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-matching-record-id.util.spec.ts index 7c0b5d7b1c..9ee5687ad7 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-matching-record-id.util.spec.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-matching-record-id.util.spec.ts @@ -1,3 +1,4 @@ +import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type'; import { type PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type'; import { getMatchingRecordId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util'; import { CommonQueryRunnerExceptionCode } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; @@ -8,11 +9,19 @@ describe('getMatchingRecordId', () => { id: 'recordId1', uniqueText: 'alpha', emailsField: { primaryEmail: 'alpha@example.com' }, + phonesField: { + primaryPhoneNumber: '123456789', + primaryPhoneCallingCode: '+1', + }, }, { id: 'recordId2', uniqueText: 'beta', emailsField: { primaryEmail: 'beta@example.com' }, + phonesField: { + primaryPhoneNumber: '123456789', + primaryPhoneCallingCode: '+32', + }, }, ]; @@ -21,33 +30,115 @@ describe('getMatchingRecordId', () => { emailsField: { primaryEmail: 'alpha@example.com' }, }; - const conflictingFields = [ + const conflictingFieldGroups: ConflictingFieldGroup[] = [ { baseField: 'emailsField', - fullPath: 'emailsField.primaryEmail', - column: 'emailsFieldPrimaryEmail', + conflictingProperties: [ + { + fullPath: 'emailsField.primaryEmail', + column: 'emailsFieldPrimaryEmail', + }, + ], }, ]; - const id = getMatchingRecordId(record, conflictingFields, existingRecords); + const id = getMatchingRecordId( + record, + conflictingFieldGroups, + existingRecords, + ); expect(id).toBe('recordId1'); }); + it('returns the matching record id when every composite unique field matches the same existing record', () => { + const record = { + phonesField: { + primaryPhoneNumber: '123456789', + primaryPhoneCallingCode: '+32', + }, + }; + + const conflictingFieldGroups: ConflictingFieldGroup[] = [ + { + baseField: 'phonesField', + conflictingProperties: [ + { + fullPath: 'phonesField.primaryPhoneNumber', + column: 'phonesFieldPrimaryPhoneNumber', + }, + { + fullPath: 'phonesField.primaryPhoneCallingCode', + column: 'phonesFieldPrimaryPhoneCallingCode', + }, + ], + }, + ]; + + const id = getMatchingRecordId( + record, + conflictingFieldGroups, + existingRecords, + ); + + expect(id).toBe('recordId2'); + }); + + it('returns undefined when only part of a composite unique field matches', () => { + const record = { + phonesField: { + primaryPhoneNumber: '123456789', + primaryPhoneCallingCode: '+33', + }, + }; + + const conflictingFieldGroups: ConflictingFieldGroup[] = [ + { + baseField: 'phonesField', + conflictingProperties: [ + { + fullPath: 'phonesField.primaryPhoneNumber', + column: 'phonesFieldPrimaryPhoneNumber', + }, + { + fullPath: 'phonesField.primaryPhoneCallingCode', + column: 'phonesFieldPrimaryPhoneCallingCode', + }, + ], + }, + ]; + + const id = getMatchingRecordId( + record, + conflictingFieldGroups, + existingRecords, + ); + + expect(id).toBeUndefined(); + }); + it('returns undefined when no existing record matches any conflicting field', () => { const record = { emailsField: { primaryEmail: 'nobody@example.com' }, }; - const conflictingFields = [ + const conflictingFieldGroups: ConflictingFieldGroup[] = [ { baseField: 'emailsField', - fullPath: 'emailsField.primaryEmail', - column: 'emailsFieldPrimaryEmail', + conflictingProperties: [ + { + fullPath: 'emailsField.primaryEmail', + column: 'emailsFieldPrimaryEmail', + }, + ], }, ]; - const id = getMatchingRecordId(record, conflictingFields, existingRecords); + const id = getMatchingRecordId( + record, + conflictingFieldGroups, + existingRecords, + ); expect(id).toBeUndefined(); }); @@ -58,12 +149,24 @@ describe('getMatchingRecordId', () => { uniqueText: 'alpha', }; - const conflictingFields = [ - { baseField: 'id', fullPath: 'id', column: 'id' }, - { baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' }, + const conflictingFieldGroups: ConflictingFieldGroup[] = [ + { + baseField: 'id', + conflictingProperties: [{ fullPath: 'id', column: 'id' }], + }, + { + baseField: 'uniqueText', + conflictingProperties: [ + { fullPath: 'uniqueText', column: 'uniqueText' }, + ], + }, ]; - const id = getMatchingRecordId(record, conflictingFields, existingRecords); + const id = getMatchingRecordId( + record, + conflictingFieldGroups, + existingRecords, + ); expect(id).toBe('recordId1'); }); @@ -74,21 +177,30 @@ describe('getMatchingRecordId', () => { emailsField: { primaryEmail: 'beta@example.com' }, }; - const conflictingFields = [ - { baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' }, + const conflictingFieldGroups: ConflictingFieldGroup[] = [ + { + baseField: 'uniqueText', + conflictingProperties: [ + { fullPath: 'uniqueText', column: 'uniqueText' }, + ], + }, { baseField: 'emailsField', - fullPath: 'emailsField.primaryEmail', - column: 'emailsFieldPrimaryEmail', + conflictingProperties: [ + { + fullPath: 'emailsField.primaryEmail', + column: 'emailsFieldPrimaryEmail', + }, + ], }, ]; expect(() => - getMatchingRecordId(record, conflictingFields, existingRecords), + getMatchingRecordId(record, conflictingFieldGroups, existingRecords), ).toThrow(); try { - getMatchingRecordId(record, conflictingFields, existingRecords); + getMatchingRecordId(record, conflictingFieldGroups, existingRecords); } catch (error) { expect(error.code).toBe( CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT, diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util.ts index 8829053968..4ffcab0ee6 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util.ts @@ -2,25 +2,26 @@ import { type ObjectRecord } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { type FindOperator, In } from 'typeorm'; +import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type'; import { getValueFromPath } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-value-from-path.util'; export const buildWhereConditions = ( records: Partial[], - conflictingFields: { - baseField: string; - fullPath: string; - column: string; - }[], + conflictingFieldGroups: ConflictingFieldGroup[], ): Record>[] => { const whereConditions: Record>[] = []; - for (const field of conflictingFields) { + for (const conflictingProperty of conflictingFieldGroups.flatMap( + (group) => group.conflictingProperties, + )) { const fieldValues = records - .map((record) => getValueFromPath(record, field.fullPath)) + .map((record) => getValueFromPath(record, conflictingProperty.fullPath)) .filter(isDefined); if (fieldValues.length > 0) { - whereConditions.push({ [field.column]: In(fieldValues) }); + whereConditions.push({ + [conflictingProperty.column]: In(fieldValues), + }); } } diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/categorize-records.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/categorize-records.util.ts index e809c5b17a..bba1a705ab 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/categorize-records.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/categorize-records.util.ts @@ -1,16 +1,13 @@ import { type ObjectRecord } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type'; import { type PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type'; import { getMatchingRecordId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util'; export const categorizeRecords = ( records: Partial[], - conflictingFields: { - baseField: string; - fullPath: string; - column: string; - }[], + conflictingFieldGroups: ConflictingFieldGroup[], existingRecords: PartialObjectRecordWithId[], ): { recordsToUpdate: PartialObjectRecordWithId[]; @@ -22,7 +19,7 @@ export const categorizeRecords = ( for (const record of records) { const matchingRecordId = getMatchingRecordId( record, - conflictingFields, + conflictingFieldGroups, existingRecords, ); diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util.ts index 4207ef12da..9d4d9f7005 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util.ts @@ -1,6 +1,7 @@ import { compositeTypeDefinitions } from 'twenty-shared/types'; import { capitalize } from 'twenty-shared/utils'; +import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type'; import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; @@ -9,41 +10,30 @@ import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object export const getConflictingFields = ( flatObjectMetadata: FlatObjectMetadata, flatFieldMetadataMaps: FlatEntityMaps, -): { - baseField: string; - fullPath: string; - column: string; -}[] => { +): ConflictingFieldGroup[] => { return getFlatFieldsFromFlatObjectMetadata( flatObjectMetadata, flatFieldMetadataMaps, ) .filter((field) => field.isUnique || field.name === 'id') - .flatMap((field) => { + .map((field) => { const compositeType = compositeTypeDefinitions.get(field.type); if (!compositeType) { - return [ - { - baseField: field.name, - fullPath: field.name, - column: field.name, - }, - ]; + return { + baseField: field.name, + conflictingProperties: [{ fullPath: field.name, column: field.name }], + }; } - const property = compositeType.properties.find( - (prop) => prop.isIncludedInUniqueConstraint, - ); + const conflictingProperties = compositeType.properties + .filter((prop) => prop.isIncludedInUniqueConstraint) + .map((property) => ({ + fullPath: `${field.name}.${property.name}`, + column: `${field.name}${capitalize(property.name)}`, + })); - return property - ? [ - { - baseField: field.name, - fullPath: `${field.name}.${property.name}`, - column: `${field.name}${capitalize(property.name)}`, - }, - ] - : []; - }); + return { baseField: field.name, conflictingProperties }; + }) + .filter((group) => group.conflictingProperties.length > 0); }; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util.ts index ebd5855902..2f587af82d 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util.ts @@ -2,6 +2,7 @@ import { msg } from '@lingui/core/macro'; import { type ObjectRecord } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type'; import { type PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type'; import { getValueFromPath } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-value-from-path.util'; import { @@ -11,41 +12,51 @@ import { export const getMatchingRecordId = ( record: Partial, - conflictingFields: { - baseField: string; - fullPath: string; - column: string; - }[], + conflictingFieldGroups: ConflictingFieldGroup[], existingRecords: PartialObjectRecordWithId[], ): string | undefined => { - const matchingRecordIds = conflictingFields.reduce((acc, field) => { - const requestFieldValue = getValueFromPath(record, field.fullPath); - - const matchingRecord = existingRecords.find((existingRecord) => { - const existingFieldValue = getValueFromPath( - existingRecord, - field.fullPath, + const matchingRecordIds = conflictingFieldGroups.reduce( + (acc, fieldGroup) => { + const requestFieldValues = fieldGroup.conflictingProperties.map( + (conflictingProperty) => ({ + conflictingProperty, + value: getValueFromPath(record, conflictingProperty.fullPath), + }), ); - return ( - isDefined(existingFieldValue) && - existingFieldValue === requestFieldValue + if (requestFieldValues.some(({ value }) => !isDefined(value))) { + return acc; + } + + const matchingRecord = existingRecords.find((existingRecord) => + requestFieldValues.every(({ conflictingProperty, value }) => { + const existingFieldValue = getValueFromPath( + existingRecord, + conflictingProperty.fullPath, + ); + + return isDefined(existingFieldValue) && existingFieldValue === value; + }), ); - }); - if (isDefined(matchingRecord)) { - acc.push(matchingRecord.id); - } + if (isDefined(matchingRecord)) { + acc.push(matchingRecord.id); + } - return acc; - }, []); + return acc; + }, + [], + ); if ([...new Set(matchingRecordIds)].length > 1) { - const conflictingFieldsValues = conflictingFields - .map((field) => { - const value = getValueFromPath(record, field.fullPath); + const conflictingFieldsValues = conflictingFieldGroups + .flatMap((group) => group.conflictingProperties) + .map((conflictingProperty) => { + const value = getValueFromPath(record, conflictingProperty.fullPath); - return isDefined(value) ? `${field.fullPath}: ${value}` : undefined; + return isDefined(value) + ? `${conflictingProperty.fullPath}: ${value}` + : undefined; }) .filter(isDefined) .join(', '); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/deprecated-generate-default-value.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/deprecated-generate-default-value.ts deleted file mode 100644 index 3ba64e4253..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/deprecated-generate-default-value.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - FieldActorSource, - type FieldMetadataDefaultValue, - FieldMetadataType, -} from 'twenty-shared/types'; - -export function deprecatedGenerateDefaultValue( - type: FieldMetadataType, -): FieldMetadataDefaultValue { - switch (type) { - case FieldMetadataType.TEXT: - return "''" satisfies FieldMetadataDefaultValue; - case FieldMetadataType.EMAILS: - return { - primaryEmail: "''", - additionalEmails: null, - } satisfies FieldMetadataDefaultValue; - case FieldMetadataType.FULL_NAME: - return { - firstName: "''", - lastName: "''", - } satisfies FieldMetadataDefaultValue; - case FieldMetadataType.ADDRESS: - return { - addressStreet1: "''", - addressStreet2: "''", - addressCity: "''", - addressState: "''", - addressCountry: "''", - addressPostcode: "''", - addressLat: null, - addressLng: null, - } satisfies FieldMetadataDefaultValue; - case FieldMetadataType.CURRENCY: - return { - amountMicros: null, - currencyCode: "''", - } satisfies FieldMetadataDefaultValue; - case FieldMetadataType.LINKS: - return { - primaryLinkLabel: "''", - primaryLinkUrl: "''", - secondaryLinks: null, - } satisfies FieldMetadataDefaultValue; - case FieldMetadataType.PHONES: - return { - primaryPhoneNumber: "''", - primaryPhoneCountryCode: "''", - primaryPhoneCallingCode: "''", - additionalPhones: null, - } satisfies FieldMetadataDefaultValue; - case FieldMetadataType.ACTOR: - return { - source: `'${FieldActorSource.MANUAL}'`, - name: "'System'", - workspaceMemberId: null, - } satisfies FieldMetadataDefaultValue; - default: - return null; - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-actor-default-value.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-actor-default-value.spec.ts new file mode 100644 index 0000000000..9efb46b000 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-actor-default-value.spec.ts @@ -0,0 +1,30 @@ +import { nullifyEmptyActorDefaultValue } from '../nullify-empty-actor-default-value.util'; + +describe('nullifyEmptyActorDefaultValue', () => { + it('returns null when all sub-fields are null or empty-string equivalents', () => { + expect( + nullifyEmptyActorDefaultValue({ + source: null, + workspaceMemberId: null, + name: "''", + context: null, + }), + ).toBeNull(); + }); + + it('returns normalized object when source has a value', () => { + expect( + nullifyEmptyActorDefaultValue({ + source: 'MANUAL', + workspaceMemberId: null, + name: "''", + context: null, + }), + ).toEqual({ + source: 'MANUAL', + workspaceMemberId: null, + name: null, + context: null, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-address-default-value.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-address-default-value.spec.ts new file mode 100644 index 0000000000..eb5ef8ce8a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-address-default-value.spec.ts @@ -0,0 +1,66 @@ +import { nullifyEmptyAddressDefaultValue } from '../nullify-empty-address-default-value.util'; + +describe('nullifyEmptyAddressDefaultValue', () => { + it('returns null when all sub-fields are empty-string equivalents or null', () => { + expect( + nullifyEmptyAddressDefaultValue({ + addressStreet1: "''", + addressStreet2: '', + addressCity: '', + addressState: null, + addressCountry: null, + addressPostcode: null, + addressLat: null, + addressLng: null, + }), + ).toBeNull(); + }); + + it('returns normalized object when addressCity has a value', () => { + expect( + nullifyEmptyAddressDefaultValue({ + addressStreet1: "''", + addressStreet2: null, + addressCity: 'Paris', + addressState: '', + addressCountry: null, + addressPostcode: null, + addressLat: null, + addressLng: null, + }), + ).toEqual({ + addressStreet1: null, + addressStreet2: null, + addressCity: 'Paris', + addressState: null, + addressCountry: null, + addressPostcode: null, + addressLat: null, + addressLng: null, + }); + }); + + it('returns object when only numeric coords are set', () => { + expect( + nullifyEmptyAddressDefaultValue({ + addressStreet1: null, + addressStreet2: null, + addressCity: null, + addressState: null, + addressCountry: null, + addressPostcode: null, + addressLat: 48.8566, + addressLng: 2.3522, + }), + ).toEqual({ + addressStreet1: null, + addressStreet2: null, + addressCity: null, + addressState: null, + addressCountry: null, + addressPostcode: null, + addressLat: 48.8566, + addressLng: 2.3522, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-currency-default-value.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-currency-default-value.spec.ts new file mode 100644 index 0000000000..1d6ba763aa --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-currency-default-value.spec.ts @@ -0,0 +1,30 @@ +import { nullifyEmptyCurrencyDefaultValue } from '../nullify-empty-currency-default-value.util'; + +describe('nullifyEmptyCurrencyDefaultValue', () => { + it('returns null when both sub-fields are null or empty-string equivalent', () => { + expect( + nullifyEmptyCurrencyDefaultValue({ + amountMicros: null, + currencyCode: "''", + }), + ).toBeNull(); + }); + + it('returns normalized object when amountMicros has a value', () => { + expect( + nullifyEmptyCurrencyDefaultValue({ + amountMicros: 5000000, + currencyCode: "''", + }), + ).toEqual({ amountMicros: 5000000, currencyCode: null }); + }); + + it('returns normalized object when currencyCode has a value', () => { + expect( + nullifyEmptyCurrencyDefaultValue({ + amountMicros: null, + currencyCode: 'EUR', + }), + ).toEqual({ amountMicros: null, currencyCode: 'EUR' }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-emails-default-value.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-emails-default-value.spec.ts new file mode 100644 index 0000000000..e75d5a815f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-emails-default-value.spec.ts @@ -0,0 +1,21 @@ +import { nullifyEmptyEmailsDefaultValue } from '../nullify-empty-emails-default-value.util'; + +describe('nullifyEmptyEmailsDefaultValue', () => { + it('returns null when all sub-fields are empty-string equivalents', () => { + expect( + nullifyEmptyEmailsDefaultValue({ + primaryEmail: "''", + additionalEmails: [], + }), + ).toBeNull(); + }); + + it('returns normalized object when primaryEmail has a value', () => { + expect( + nullifyEmptyEmailsDefaultValue({ + primaryEmail: 'user@example.com', + additionalEmails: [], + }), + ).toEqual({ primaryEmail: 'user@example.com', additionalEmails: null }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-full-name-default-value.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-full-name-default-value.spec.ts new file mode 100644 index 0000000000..265147116a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-full-name-default-value.spec.ts @@ -0,0 +1,15 @@ +import { nullifyEmptyFullNameDefaultValue } from '../nullify-empty-full-name-default-value.util'; + +describe('nullifyEmptyFullNameDefaultValue', () => { + it('returns null when both sub-fields are empty-string equivalents', () => { + expect( + nullifyEmptyFullNameDefaultValue({ firstName: "''", lastName: '' }), + ).toBeNull(); + }); + + it('returns normalized object when lastName has a value', () => { + expect( + nullifyEmptyFullNameDefaultValue({ firstName: "''", lastName: 'Doe' }), + ).toEqual({ firstName: null, lastName: 'Doe' }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-links-default-value.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-links-default-value.spec.ts new file mode 100644 index 0000000000..226ecec73a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-links-default-value.spec.ts @@ -0,0 +1,27 @@ +import { nullifyEmptyLinksDefaultValue } from '../nullify-empty-links-default-value.util'; + +describe('nullifyEmptyLinksDefaultValue', () => { + it('returns null when all sub-fields are empty-string equivalents', () => { + expect( + nullifyEmptyLinksDefaultValue({ + primaryLinkLabel: '', + primaryLinkUrl: "''", + secondaryLinks: null, + }), + ).toBeNull(); + }); + + it('returns normalized object when primaryLinkUrl has a value', () => { + expect( + nullifyEmptyLinksDefaultValue({ + primaryLinkLabel: "''", + primaryLinkUrl: 'https://twenty.com', + secondaryLinks: null, + }), + ).toEqual({ + primaryLinkLabel: null, + primaryLinkUrl: 'https://twenty.com', + secondaryLinks: null, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-phones-default-value.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-phones-default-value.spec.ts new file mode 100644 index 0000000000..b76c8bc5d3 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-phones-default-value.spec.ts @@ -0,0 +1,30 @@ +import { nullifyEmptyPhonesDefaultValue } from '../nullify-empty-phones-default-value.util'; + +describe('nullifyEmptyPhonesDefaultValue', () => { + it('returns null when all fields are null-equivalent', () => { + expect( + nullifyEmptyPhonesDefaultValue({ + primaryPhoneNumber: "''", + primaryPhoneCountryCode: "''", + primaryPhoneCallingCode: null, + additionalPhones: null, + }), + ).toBeNull(); + }); + + it('returns normalized object when primaryPhoneNumber has a value', () => { + expect( + nullifyEmptyPhonesDefaultValue({ + primaryPhoneNumber: '+33612345678', + primaryPhoneCountryCode: "''", + primaryPhoneCallingCode: '', + additionalPhones: null, + }), + ).toEqual({ + primaryPhoneNumber: '+33612345678', + primaryPhoneCountryCode: null, + primaryPhoneCallingCode: null, + additionalPhones: null, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-rich-text-default-value.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-rich-text-default-value.spec.ts new file mode 100644 index 0000000000..3c3bcbe9ad --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/nullify-empty-rich-text-default-value.spec.ts @@ -0,0 +1,15 @@ +import { nullifyEmptyRichTextDefaultValue } from '../nullify-empty-rich-text-default-value.util'; + +describe('nullifyEmptyRichTextDefaultValue', () => { + it('returns null when both sub-fields are empty-string equivalents', () => { + expect( + nullifyEmptyRichTextDefaultValue({ blocknote: "''", markdown: '' }), + ).toBeNull(); + }); + + it('returns normalized object when blocknote has a value', () => { + expect( + nullifyEmptyRichTextDefaultValue({ blocknote: '[]', markdown: "''" }), + ).toEqual({ blocknote: '[]', markdown: null }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts index 87ffc45209..5c65c58e5a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts @@ -5,6 +5,8 @@ import { type FlatApplication } from 'src/engine/core-modules/application/types/ import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value'; import { generateNullable } from 'src/engine/metadata-modules/field-metadata/utils/generate-nullable'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { nullifyEmptyCompositeDefaultValue } from 'src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-composite-default-value.util'; import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; type GetDefaultFlatFieldMetadataArgs = { @@ -23,6 +25,8 @@ export const getDefaultFlatFieldMetadata = ({ ); const createdAt = new Date().toISOString(); + const resolvedDefaultValue = + defaultValue ?? generateDefaultValue(createFieldInput.type); return { description: createFieldInput.description ?? null, @@ -42,7 +46,12 @@ export const getDefaultFlatFieldMetadata = ({ type: createFieldInput.type, universalIdentifier: createFieldInput.universalIdentifier ?? v4(), options: createFieldInput.options ?? null, - defaultValue: defaultValue ?? generateDefaultValue(createFieldInput.type), + defaultValue: isCompositeFieldMetadataType(createFieldInput.type) + ? nullifyEmptyCompositeDefaultValue({ + defaultValue: resolvedDefaultValue, + fieldType: createFieldInput.type, + }) + : resolvedDefaultValue, createdAt, updatedAt: createdAt, isUIReadOnly: createFieldInput.isUIReadOnly ?? false, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/is-null-equivalent-text-default-value.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/is-null-equivalent-text-default-value.util.ts new file mode 100644 index 0000000000..4465617f42 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/is-null-equivalent-text-default-value.util.ts @@ -0,0 +1,2 @@ +export const isNullEquivalentTextDefaultValue = (value: unknown): boolean => + value === "''" || value === ''; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-actor-default-value.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-actor-default-value.util.ts new file mode 100644 index 0000000000..ad23f78528 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-actor-default-value.util.ts @@ -0,0 +1,37 @@ +import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { isNullEquivalentTextDefaultValue } from './is-null-equivalent-text-default-value.util'; + +export const nullifyEmptyActorDefaultValue = ( + defaultValue: FieldMetadataDefaultValueForAnyType, +): FieldMetadataDefaultValueForAnyType => { + if (!isDefined(defaultValue)) { + return null; + } + + const v = defaultValue as { + source?: string | null; + workspaceMemberId?: string | null; + name?: string | null; + context?: object | null; + }; + + const source = v.source ?? null; + const workspaceMemberId = v.workspaceMemberId ?? null; + const name = isNullEquivalentTextDefaultValue(v.name) + ? null + : (v.name ?? null); + const context = v.context ?? null; + + if ( + source === null && + workspaceMemberId === null && + name === null && + context === null + ) { + return null; + } + + return { source, workspaceMemberId, name, context }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-address-default-value.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-address-default-value.util.ts new file mode 100644 index 0000000000..83136b0b59 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-address-default-value.util.ts @@ -0,0 +1,68 @@ +import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { isNullEquivalentTextDefaultValue } from './is-null-equivalent-text-default-value.util'; + +export const nullifyEmptyAddressDefaultValue = ( + defaultValue: FieldMetadataDefaultValueForAnyType, +): FieldMetadataDefaultValueForAnyType => { + if (!isDefined(defaultValue)) { + return null; + } + + const v = defaultValue as { + addressStreet1?: string | null; + addressStreet2?: string | null; + addressCity?: string | null; + addressState?: string | null; + addressCountry?: string | null; + addressPostcode?: string | null; + addressLat?: number | null; + addressLng?: number | null; + }; + + const addressStreet1 = isNullEquivalentTextDefaultValue(v.addressStreet1) + ? null + : (v.addressStreet1 ?? null); + const addressStreet2 = isNullEquivalentTextDefaultValue(v.addressStreet2) + ? null + : (v.addressStreet2 ?? null); + const addressCity = isNullEquivalentTextDefaultValue(v.addressCity) + ? null + : (v.addressCity ?? null); + const addressState = isNullEquivalentTextDefaultValue(v.addressState) + ? null + : (v.addressState ?? null); + const addressCountry = isNullEquivalentTextDefaultValue(v.addressCountry) + ? null + : (v.addressCountry ?? null); + const addressPostcode = isNullEquivalentTextDefaultValue(v.addressPostcode) + ? null + : (v.addressPostcode ?? null); + const addressLat = v.addressLat ?? null; + const addressLng = v.addressLng ?? null; + + if ( + addressStreet1 === null && + addressStreet2 === null && + addressCity === null && + addressState === null && + addressCountry === null && + addressPostcode === null && + addressLat === null && + addressLng === null + ) { + return null; + } + + return { + addressStreet1, + addressStreet2, + addressCity, + addressState, + addressCountry, + addressPostcode, + addressLat, + addressLng, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-composite-default-value.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-composite-default-value.util.ts new file mode 100644 index 0000000000..ab5f9fc042 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-composite-default-value.util.ts @@ -0,0 +1,45 @@ +import { + FieldMetadataType, + type FieldMetadataDefaultValueForAnyType, +} from 'twenty-shared/types'; +import { assertUnreachable } from 'twenty-shared/utils'; + +import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type'; + +import { nullifyEmptyActorDefaultValue } from './nullify-empty-actor-default-value.util'; +import { nullifyEmptyAddressDefaultValue } from './nullify-empty-address-default-value.util'; +import { nullifyEmptyCurrencyDefaultValue } from './nullify-empty-currency-default-value.util'; +import { nullifyEmptyEmailsDefaultValue } from './nullify-empty-emails-default-value.util'; +import { nullifyEmptyFullNameDefaultValue } from './nullify-empty-full-name-default-value.util'; +import { nullifyEmptyLinksDefaultValue } from './nullify-empty-links-default-value.util'; +import { nullifyEmptyPhonesDefaultValue } from './nullify-empty-phones-default-value.util'; +import { nullifyEmptyRichTextDefaultValue } from './nullify-empty-rich-text-default-value.util'; + +export const nullifyEmptyCompositeDefaultValue = ({ + defaultValue, + fieldType, +}: { + defaultValue: FieldMetadataDefaultValueForAnyType; + fieldType: CompositeFieldMetadataType; +}): FieldMetadataDefaultValueForAnyType => { + switch (fieldType) { + case FieldMetadataType.PHONES: + return nullifyEmptyPhonesDefaultValue(defaultValue); + case FieldMetadataType.EMAILS: + return nullifyEmptyEmailsDefaultValue(defaultValue); + case FieldMetadataType.LINKS: + return nullifyEmptyLinksDefaultValue(defaultValue); + case FieldMetadataType.ADDRESS: + return nullifyEmptyAddressDefaultValue(defaultValue); + case FieldMetadataType.FULL_NAME: + return nullifyEmptyFullNameDefaultValue(defaultValue); + case FieldMetadataType.ACTOR: + return nullifyEmptyActorDefaultValue(defaultValue); + case FieldMetadataType.CURRENCY: + return nullifyEmptyCurrencyDefaultValue(defaultValue); + case FieldMetadataType.RICH_TEXT: + return nullifyEmptyRichTextDefaultValue(defaultValue); + default: + assertUnreachable(fieldType); + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-currency-default-value.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-currency-default-value.util.ts new file mode 100644 index 0000000000..1f96665cd6 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-currency-default-value.util.ts @@ -0,0 +1,28 @@ +import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { isNullEquivalentTextDefaultValue } from './is-null-equivalent-text-default-value.util'; + +export const nullifyEmptyCurrencyDefaultValue = ( + defaultValue: FieldMetadataDefaultValueForAnyType, +): FieldMetadataDefaultValueForAnyType => { + if (!isDefined(defaultValue)) { + return null; + } + + const v = defaultValue as { + amountMicros?: number | null; + currencyCode?: string | null; + }; + + const amountMicros = v.amountMicros ?? null; + const currencyCode = isNullEquivalentTextDefaultValue(v.currencyCode) + ? null + : (v.currencyCode ?? null); + + if (amountMicros === null && currencyCode === null) { + return null; + } + + return { amountMicros, currencyCode }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-emails-default-value.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-emails-default-value.util.ts new file mode 100644 index 0000000000..6aad13f928 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-emails-default-value.util.ts @@ -0,0 +1,32 @@ +import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { isNullEquivalentArrayFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util'; + +import { isNullEquivalentTextDefaultValue } from './is-null-equivalent-text-default-value.util'; + +export const nullifyEmptyEmailsDefaultValue = ( + defaultValue: FieldMetadataDefaultValueForAnyType, +): FieldMetadataDefaultValueForAnyType => { + if (!isDefined(defaultValue)) { + return null; + } + + const v = defaultValue as { + primaryEmail?: string | null; + additionalEmails?: object | null; + }; + + const primaryEmail = isNullEquivalentTextDefaultValue(v.primaryEmail) + ? null + : (v.primaryEmail ?? null); + const additionalEmails = isNullEquivalentArrayFieldValue(v.additionalEmails) + ? null + : (v.additionalEmails ?? null); + + if (primaryEmail === null && additionalEmails === null) { + return null; + } + + return { primaryEmail, additionalEmails }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-full-name-default-value.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-full-name-default-value.util.ts new file mode 100644 index 0000000000..cdad9ef0e9 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-full-name-default-value.util.ts @@ -0,0 +1,30 @@ +import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { isNullEquivalentTextDefaultValue } from './is-null-equivalent-text-default-value.util'; + +export const nullifyEmptyFullNameDefaultValue = ( + defaultValue: FieldMetadataDefaultValueForAnyType, +): FieldMetadataDefaultValueForAnyType => { + if (!isDefined(defaultValue)) { + return null; + } + + const v = defaultValue as { + firstName?: string | null; + lastName?: string | null; + }; + + const firstName = isNullEquivalentTextDefaultValue(v.firstName) + ? null + : (v.firstName ?? null); + const lastName = isNullEquivalentTextDefaultValue(v.lastName) + ? null + : (v.lastName ?? null); + + if (firstName === null && lastName === null) { + return null; + } + + return { firstName, lastName }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-links-default-value.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-links-default-value.util.ts new file mode 100644 index 0000000000..a0a4225b6e --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-links-default-value.util.ts @@ -0,0 +1,40 @@ +import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { isNullEquivalentArrayFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util'; + +import { isNullEquivalentTextDefaultValue } from './is-null-equivalent-text-default-value.util'; + +export const nullifyEmptyLinksDefaultValue = ( + defaultValue: FieldMetadataDefaultValueForAnyType, +): FieldMetadataDefaultValueForAnyType => { + if (!isDefined(defaultValue)) { + return null; + } + + const v = defaultValue as { + primaryLinkLabel?: string | null; + primaryLinkUrl?: string | null; + secondaryLinks?: object | null; + }; + + const primaryLinkLabel = isNullEquivalentTextDefaultValue(v.primaryLinkLabel) + ? null + : (v.primaryLinkLabel ?? null); + const primaryLinkUrl = isNullEquivalentTextDefaultValue(v.primaryLinkUrl) + ? null + : (v.primaryLinkUrl ?? null); + const secondaryLinks = isNullEquivalentArrayFieldValue(v.secondaryLinks) + ? null + : (v.secondaryLinks ?? null); + + if ( + primaryLinkLabel === null && + primaryLinkUrl === null && + secondaryLinks === null + ) { + return null; + } + + return { primaryLinkLabel, primaryLinkUrl, secondaryLinks }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-phones-default-value.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-phones-default-value.util.ts new file mode 100644 index 0000000000..9a0cba5cc6 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-phones-default-value.util.ts @@ -0,0 +1,56 @@ +import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { isNullEquivalentArrayFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util'; + +import { isNullEquivalentTextDefaultValue } from './is-null-equivalent-text-default-value.util'; + +export const nullifyEmptyPhonesDefaultValue = ( + defaultValue: FieldMetadataDefaultValueForAnyType, +): FieldMetadataDefaultValueForAnyType => { + if (!isDefined(defaultValue)) { + return null; + } + + const v = defaultValue as { + primaryPhoneNumber?: string | null; + primaryPhoneCountryCode?: string | null; + primaryPhoneCallingCode?: string | null; + additionalPhones?: object | null; + }; + + const primaryPhoneNumber = isNullEquivalentTextDefaultValue( + v.primaryPhoneNumber, + ) + ? null + : (v.primaryPhoneNumber ?? null); + const primaryPhoneCountryCode = isNullEquivalentTextDefaultValue( + v.primaryPhoneCountryCode, + ) + ? null + : (v.primaryPhoneCountryCode ?? null); + const primaryPhoneCallingCode = isNullEquivalentTextDefaultValue( + v.primaryPhoneCallingCode, + ) + ? null + : (v.primaryPhoneCallingCode ?? null); + const additionalPhones = isNullEquivalentArrayFieldValue(v.additionalPhones) + ? null + : (v.additionalPhones ?? null); + + if ( + primaryPhoneNumber === null && + primaryPhoneCountryCode === null && + primaryPhoneCallingCode === null && + additionalPhones === null + ) { + return null; + } + + return { + primaryPhoneNumber, + primaryPhoneCountryCode, + primaryPhoneCallingCode, + additionalPhones, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-rich-text-default-value.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-rich-text-default-value.util.ts new file mode 100644 index 0000000000..a6a67b5355 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-rich-text-default-value.util.ts @@ -0,0 +1,30 @@ +import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { isNullEquivalentTextDefaultValue } from './is-null-equivalent-text-default-value.util'; + +export const nullifyEmptyRichTextDefaultValue = ( + defaultValue: FieldMetadataDefaultValueForAnyType, +): FieldMetadataDefaultValueForAnyType => { + if (!isDefined(defaultValue)) { + return null; + } + + const v = defaultValue as { + blocknote?: string | null; + markdown?: string | null; + }; + + const blocknote = isNullEquivalentTextDefaultValue(v.blocknote) + ? null + : (v.blocknote ?? null); + const markdown = isNullEquivalentTextDefaultValue(v.markdown) + ? null + : (v.markdown ?? null); + + if (blocknote === null && markdown === null) { + return null; + } + + return { blocknote, markdown }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/sanitize-raw-update-field-input.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/sanitize-raw-update-field-input.ts index bf1305cbd3..eeccaa0698 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/sanitize-raw-update-field-input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/sanitize-raw-update-field-input.ts @@ -13,6 +13,8 @@ import { import { FLAT_FIELD_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant'; import { type FlatFieldMetadataEditableProperties } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-editable-properties.constant'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { nullifyEmptyCompositeDefaultValue } from 'src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-composite-default-value.util'; import { belongsToTwentyStandardApp } from 'src/engine/metadata-modules/utils/belongs-to-twenty-standard-app.util'; type SanitizeRawUpdateFieldInputArgs = { @@ -45,6 +47,17 @@ export const sanitizeRawUpdateFieldInput = ({ ...option, })); + if ( + updatedEditableFieldProperties.defaultValue !== undefined && + isCompositeFieldMetadataType(existingFlatFieldMetadata.type) + ) { + updatedEditableFieldProperties.defaultValue = + nullifyEmptyCompositeDefaultValue({ + defaultValue: updatedEditableFieldProperties.defaultValue, + fieldType: existingFlatFieldMetadata.type, + }); + } + if (!isStandardField || isSystemBuild) { return { updatedEditableFieldProperties, diff --git a/packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/types/entity-schema-metadata.type.ts b/packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/types/entity-schema-metadata.type.ts index 61e7bd4ff6..7d724fe791 100644 --- a/packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/types/entity-schema-metadata.type.ts +++ b/packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/types/entity-schema-metadata.type.ts @@ -19,6 +19,7 @@ export type EntitySchemaFieldMetadata< | 'type' | 'settings' | 'isNullable' + | 'isUnique' | 'defaultValue' | 'options' | 'objectMetadataId' @@ -73,6 +74,7 @@ export const buildEntitySchemaMetadataMaps = ( type: field.type, settings: field.settings, isNullable: field.isNullable, + isUnique: field.isUnique, defaultValue: field.defaultValue, options: field.options, objectMetadataId: field.objectMetadataId, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/utils/is-composite-field-default-value-compatible-with-unique-index.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/utils/is-composite-field-default-value-compatible-with-unique-index.util.ts new file mode 100644 index 0000000000..19e3894647 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/utils/is-composite-field-default-value-compatible-with-unique-index.util.ts @@ -0,0 +1,42 @@ +import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type'; +import { nullifyEmptyCompositeDefaultValue } from 'src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-composite-default-value.util'; +import { + type CompositeProperty, + type FieldMetadataDefaultValueForAnyType, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export const isCompositeFieldDefaultValueCompatibleWithUniqueIndex = ({ + fieldType, + compositeProperties, + defaultValue, +}: { + fieldType: CompositeFieldMetadataType; + compositeProperties: CompositeProperty[]; + defaultValue?: FieldMetadataDefaultValueForAnyType; +}) => { + if (!isDefined(defaultValue)) { + return true; + } + + const normalizedDefaultValue = nullifyEmptyCompositeDefaultValue({ + defaultValue, + fieldType, + }); + + if (!isDefined(normalizedDefaultValue)) { + return true; + } + + const uniqueCompositeProperties = compositeProperties.filter( + (property) => property.isIncludedInUniqueConstraint === true, + ); + + return uniqueCompositeProperties.some((compositeProperty) => { + return !isDefined( + normalizedDefaultValue[ + compositeProperty.name as keyof typeof normalizedDefaultValue + ], + ); + }); +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-index-metadata-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-index-metadata-validator.service.ts index 4d63bd4cd4..3496c15955 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-index-metadata-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-index-metadata-validator.service.ts @@ -15,6 +15,8 @@ import { IndexExceptionCode } from 'src/engine/metadata-modules/flat-index-metad import { FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util'; import { UniversalFlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-args.type'; +import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type'; +import { isCompositeFieldDefaultValueCompatibleWithUniqueIndex } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/utils/is-composite-field-default-value-compatible-with-unique-index.util'; @Injectable() export class FlatIndexValidatorService { @@ -147,8 +149,23 @@ export class FlatIndexValidatorService { } if (flatIndexToValidate.isUnique) { + const compositeType = isCompositeUniversalFlatFieldMetadata( + relatedFlatField, + ) + ? compositeTypeDefinitions.get(relatedFlatField.type) + : undefined; + + const canUseDefaultValueInUniqueIndex = isDefined(compositeType) + ? isCompositeFieldDefaultValueCompatibleWithUniqueIndex({ + fieldType: + relatedFlatField.type as CompositeFieldMetadataType, + compositeProperties: compositeType.properties, + defaultValue: relatedFlatField.defaultValue, + }) + : !isDefined(relatedFlatField.defaultValue); + if ( - isDefined(relatedFlatField.defaultValue) && + !canUseDefaultValueInUniqueIndex && relatedFlatField.isUnique ) { const fieldName = relatedFlatField.name; @@ -163,11 +180,9 @@ export class FlatIndexValidatorService { const isCompositeFieldWithNonIncludedUniqueConstraint = isCompositeUniversalFlatFieldMetadata(relatedFlatField) && - !compositeTypeDefinitions - .get(relatedFlatField.type) - ?.properties.some( - (property) => property.isIncludedInUniqueConstraint, - ); + !compositeType?.properties.some( + (property) => property.isIncludedInUniqueConstraint, + ); if ( [ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/__tests__/compute-flat-index-field-column-names.util.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/__tests__/compute-flat-index-field-column-names.util.spec.ts new file mode 100644 index 0000000000..fc672414c3 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/__tests__/compute-flat-index-field-column-names.util.spec.ts @@ -0,0 +1,44 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { type MetadataFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity-maps.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatIndexFieldMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; +import { computeFlatIndexFieldColumnNames } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/index-action-handler.utils'; + +describe('computeFlatIndexFieldColumnNames', () => { + const phoneFieldMetadataId = 'phone-field-metadata-id'; + const phoneFieldUniversalIdentifier = 'phone-field-universal-identifier'; + + const flatFieldMetadataMaps = { + byUniversalIdentifier: { + [phoneFieldUniversalIdentifier]: { + id: phoneFieldMetadataId, + universalIdentifier: phoneFieldUniversalIdentifier, + name: 'phone', + type: FieldMetadataType.PHONES, + } as FlatFieldMetadata, + }, + universalIdentifierById: { + [phoneFieldMetadataId]: phoneFieldUniversalIdentifier, + }, + universalIdentifiersByApplicationId: {}, + } as MetadataFlatEntityMaps<'fieldMetadata'>; + + it('returns every unique subfield column for phone composite fields', () => { + const flatIndexFieldMetadatas = [ + { + fieldMetadataId: phoneFieldMetadataId, + } as FlatIndexFieldMetadata, + ]; + expect( + computeFlatIndexFieldColumnNames({ + flatIndexFieldMetadatas, + flatFieldMetadataMaps, + }), + ).toEqual([ + 'phonePrimaryPhoneNumber', + 'phonePrimaryPhoneCountryCode', + 'phonePrimaryPhoneCallingCode', + ]); + }); +}); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/__tests__/generate-column-definitions.util.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/__tests__/generate-column-definitions.util.spec.ts index 210d70c1a1..646e38dbd8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/__tests__/generate-column-definitions.util.spec.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/__tests__/generate-column-definitions.util.spec.ts @@ -246,6 +246,90 @@ describe('Generate Column Definitions', () => { default: "'USD'::text", }); }); + + it('should serialize null-equivalent unique composite defaults as NULL', () => { + const phonesField = getFlatFieldMetadataMock({ + universalIdentifier: 'phone', + objectMetadataId: mockObjectId, + type: FieldMetadataType.PHONES, + name: 'phone', + isUnique: true, + defaultValue: { + primaryPhoneNumber: "''", + primaryPhoneCountryCode: "'US'", + primaryPhoneCallingCode: "'+1'", + additionalPhones: null, + }, + }); + + const columns = generateColumnDefinitions({ + flatFieldMetadata: phonesField, + flatObjectMetadata: mockObjectMetadata, + workspaceId, + }); + + expect(columns).toHaveLength(4); + expect(columns).toEqual([ + expect.objectContaining({ + name: 'phonePrimaryPhoneNumber', + default: 'NULL', + }), + expect.objectContaining({ + name: 'phonePrimaryPhoneCountryCode', + default: "'US'::text", + }), + expect.objectContaining({ + name: 'phonePrimaryPhoneCallingCode', + default: "'+1'::text", + }), + expect.objectContaining({ + name: 'phoneAdditionalPhones', + default: 'NULL', + }), + ]); + }); + + it('should serialize normalized unique phone defaults from metadata input', () => { + const phonesField = getFlatFieldMetadataMock({ + universalIdentifier: 'phone', + objectMetadataId: mockObjectId, + type: FieldMetadataType.PHONES, + name: 'phone', + isUnique: true, + defaultValue: { + primaryPhoneNumber: '', + primaryPhoneCountryCode: '', + primaryPhoneCallingCode: '', + additionalPhones: null, + }, + }); + + const columns = generateColumnDefinitions({ + flatFieldMetadata: phonesField, + flatObjectMetadata: mockObjectMetadata, + workspaceId, + }); + + expect(columns).toHaveLength(4); + expect(columns).toEqual([ + expect.objectContaining({ + name: 'phonePrimaryPhoneNumber', + default: 'NULL', + }), + expect.objectContaining({ + name: 'phonePrimaryPhoneCountryCode', + default: 'NULL', + }), + expect.objectContaining({ + name: 'phonePrimaryPhoneCallingCode', + default: 'NULL', + }), + expect.objectContaining({ + name: 'phoneAdditionalPhones', + default: 'NULL', + }), + ]); + }); }); describe('Default Value Schema Generation', () => { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/generate-column-definitions.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/generate-column-definitions.util.ts index 2bd077f9c4..d741238f4c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/generate-column-definitions.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/generate-column-definitions.util.ts @@ -27,6 +27,7 @@ import { } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-action-execution.exception'; import { fieldMetadataTypeToColumnType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/field-metadata-type-to-column-type.util'; import { getWorkspaceSchemaContextForMigration } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/get-workspace-schema-context-for-migration.util'; +import { nullifyEmptyCompositeDefaultValue } from 'src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-composite-default-value.util'; export const generateCompositeColumnDefinition = ({ compositeProperty, @@ -58,9 +59,14 @@ export const generateCompositeColumnDefinition = ({ parentFlatFieldMetadata.name, compositeProperty, ); + const normalizedDefaultValue = nullifyEmptyCompositeDefaultValue({ + defaultValue: parentFlatFieldMetadata.defaultValue, + fieldType: parentFlatFieldMetadata.type as CompositeFieldMetadataType, + }); const defaultValue = - // @ts-expect-error - TODO: fix this - parentFlatFieldMetadata.defaultValue?.[compositeProperty.name]; + normalizedDefaultValue?.[ + compositeProperty.name as keyof typeof normalizedDefaultValue + ]; const columnType = fieldMetadataTypeToColumnType(compositeProperty.type); const serializedDefaultValue = serializeDefaultValue({ columnName, diff --git a/packages/twenty-server/test/integration/graphql/suites/unique-field/unique-phones-field-null-equivalence.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/unique-field/unique-phones-field-null-equivalence.integration-spec.ts index 5b576c3f52..3a7fb53615 100644 --- a/packages/twenty-server/test/integration/graphql/suites/unique-field/unique-phones-field-null-equivalence.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/unique-field/unique-phones-field-null-equivalence.integration-spec.ts @@ -9,6 +9,7 @@ import { FieldMetadataType } from 'twenty-shared/types'; const OBJECT_SINGULAR = 'uniquePhonesTestObject'; const OBJECT_PLURAL = 'uniquePhonesTestObjects'; +const OBJECT_TABLE_NAME = `_${OBJECT_SINGULAR}`; const FIELD_NAME = 'phone'; describe('unique PHONES field with empty values', () => { @@ -36,6 +37,12 @@ describe('unique PHONES field with empty values', () => { type: FieldMetadataType.PHONES, objectMetadataId: createdObjectMetadataId, isUnique: true, + defaultValue: { + primaryPhoneNumber: "''", + primaryPhoneCountryCode: "'US'", + primaryPhoneCallingCode: "'+1'", + additionalPhones: null, + }, isLabelSyncedWithName: false, }, gqlFields: ` @@ -48,7 +55,7 @@ describe('unique PHONES field with empty values', () => { afterEach(async () => { if (createdRecordIdsForCleaning.length > 0) { - await deleteRecordsByIds(OBJECT_SINGULAR, createdRecordIdsForCleaning); + await deleteRecordsByIds(OBJECT_TABLE_NAME, createdRecordIdsForCleaning); createdRecordIdsForCleaning = []; } }); @@ -104,23 +111,86 @@ describe('unique PHONES field with empty values', () => { const firstResponse = await createOneOperation({ objectMetadataSingularName: OBJECT_SINGULAR, input: { id: firstId }, - gqlFields: `id`, + gqlFields: ` + id + ${FIELD_NAME} { + primaryPhoneNumber + primaryPhoneCountryCode + primaryPhoneCallingCode + } + `, }); expect(firstResponse.errors).toBeUndefined(); + expect(firstResponse.data.createOneResponse[FIELD_NAME]).toMatchObject({ + primaryPhoneNumber: '', + primaryPhoneCountryCode: 'US', + primaryPhoneCallingCode: '+1', + }); createdRecordIdsForCleaning.push(firstId); const secondResponse = await createOneOperation({ objectMetadataSingularName: OBJECT_SINGULAR, input: { id: secondId }, + gqlFields: ` + id + ${FIELD_NAME} { + primaryPhoneNumber + primaryPhoneCountryCode + primaryPhoneCallingCode + } + `, + }); + + expect(secondResponse.errors).toBeUndefined(); + expect(secondResponse.data.createOneResponse[FIELD_NAME]).toMatchObject({ + primaryPhoneNumber: '', + primaryPhoneCountryCode: 'US', + primaryPhoneCallingCode: '+1', + }); + createdRecordIdsForCleaning.push(secondId); + }); + + it('should allow creating when same phoneNumber but different phoneCallingCode', async () => { + const firstId = faker.string.uuid(); + const secondId = faker.string.uuid(); + + const firstResponse = await createOneOperation({ + objectMetadataSingularName: OBJECT_SINGULAR, + input: { + id: firstId, + [FIELD_NAME]: { + primaryPhoneNumber: '4155552671', + primaryPhoneCallingCode: '+1', + primaryPhoneCountryCode: 'US', + }, + }, + gqlFields: `id`, + }); + + expect(firstResponse.errors).toBeUndefined(); + expect(firstResponse.data.createOneResponse.id).toBe(firstId); + createdRecordIdsForCleaning.push(firstId); + + const secondResponse = await createOneOperation({ + objectMetadataSingularName: OBJECT_SINGULAR, + input: { + id: secondId, + [FIELD_NAME]: { + primaryPhoneNumber: '4155552671', + primaryPhoneCallingCode: '+32', + primaryPhoneCountryCode: 'BE', + }, + }, gqlFields: `id`, }); expect(secondResponse.errors).toBeUndefined(); + expect(secondResponse.data.createOneResponse.id).toBe(secondId); createdRecordIdsForCleaning.push(secondId); }); - it('should still enforce uniqueness when two records share the same non-empty primaryPhoneNumber', async () => { + it('should still enforce uniqueness when two records share the same non-empty primaryPhoneNumber and primaryPhoneCallingCode', async () => { const firstId = faker.string.uuid(); const secondId = faker.string.uuid(); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-address-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-address-field-metadata.integration-spec.ts index 4cd6213fdf..9971ac9692 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-address-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-address-field-metadata.integration-spec.ts @@ -86,11 +86,11 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ label: 'Shipping Address', defaultValue: { addressStreet1: "'456 Oak Ave'", - addressStreet2: "''", + addressStreet2: null, addressCity: "'New York'", - addressState: "''", - addressPostcode: "''", - addressCountry: "''", + addressState: null, + addressPostcode: null, + addressCountry: null, addressLat: null, addressLng: null, }, @@ -103,16 +103,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ input: { name: 'billingAddress', label: 'Billing Address', - defaultValue: { - addressStreet1: "''", - addressStreet2: "''", - addressCity: "''", - addressState: "''", - addressPostcode: "''", - addressCountry: "''", - addressLat: null, - addressLng: null, - }, + defaultValue: null, }, }, }, @@ -126,11 +117,11 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ subFields: ['addressStreet1', 'addressCity', 'addressCountry'], }, defaultValue: { - addressStreet1: "''", - addressStreet2: "''", - addressCity: "''", - addressState: "''", - addressPostcode: "''", + addressStreet1: null, + addressStreet2: null, + addressCity: null, + addressState: null, + addressPostcode: null, addressCountry: "'USA'", addressLat: null, addressLng: null, diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-currency-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-currency-field-metadata.integration-spec.ts index 6968dd933a..c1a14d3b63 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-currency-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-currency-field-metadata.integration-spec.ts @@ -72,10 +72,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ input: { name: 'cost', label: 'Cost', - defaultValue: { - amountMicros: null, - currencyCode: "''", - }, + defaultValue: null, }, }, }, diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-emails-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-emails-field-metadata.integration-spec.ts index 90a47672a2..1cb51e061f 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-emails-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-emails-field-metadata.integration-spec.ts @@ -57,10 +57,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ input: { name: 'emptyEmails', label: 'Empty Emails', - defaultValue: { - primaryEmail: "''", - additionalEmails: null, - }, + defaultValue: null, }, }, }, diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-full-name-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-full-name-field-metadata.integration-spec.ts index 387e9c0116..bbcc9ffa66 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-full-name-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-full-name-field-metadata.integration-spec.ts @@ -48,7 +48,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ label: 'Customer Name', defaultValue: { firstName: "'Jane'", - lastName: "''", + lastName: null, }, }, }, @@ -59,10 +59,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ input: { name: 'authorName', label: 'Author Name', - defaultValue: { - firstName: "''", - lastName: "''", - }, + defaultValue: null, }, }, }, diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-links-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-links-field-metadata.integration-spec.ts index 1d6af440de..99c46b48f5 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-links-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-links-field-metadata.integration-spec.ts @@ -58,11 +58,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ input: { name: 'emptyLinks', label: 'Empty Links', - defaultValue: { - primaryLinkLabel: "''", - primaryLinkUrl: "''", - secondaryLinks: null, - }, + defaultValue: null, }, }, }, diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-rich-text-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-rich-text-field-metadata.integration-spec.ts index 825e21c540..47f6f3c015 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-rich-text-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-create-rich-text-field-metadata.integration-spec.ts @@ -59,10 +59,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ input: { name: 'body', label: 'Body', - defaultValue: { - blocknote: null, - markdown: null, - }, + defaultValue: null, }, }, }, diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-address-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-address-field-metadata.integration-spec.ts index 5bb162ef27..7723b4dc8f 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-address-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-address-field-metadata.integration-spec.ts @@ -77,11 +77,11 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ input: { defaultValue: { addressStreet1: "'456 Oak Ave'", - addressStreet2: "''", + addressStreet2: null, addressCity: "'Boston'", - addressState: "''", - addressPostcode: "''", - addressCountry: "''", + addressState: null, + addressPostcode: null, + addressCountry: null, addressLat: null, addressLng: null, }, @@ -104,11 +104,11 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ subFields: ['addressStreet1', 'addressCity', 'addressPostcode'], }, defaultValue: { - addressStreet1: "''", - addressStreet2: "''", - addressCity: "''", - addressState: "''", - addressPostcode: "''", + addressStreet1: null, + addressStreet2: null, + addressCity: null, + addressState: null, + addressPostcode: null, addressCountry: "'USA'", addressLat: null, addressLng: null, diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-currency-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-currency-field-metadata.integration-spec.ts index 64a1d1fd18..511a4ce3e1 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-currency-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-currency-field-metadata.integration-spec.ts @@ -62,10 +62,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ title: 'currency field default value with empty values', context: { input: { - defaultValue: { - amountMicros: null, - currencyCode: "''", - }, + defaultValue: null, }, }, }, diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-emails-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-emails-field-metadata.integration-spec.ts index d2596e1f5e..1c102520cd 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-emails-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-emails-field-metadata.integration-spec.ts @@ -50,10 +50,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ title: 'emails field default value with empty values', context: { input: { - defaultValue: { - primaryEmail: "''", - additionalEmails: null, - }, + defaultValue: null, }, }, }, diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-full-name-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-full-name-field-metadata.integration-spec.ts index b10c9403f5..56639b4f13 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-full-name-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-full-name-field-metadata.integration-spec.ts @@ -42,7 +42,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ input: { defaultValue: { firstName: "'Jane'", - lastName: "''", + lastName: null, }, }, }, @@ -51,10 +51,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ title: 'full name field default value with empty values', context: { input: { - defaultValue: { - firstName: "''", - lastName: "''", - }, + defaultValue: null, }, }, }, diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-links-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-links-field-metadata.integration-spec.ts index 8b41342eff..ccfc4f034d 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-links-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-links-field-metadata.integration-spec.ts @@ -51,11 +51,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ title: 'links field default value with empty values', context: { input: { - defaultValue: { - primaryLinkLabel: "''", - primaryLinkUrl: "''", - secondaryLinks: null, - }, + defaultValue: null, }, }, }, diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-phones-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-phones-field-metadata.integration-spec.ts index 1b767230b8..440cc6e4d6 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-phones-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/composite/successful-update-phones-field-metadata.integration-spec.ts @@ -52,12 +52,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<{ title: 'phones field default value with empty values', context: { input: { - defaultValue: { - primaryPhoneNumber: "''", - primaryPhoneCountryCode: "''", - primaryPhoneCallingCode: "''", - additionalPhones: null, - }, + defaultValue: null, }, }, }, diff --git a/packages/twenty-shared/src/types/composite-types/phones.composite-type.ts b/packages/twenty-shared/src/types/composite-types/phones.composite-type.ts index dbba0a336c..4e2cf94333 100644 --- a/packages/twenty-shared/src/types/composite-types/phones.composite-type.ts +++ b/packages/twenty-shared/src/types/composite-types/phones.composite-type.ts @@ -17,12 +17,14 @@ export const phonesCompositeType: CompositeType = { type: FieldMetadataType.TEXT, hidden: false, isRequired: false, + isIncludedInUniqueConstraint: true, }, { name: 'primaryPhoneCallingCode', type: FieldMetadataType.TEXT, hidden: false, isRequired: false, + isIncludedInUniqueConstraint: true, }, { name: 'additionalPhones',