Fix phone unique constraints (#20261)

## Summary

Closes #20195

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

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

## Root Cause

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

## Test Plan

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

---------

Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
MkDev11
2026-05-13 04:57:19 -07:00
committed by GitHub
parent ac653182b2
commit bbc55193f5
58 changed files with 1669 additions and 286 deletions
@@ -0,0 +1,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
],
);
});
};
@@ -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 (
[
@@ -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',
]);
});
});
@@ -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', () => {
@@ -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,