From d4b5ed93a381b5b6b97dc1349c5f4e49f845f6e5 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Tue, 12 Aug 2025 12:05:53 +0200 Subject: [PATCH] `FieldMetadataServiceV2.updateOne` (#13784) # Introduction Implementing the `updateOne` service handler following workspace migration v2 style --- .../dtos/field-standard-overrides.dto.ts | 14 +- .../field-metadata/field-metadata.resolver.ts | 17 ++ .../field-metadata-relation.service.ts | 2 +- .../field-metadata-validation.service.ts | 2 +- .../services/field-metadata.service-v2.ts | 138 ++++++++++--- ...t-field-metadata-type-validator.service.ts | 39 +++- .../flat-field-metadata-validator.service.ts | 194 +++++++++++++++--- .../compare-two-flat-field-metadata.util.ts | 31 ++- ...t-to-flat-field-metadata-to-update.util.ts | 158 ++++++++++++++ ...ld-metadata-name-availability.validator.ts | 8 +- .../validate-object-metadata-input.util.ts | 2 +- .../__tests__/validate-metadata-name.spec.ts | 2 +- .../validate-metadata-name-or-throw.utils.ts | 17 ++ .../utils/validate-metadata-name.utils.ts | 34 +-- 14 files changed, 565 insertions(+), 93 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata-to-update.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils.ts diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto.ts index efcb21247cf..ce8c1023a94 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto.ts @@ -4,8 +4,20 @@ import { IsJSON, IsOptional, IsString } from 'class-validator'; import { GraphQLJSON } from 'graphql-type-json'; import { type APP_LOCALES } from 'twenty-shared/translations'; +import { FlatFieldMetadataPropertiesToCompare } from 'src/engine/metadata-modules/flat-field-metadata/utils/compare-two-flat-field-metadata.util'; + +export const fieldMetadataStandardOverridesProperties = [ + 'label', + 'description', + 'icon', +] as const satisfies FlatFieldMetadataPropertiesToCompare[]; + +export type FieldMetadataStandardOverridesProperties = + (typeof fieldMetadataStandardOverridesProperties)[number]; @ObjectType('StandardOverrides') -export class FieldStandardOverridesDTO { +export class FieldStandardOverridesDTO + implements Partial> +{ @IsString() @IsOptional() @Field(() => String, { nullable: true }) diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts index ad5b3a3dfb2..50af9f3165e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts @@ -87,6 +87,23 @@ export class FieldMetadataResolver { @AuthWorkspace() { id: workspaceId }: Workspace, @Context() context: I18nContext, ) { + const isWorkspaceMigrationV2Enabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED, + workspaceId, + ); + + if (isWorkspaceMigrationV2Enabled) { + try { + return await this.fieldMetadataServiceV2.updateOne({ + updateFieldInput: { ...input.update, id: input.id }, + workspaceId, + }); + } catch (error) { + fieldMetadataGraphqlApiExceptionHandler(error); + } + } + try { const updatedInput = (await this.beforeUpdateOneField.run(input, { workspaceId, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service.ts index 22219b64811..41a61fed35a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service.ts @@ -26,7 +26,7 @@ import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-module import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps'; import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils'; -import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils'; +import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils'; import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util'; import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts index fa783cfeeed..52b732dbd83 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts @@ -33,7 +33,7 @@ import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metad import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception'; import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils'; -import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils'; +import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils'; type ValidateFieldMetadataArgs = { fieldMetadataType: FieldMetadataType; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2.ts index b9f5a33e2b2..4f7e5b4f22a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2.ts @@ -11,6 +11,7 @@ import { In, Repository } from 'typeorm'; import { MultipleMetadataValidationErrors } from 'src/engine/core-modules/error/multiple-metadata-validation-errors'; import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; import { type DeleteOneFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/delete-field.input'; +import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataException, @@ -21,6 +22,7 @@ import { type FailedFlatFieldMetadataValidationExceptions } from 'src/engine/met import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { fromCreateFieldInputToFlatFieldMetadatasToCreate } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadatas-to-create.util'; import { fromDeleteFieldInputToFlatFieldMetadatasToDelete } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util'; +import { fromUpdateFieldInputToFlatFieldMetadataToUpdate } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata-to-update.util'; import { isFlatFieldMetadataEntityOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util'; import { throwOnFieldInputTranspilationsError } from 'src/engine/metadata-modules/flat-field-metadata/utils/throw-on-field-input-transpilations-error.util'; import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type'; @@ -29,6 +31,7 @@ import { addFlatFieldMetadataInFlatObjectMetadataMaps } from 'src/engine/metadat import { deleteFieldFromFlatObjectMetadataMapsOrThrow } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/delete-field-from-flat-object-metadata-maps-or-throw.util'; import { getSubFlatObjectMetadataMapsOrThrow } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/get-sub-flat-object-metadata-maps-or-throw.util'; import { getSubFlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/get-sub-flat-object-metadata-maps.util'; +import { replaceFlatFieldMetadataInFlatObjectMetadataMapsOrThrow } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/replace-flat-field-metadata-in-flat-object-metadata-maps-or-throw.util'; import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; import { WorkspaceMigrationBuilderV2Service } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-builder-v2.service'; import { WorkspaceMigrationRunnerV2Service } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-migration-runner-v2.service'; @@ -105,33 +108,40 @@ export class FieldMetadataServiceV2 { ); } - const flatObjectMetadataMapsWithImpactedObject = - getSubFlatObjectMetadataMapsOrThrow({ - flatObjectMetadataMaps: existingFlatObjectMetadataMaps, - objectMetadataIds: flatFieldMetadatasToDelete.map( - (flatFieldMetadataToDelete) => - flatFieldMetadataToDelete.objectMetadataId, - ), + try { + const flatObjectMetadataMapsWithImpactedObject = + getSubFlatObjectMetadataMapsOrThrow({ + flatObjectMetadataMaps: existingFlatObjectMetadataMaps, + objectMetadataIds: flatFieldMetadatasToDelete.map( + (flatFieldMetadataToDelete) => + flatFieldMetadataToDelete.objectMetadataId, + ), + }); + + const toFlatObjectMetadataMaps = flatFieldMetadatasToDelete.reduce( + (flatObjectMetadataMaps, flatFieldMetadataToDelete) => + deleteFieldFromFlatObjectMetadataMapsOrThrow({ + fieldMetadataId: flatFieldMetadataToDelete.id, + flatObjectMetadataMaps, + objectMetadataId: flatFieldMetadataToDelete.objectMetadataId, + }), + flatObjectMetadataMapsWithImpactedObject, + ); + + const workspaceMigration = this.workspaceMigrationBuilderV2.build({ + fromFlatObjectMetadataMaps: flatObjectMetadataMapsWithImpactedObject, + toFlatObjectMetadataMaps, + inferDeletionFromMissingObjectFieldIndex: true, + workspaceId, }); - const toFlatObjectMetadataMaps = flatFieldMetadatasToDelete.reduce( - (flatObjectMetadataMaps, flatFieldMetadataToDelete) => - deleteFieldFromFlatObjectMetadataMapsOrThrow({ - fieldMetadataId: flatFieldMetadataToDelete.id, - flatObjectMetadataMaps, - objectMetadataId: flatFieldMetadataToDelete.objectMetadataId, - }), - flatObjectMetadataMapsWithImpactedObject, - ); - - const workspaceMigration = this.workspaceMigrationBuilderV2.build({ - fromFlatObjectMetadataMaps: flatObjectMetadataMapsWithImpactedObject, - toFlatObjectMetadataMaps, - inferDeletionFromMissingObjectFieldIndex: true, - workspaceId, - }); - - await this.workspaceMigrationRunnerV2Service.run(workspaceMigration); + await this.workspaceMigrationRunnerV2Service.run(workspaceMigration); + } catch { + throw new FieldMetadataException( + 'Workspace migration failed to run', + FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR, + ); + } // TODO to be discussed with coco regarding storing dates in flat in order to build dtos // return flatFieldMetadatasToDelete[0]; @@ -188,6 +198,82 @@ export class FieldMetadataServiceV2 { }); } + async updateOne({ + updateFieldInput, + workspaceId, + }: { + updateFieldInput: UpdateFieldInput; + workspaceId: string; + }): Promise { + const { flatObjectMetadataMaps: existingFlatObjectMetadataMaps } = + await this.workspaceMetadataCacheService.getExistingOrRecomputeFlatObjectMetadataMaps( + { workspaceId }, + ); + + const inputTranspilationResult = + fromUpdateFieldInputToFlatFieldMetadataToUpdate({ + existingFlatObjectMetadataMaps, + updateFieldInput, + }); + + if (inputTranspilationResult.status === 'fail') { + throw inputTranspilationResult.error; + } + + const optimisticiallyUpdatedFlatFieldMetadata = + inputTranspilationResult.result; + + const validationErrors = + await this.flatFieldMetadataValidatorService.validateFlatFieldMetadataUpdate( + { + existingFlatObjectMetadataMaps, + flatFieldMetadataToValidate: optimisticiallyUpdatedFlatFieldMetadata, + workspaceId, + }, + ); + + if (validationErrors.length > 0) { + throw new MultipleMetadataValidationErrors( + validationErrors, + 'Multiple validation errors occurred while updating field', + ); + } + + try { + const fromFlatObjectMetadataMaps = getSubFlatObjectMetadataMapsOrThrow({ + flatObjectMetadataMaps: existingFlatObjectMetadataMaps, + objectMetadataIds: [ + optimisticiallyUpdatedFlatFieldMetadata.objectMetadataId, + ], + }); + const toFlatObjectMetadataMaps = + replaceFlatFieldMetadataInFlatObjectMetadataMapsOrThrow({ + flatObjectMetadataMaps: fromFlatObjectMetadataMaps, + flatFieldMetadata: optimisticiallyUpdatedFlatFieldMetadata, + }); + const workspaceMigration = this.workspaceMigrationBuilderV2.build({ + fromFlatObjectMetadataMaps, + toFlatObjectMetadataMaps, + inferDeletionFromMissingObjectFieldIndex: false, + workspaceId, + }); + + await this.workspaceMigrationRunnerV2Service.run(workspaceMigration); + + return this.fieldMetadataRepository.findOneOrFail({ + where: { + id: optimisticiallyUpdatedFlatFieldMetadata.id, + workspaceId, + }, + }); + } catch { + throw new FieldMetadataException( + 'Workspace migration failed to run', + FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + } + async createMany({ fieldMetadataInputs, workspaceId, @@ -240,7 +326,7 @@ export class FieldMetadataServiceV2 { }); const validationErrors = - await this.flatFieldMetadataValidatorService.validateOneFlatFieldMetadata( + await this.flatFieldMetadataValidatorService.validateOneFlatFieldMetadataCreation( { existingFlatObjectMetadataMaps: optimisticFlatObjectMetadataMaps, flatFieldMetadataToValidate: flatFieldMetadataToCreate, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service.ts index ad911f6f216..ef32f351013 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service.ts @@ -1,11 +1,16 @@ import { Injectable } from '@nestjs/common'; +import { isDefined } from 'class-validator'; +import { FieldMetadataType } from 'twenty-shared/types'; + import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FieldMetadataException, FieldMetadataExceptionCode, } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { ValidateOneFieldMetadataArgs } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service'; +import { FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type'; import { type FlatFieldMetadataTypeValidator } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type'; import { isEnumValidateOneFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-enum-validate-one-field-metadata-args.util'; import { validateEnumSelectFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/validate-enum-flat-field-metadata.validator'; @@ -14,7 +19,7 @@ import { validateRelationFlatFieldMetadata } from 'src/engine/metadata-modules/f export class FlatFieldMetadataTypeValidatorService { constructor(private readonly featureFlagService: FeatureFlagService) {} - public readonly FIELD_METADATA_TYPE_VALIDATOR_HASHMAP: FlatFieldMetadataTypeValidator = + private readonly FIELD_METADATA_TYPE_VALIDATOR_HASHMAP: FlatFieldMetadataTypeValidator = { ACTOR: async (_args) => { return []; @@ -126,4 +131,36 @@ export class FlatFieldMetadataTypeValidatorService { return []; }, }; + + public async validateFlatFieldMetadataTypeSpecificities< + T extends FieldMetadataType = FieldMetadataType, + >({ + existingFlatObjectMetadataMaps, + flatFieldMetadataToValidate, + workspaceId, + otherFlatObjectMetadataMapsToValidate, + }: ValidateOneFieldMetadataArgs): Promise< + FailedFlatFieldMetadataValidationExceptions[] + > { + const fieldMetadataTypeValidator = + this.FIELD_METADATA_TYPE_VALIDATOR_HASHMAP[ + flatFieldMetadataToValidate.type + ]; + + if (!isDefined(fieldMetadataTypeValidator)) { + return [ + new FieldMetadataException( + 'Unsupported field metadata type', + FieldMetadataExceptionCode.UNCOVERED_FIELD_METADATA_TYPE_VALIDATION, + ), + ]; + } + + return await fieldMetadataTypeValidator({ + existingFlatObjectMetadataMaps, + flatFieldMetadataToValidate, + workspaceId, + otherFlatObjectMetadataMapsToValidate, + }); + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service.ts index 566dfe54eb6..611827869bb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { t } from '@lingui/core/macro'; -import { type FieldMetadataType } from 'twenty-shared/types'; +import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { @@ -11,8 +11,14 @@ import { import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service'; import { type FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { + compareTwoFlatFieldMetadata, + type FlatFieldMetadataPropertiesToCompare, +} from 'src/engine/metadata-modules/flat-field-metadata/utils/compare-two-flat-field-metadata.util'; +import { isFlatFieldMetadataEntityOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util'; import { validateFlatFieldMetadataNameAvailability } from 'src/engine/metadata-modules/flat-field-metadata/validators/validate-flat-field-metadata-name-availability.validator'; import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type'; +import { fromFlatObjectMetadataWithFlatFieldMapsToFlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-with-flat-field-maps-to-flat-object-metadatas.util'; import { ObjectMetadataException, ObjectMetadataExceptionCode, @@ -21,7 +27,7 @@ import { InvalidMetadataException, InvalidMetadataExceptionCode, } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception'; -import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils'; +import { validateMetadataName } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils'; import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util'; export type ValidateOneFieldMetadataArgs< @@ -39,6 +45,143 @@ export class FlatFieldMetadataValidatorService { private readonly flatFieldMetadataTypeValidatorService: FlatFieldMetadataTypeValidatorService, ) {} + async validateFlatFieldMetadataUpdate({ + existingFlatObjectMetadataMaps, + flatFieldMetadataToValidate: updatedFlatFieldMetadata, + workspaceId, + }: ValidateOneFieldMetadataArgs): Promise< + FailedFlatFieldMetadataValidationExceptions[] + > { + const errors: FailedFlatFieldMetadataValidationExceptions[] = []; + const flatObjectMetadataWithFlatFieldMaps = + existingFlatObjectMetadataMaps.byId[ + updatedFlatFieldMetadata.objectMetadataId + ]; + + if (!isDefined(flatObjectMetadataWithFlatFieldMaps)) { + return [ + new FieldMetadataException( + 'field metadata to update object metadata not found', + FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND, + ), + ]; + } + if ( + !isDefined( + flatObjectMetadataWithFlatFieldMaps.labelIdentifierFieldMetadataId, + ) + ) { + errors.push( + new FieldMetadataException( + 'Label identifier field metadata id does not exist', + FieldMetadataExceptionCode.LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND, + ), + ); + } + + const existingFlatFieldMetadataToUpdate = + flatObjectMetadataWithFlatFieldMaps.fieldsById[ + updatedFlatFieldMetadata.id + ]; + + if (!isDefined(existingFlatFieldMetadataToUpdate)) { + errors.push( + new FieldMetadataException( + 'field metadata to update not found', + FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND, + ), + ); + + return errors; + } + + const updates = compareTwoFlatFieldMetadata({ + from: existingFlatFieldMetadataToUpdate, + to: updatedFlatFieldMetadata, + }); + + if ( + isFlatFieldMetadataEntityOfType( + updatedFlatFieldMetadata, + FieldMetadataType.RELATION, + ) || + isFlatFieldMetadataEntityOfType( + updatedFlatFieldMetadata, + FieldMetadataType.MORPH_RELATION, + ) + ) { + const relationEditableFields = [ + 'label', + 'description', + 'isActive', + ] as const satisfies FlatFieldMetadataPropertiesToCompare[]; + const relationNonEditableUpdatedProperties = updates.flatMap( + ({ property }) => + !relationEditableFields.includes( + property as (typeof relationEditableFields)[number], + ) + ? property + : [], + ); + + if (relationNonEditableUpdatedProperties.length > 0) { + errors.push( + new FieldMetadataException( + `Forbidden updated properties for relation field metadata: ${relationNonEditableUpdatedProperties.join(', ')}`, + FieldMetadataExceptionCode.FIELD_MUTATION_NOT_ALLOWED, + ), + ); + } + } + + const flatObjectMetadata = + fromFlatObjectMetadataWithFlatFieldMapsToFlatObjectMetadata( + flatObjectMetadataWithFlatFieldMaps, + ); + + if (updates.some((update) => update.property === 'name')) { + errors.push( + ...[ + validateMetadataName(updatedFlatFieldMetadata.name), + validateFlatFieldMetadataNameAvailability({ + name: updatedFlatFieldMetadata.name, + flatObjectMetadata: flatObjectMetadata, + }), + ].filter(isDefined), + ); + } + + if (updatedFlatFieldMetadata.isLabelSyncedWithName) { + const computedName = computeMetadataNameFromLabel( + updatedFlatFieldMetadata.label, + ); + + if (updatedFlatFieldMetadata.name !== computedName) { + errors.push( + new InvalidMetadataException( + `Name is not synced with label. Expected name: "${computedName}", got ${updatedFlatFieldMetadata.name}`, + InvalidMetadataExceptionCode.NAME_NOT_SYNCED_WITH_LABEL, + ), + ); + } + } + + const fieldMetadataTypeValidationErrors = + await this.flatFieldMetadataTypeValidatorService.validateFlatFieldMetadataTypeSpecificities( + { + existingFlatObjectMetadataMaps, + flatFieldMetadataToValidate: updatedFlatFieldMetadata, + workspaceId, + }, + ); + + if (fieldMetadataTypeValidationErrors.length > 0) { + errors.push(...fieldMetadataTypeValidationErrors); + } + + return errors; + } + validateFlatFieldMetadataDeletion({ existingFlatObjectMetadataMaps, flatFieldMetadataToDelete, @@ -98,7 +241,7 @@ export class FlatFieldMetadataValidatorService { return errors; } - async validateOneFlatFieldMetadata< + async validateOneFlatFieldMetadataCreation< T extends FieldMetadataType = FieldMetadataType, >({ existingFlatObjectMetadataMaps, @@ -140,7 +283,7 @@ export class FlatFieldMetadataValidatorService { const failedNameAvailabilityValidation = validateFlatFieldMetadataNameAvailability({ name: flatFieldMetadataToValidate.name, - objectMetadata: parentFlatObjectMetadata, + flatObjectMetadata: parentFlatObjectMetadata, }); if (isDefined(failedNameAvailabilityValidation)) { @@ -163,43 +306,26 @@ export class FlatFieldMetadataValidatorService { } } - try { - validateMetadataNameOrThrow(flatFieldMetadataToValidate.name); - } catch (error) { - errors.push( - new FieldMetadataException( - error.message, - FieldMetadataExceptionCode.INVALID_FIELD_INPUT, - { - userFriendlyMessage: error.userFriendlyMessage, - }, - ), - ); + const nameValidationResult = validateMetadataName( + flatFieldMetadataToValidate.name, + ); + + if (isDefined(nameValidationResult)) { + errors.push(nameValidationResult); } - const fieldMetadataTypeValidator = - this.flatFieldMetadataTypeValidatorService - .FIELD_METADATA_TYPE_VALIDATOR_HASHMAP[ - flatFieldMetadataToValidate.type - ]; - - if (!isDefined(fieldMetadataTypeValidator)) { - errors.push( - new FieldMetadataException( - 'Unsupported field metadata type', - FieldMetadataExceptionCode.UNCOVERED_FIELD_METADATA_TYPE_VALIDATION, - ), - ); - } else { - const fieldMetadataTypeValidatorExceptions = - await fieldMetadataTypeValidator({ + const fieldMetadataTypeValidationErrors = + await this.flatFieldMetadataTypeValidatorService.validateFlatFieldMetadataTypeSpecificities( + { existingFlatObjectMetadataMaps, flatFieldMetadataToValidate, workspaceId, otherFlatObjectMetadataMapsToValidate, - }); + }, + ); - errors.push(...fieldMetadataTypeValidatorExceptions); + if (fieldMetadataTypeValidationErrors.length > 0) { + errors.push(...fieldMetadataTypeValidationErrors); } return errors; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/compare-two-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/compare-two-flat-field-metadata.util.ts index 09665ac2c15..14c38cc97f4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/compare-two-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/compare-two-flat-field-metadata.util.ts @@ -3,11 +3,10 @@ import { FieldMetadataType, type FromTo } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util'; import { type UpdateFieldAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-field-action-v2'; import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util'; -const flatFieldMetadataPropertiesToCompare = [ +export const flatFieldMetadataPropertiesToCompare = [ 'defaultValue', 'description', 'icon', @@ -24,11 +23,14 @@ const flatFieldMetadataPropertiesToCompare = [ export type FlatFieldMetadataPropertiesToCompare = (typeof flatFieldMetadataPropertiesToCompare)[number]; -const fieldMetadataPropertiesToStringify = [ +export const flatFieldMetadataEntityJsonbProperties = [ 'defaultValue', - 'standardOverrides', + 'options', 'settings', -] as const satisfies FlatFieldMetadataPropertiesToCompare[]; + 'standardOverrides', +] as const satisfies (keyof FlatFieldMetadata)[]; +export type FlatFieldMetadataEntityJsonbProperties = + (typeof flatFieldMetadataEntityJsonbProperties)[number]; const shouldNotOverrideDefaultValue = (type: FieldMetadataType) => { return [ @@ -73,7 +75,8 @@ export const compareTwoFlatFieldMetadata = ({ // Remove below assertion when we authorize relation edition, see https://github.com/twentyhq/twenty/commit/39f6f3c4bb101272a9014e142a842d0801a3c33b if ( isDefined(fieldMetadata.type) && - isRelationFieldMetadataType(fieldMetadata.type) && + (fieldMetadata.type === FieldMetadataType.RELATION || + fieldMetadata.type === FieldMetadataType.MORPH_RELATION) && !['label', 'description', 'isActive'].includes(property) ) { return true; @@ -81,7 +84,7 @@ export const compareTwoFlatFieldMetadata = ({ return false; }, - propertiesToStringify: fieldMetadataPropertiesToStringify, + propertiesToStringify: flatFieldMetadataEntityJsonbProperties, }; const fromCompare = transformMetadataForComparison( from, @@ -100,11 +103,23 @@ export const compareTwoFlatFieldMetadata = ({ switch (difference.type) { case 'CHANGE': { const { oldValue, path, value } = difference; + const property = path[0] as FlatFieldMetadataPropertiesToCompare; + const isJsonb = flatFieldMetadataEntityJsonbProperties.includes( + property as FlatFieldMetadataEntityJsonbProperties, + ); + + if (isJsonb) { + return { + from: isDefined(oldValue) ? JSON.parse(oldValue) : oldValue, + to: isDefined(value) ? JSON.parse(value) : value, + property, + }; + } return { from: oldValue, to: value, - property: path[0] as FlatFieldMetadataPropertiesToCompare, + property, }; } case 'CREATE': diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata-to-update.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata-to-update.util.ts new file mode 100644 index 00000000000..f4109c2dacf --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata-to-update.util.ts @@ -0,0 +1,158 @@ +import { + extractAndSanitizeObjectStringFields, + isDefined, + trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties, +} from 'twenty-shared/utils'; + +import { + fieldMetadataStandardOverridesProperties, + type FieldMetadataStandardOverridesProperties, +} from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto'; +import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; +import { + FieldMetadataException, + FieldMetadataExceptionCode, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { type FieldInputTranspilationResult } from 'src/engine/metadata-modules/flat-field-metadata/types/field-input-transpilation-result.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { + flatFieldMetadataPropertiesToCompare, + type FlatFieldMetadataPropertiesToCompare, +} from 'src/engine/metadata-modules/flat-field-metadata/utils/compare-two-flat-field-metadata.util'; +import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type'; +import { findFlatFieldMetadataInFlatObjectMetadataMapsWithOnlyFieldId } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/find-flat-field-metadata-in-flat-object-metadata-maps-with-field-id-only.util'; +import { + ObjectMetadataException, + ObjectMetadataExceptionCode, +} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; + +const fieldMetadataEditableProperties = + flatFieldMetadataPropertiesToCompare.filter( + ( + property, + ): property is Exclude< + FlatFieldMetadataPropertiesToCompare, + 'standardOverrides' + > => property !== 'standardOverrides', + ); + +type FromUpdateFieldInputToFlatFieldMetadataToUpdateArgs = { + existingFlatObjectMetadataMaps: FlatObjectMetadataMaps; + updateFieldInput: UpdateFieldInput; +}; +export const fromUpdateFieldInputToFlatFieldMetadataToUpdate = ({ + existingFlatObjectMetadataMaps, + updateFieldInput: rawUpdateFieldInput, +}: FromUpdateFieldInputToFlatFieldMetadataToUpdateArgs): FieldInputTranspilationResult => { + const updateFieldInputInformalProperties = + trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( + rawUpdateFieldInput, + ['objectMetadataId', 'id'], + ); + const updatedEditableFields = extractAndSanitizeObjectStringFields( + rawUpdateFieldInput, + fieldMetadataEditableProperties, + ); + + const relatedFlatFieldMetadata = + findFlatFieldMetadataInFlatObjectMetadataMapsWithOnlyFieldId({ + fieldMetadataId: updateFieldInputInformalProperties.id, + flatObjectMetadataMaps: existingFlatObjectMetadataMaps, + }); + + if (!isDefined(relatedFlatFieldMetadata)) { + return { + status: 'fail', + error: new FieldMetadataException( + 'Field metadata to update not found', + FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND, + ), + }; + } + + const flatObjectMetadataWithFlatFieldMaps = + existingFlatObjectMetadataMaps.byId[ + relatedFlatFieldMetadata.objectMetadataId + ]; + + if (!isDefined(flatObjectMetadataWithFlatFieldMaps)) { + return { + status: 'fail', + error: new FieldMetadataException( + 'Field metadata to update object metadata not found', + FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND, + ), + }; + } + + if (flatObjectMetadataWithFlatFieldMaps.isRemote) { + return { + status: 'fail', + error: new ObjectMetadataException( + 'Remote objects are read-only', + ObjectMetadataExceptionCode.OBJECT_MUTATION_NOT_ALLOWED, + ), + }; + } + const isStandardField = + relatedFlatFieldMetadata.standardId !== null && + !relatedFlatFieldMetadata.isCustom; + + if (isStandardField) { + const invalidUpdatedProperties = Object.keys(updatedEditableFields).filter( + (property) => + fieldMetadataStandardOverridesProperties.includes( + property as FieldMetadataStandardOverridesProperties, + ), + ); + + if (invalidUpdatedProperties.length > 0) { + return { + status: 'fail', + error: new FieldMetadataException( + `Cannot edit standard field metadata properties: ${invalidUpdatedProperties.join(', ')}`, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ), + }; + } + + const updatedStandardFlatFieldMetadata = + fieldMetadataStandardOverridesProperties.reduce((acc, property) => { + const isPropertyUpdated = updatedEditableFields[property] !== undefined; + + return { + ...acc, + standardOverrides: { + ...acc.standardOverrides, + ...(isPropertyUpdated + ? { [property]: updatedEditableFields[property] } + : {}), + }, + }; + }, relatedFlatFieldMetadata); + + return { + status: 'success', + result: updatedStandardFlatFieldMetadata, + }; + } + + const updatedFlatFieldMetadata = fieldMetadataEditableProperties.reduce( + (acc, property) => { + const isPropertyUpdated = updatedEditableFields[property] !== undefined; + + return { + ...acc, + ...(isPropertyUpdated + ? { [property]: updatedEditableFields[property] } + : {}), + }; + }, + relatedFlatFieldMetadata, + ); + + return { + status: 'success', + result: updatedFlatFieldMetadata, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-flat-field-metadata-name-availability.validator.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-flat-field-metadata-name-availability.validator.ts index fd2ab498f23..ea1446e755d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-flat-field-metadata-name-availability.validator.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-flat-field-metadata-name-availability.validator.ts @@ -37,16 +37,16 @@ const getReservedCompositeFieldNames = ( export const validateFlatFieldMetadataNameAvailability = ({ name, - objectMetadata, + flatObjectMetadata, }: { name: string; - objectMetadata: FlatObjectMetadata; + flatObjectMetadata: FlatObjectMetadata; }): FailedFlatFieldMetadataValidationExceptions | undefined => { const reservedCompositeFieldsNames = - getReservedCompositeFieldNames(objectMetadata); + getReservedCompositeFieldNames(flatObjectMetadata); if ( - objectMetadata.flatFieldMetadatas.some( + flatObjectMetadata.flatFieldMetadatas.some( (field) => field.name === name || (field.type === FieldMetadataType.RELATION && // Question: Should we also look for MORPH_RELATION field types ? diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util.ts index 50482b0f0ba..2a6a40fe81e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util.ts @@ -9,7 +9,7 @@ import { import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception'; import { validateMetadataNameIsNotTooLongOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-long.utils'; import { validateMetadataNameIsNotTooShortOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-short.utils'; -import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils'; +import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils'; export const validateObjectMetadataInputNamesOrThrow = < T extends UpdateObjectPayload | CreateObjectInput, diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/validate-metadata-name.spec.ts b/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/validate-metadata-name.spec.ts index 423f2c779c3..6a969d7e32d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/validate-metadata-name.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/validate-metadata-name.spec.ts @@ -1,6 +1,6 @@ import { type EachTestingContext } from 'twenty-shared/testing'; -import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils'; +import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils'; type ValidateMetadataNameTestContext = EachTestingContext<{ input: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils.ts new file mode 100644 index 00000000000..663bb029114 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils.ts @@ -0,0 +1,17 @@ +import { validateMetadataNameIsCamelCaseOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-camel-case.utils'; +import { validateMetadataNameIsNotReservedKeywordOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword'; +import { validateMetadataNameIsNotTooLongOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-long.utils'; +import { validateMetadataNameIsNotTooShortOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-short.utils'; +import { validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-start-with-lowercase-letter-and-contain-digits-nor-letters.utils'; + +export const validateMetadataNameOrThrow = (name: string): void => { + const validators = [ + validateMetadataNameIsNotTooLongOrThrow, + validateMetadataNameIsNotTooShortOrThrow, + validateMetadataNameIsCamelCaseOrThrow, + validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow, + validateMetadataNameIsNotReservedKeywordOrThrow, + ]; + + validators.forEach((validator) => validator(name)); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name.utils.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name.utils.ts index 663bb029114..167c06f4cdd 100644 --- a/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name.utils.ts +++ b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name.utils.ts @@ -1,17 +1,21 @@ -import { validateMetadataNameIsCamelCaseOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-camel-case.utils'; -import { validateMetadataNameIsNotReservedKeywordOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword'; -import { validateMetadataNameIsNotTooLongOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-long.utils'; -import { validateMetadataNameIsNotTooShortOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-short.utils'; -import { validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-start-with-lowercase-letter-and-contain-digits-nor-letters.utils'; +import { + FieldMetadataException, + FieldMetadataExceptionCode, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils'; -export const validateMetadataNameOrThrow = (name: string): void => { - const validators = [ - validateMetadataNameIsNotTooLongOrThrow, - validateMetadataNameIsNotTooShortOrThrow, - validateMetadataNameIsCamelCaseOrThrow, - validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow, - validateMetadataNameIsNotReservedKeywordOrThrow, - ]; - - validators.forEach((validator) => validator(name)); +export const validateMetadataName = ( + name: string, +): undefined | FieldMetadataException => { + try { + validateMetadataNameOrThrow(name); + } catch (error) { + return new FieldMetadataException( + error.message, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + { + userFriendlyMessage: error.userFriendlyMessage, + }, + ); + } };