diff --git a/packages/twenty-server/src/engine/core-modules/error/aggregate-error.ts b/packages/twenty-server/src/engine/core-modules/error/multiple-metadata-validation-errors.ts similarity index 72% rename from packages/twenty-server/src/engine/core-modules/error/aggregate-error.ts rename to packages/twenty-server/src/engine/core-modules/error/multiple-metadata-validation-errors.ts index 2af8ea9068f..170121ab5c1 100644 --- a/packages/twenty-server/src/engine/core-modules/error/aggregate-error.ts +++ b/packages/twenty-server/src/engine/core-modules/error/multiple-metadata-validation-errors.ts @@ -1,4 +1,4 @@ -export class AggregateError extends Error { +export class MultipleMetadataValidationErrors extends Error { constructor( public readonly errors: Error[], message = 'Multiple errors occurred', diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts index cecba278c11..b29f9564226 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts @@ -21,8 +21,10 @@ import { FieldMetadataMorphRelationService } from 'src/engine/metadata-modules/f import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service'; import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service'; import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service'; +import { FieldMetadataServiceV2 } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2'; import { IsFieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-default-value.validator'; import { IsFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-options.validator'; +import { FlatFieldMetadataModule } from 'src/engine/metadata-modules/flat-field-metadata/flat-field-metadata.module'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @@ -31,6 +33,8 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module'; +import { WorkspaceMigrationBuilderV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-builder-v2.module'; +import { WorkspaceMigrationRunnerV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-migration-runner-v2.module'; import { ViewModule } from 'src/modules/view/view.module'; import { FieldMetadataEntity } from './field-metadata.entity'; @@ -59,10 +63,14 @@ import { FieldMetadataService } from './services/field-metadata.service'; ViewModule, PermissionsModule, WorkspaceMetadataCacheModule, + WorkspaceMigrationBuilderV2Module, + WorkspaceMigrationRunnerV2Module, + FlatFieldMetadataModule, ], services: [ IsFieldMetadataDefaultValue, FieldMetadataService, + FieldMetadataServiceV2, FieldMetadataRelatedRecordsService, FieldMetadataMorphRelationService, FieldMetadataRelationService, @@ -101,6 +109,7 @@ import { FieldMetadataService } from './services/field-metadata.service'; IsFieldMetadataDefaultValue, IsFieldMetadataOptions, FieldMetadataService, + FieldMetadataServiceV2, FieldMetadataRelationService, FieldMetadataRelatedRecordsService, FieldMetadataMorphRelationService, @@ -111,6 +120,7 @@ import { FieldMetadataService } from './services/field-metadata.service'; ], exports: [ FieldMetadataService, + FieldMetadataServiceV2, FieldMetadataRelationService, FieldMetadataMorphRelationService, FieldMetadataRelatedRecordsService, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service.ts index eb766e644a6..bfee736abc3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { t } from '@lingui/core/macro'; import { isNonEmptyString } from '@sniptt/guards'; -import { FieldMetadataType } from 'twenty-shared/types'; +import { EnumFieldMetadataType, FieldMetadataType } from 'twenty-shared/types'; import { assertUnreachable, isDefined } from 'twenty-shared/utils'; import { z } from 'zod'; @@ -24,7 +24,6 @@ import { beneathDatabaseIdentifierMinimumLength, exceedsDatabaseIdentifierMaximumLength, } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils'; -import { EnumFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory'; import { isSnakeCaseString } from 'src/utils/is-snake-case-string'; type Validator = { 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 09721d3c907..fbdcc745b48 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 @@ -5,9 +5,13 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; -import { AggregateError } from 'src/engine/core-modules/error/aggregate-error'; +import { MultipleMetadataValidationErrors } from 'src/engine/core-modules/error/multiple-metadata-validation-errors'; import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { + FieldMetadataException, + FieldMetadataExceptionCode, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; import { FlatFieldMetadataValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service'; import { fromCreateFieldInputToFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadata.util'; import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; @@ -30,6 +34,21 @@ export class FieldMetadataServiceV2 extends TypeOrmQueryService { + const [createdFieldMetadata] = await this.createMany([fieldMetadataInput]); + + if (!isDefined(createdFieldMetadata)) { + throw new FieldMetadataException( + 'Failed to create field metadata', + FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + return createdFieldMetadata; + } + async createMany( fieldMetadataInputs: CreateFieldInput[], ): Promise { @@ -72,11 +91,9 @@ export class FieldMetadataServiceV2 extends TypeOrmQueryService 0) { - const errors = createdFlatFieldMetadataValidationResult.map( - (validationResult) => validationResult.error, - ); + const errors = createdFlatFieldMetadataValidationResult.flat(); - throw new AggregateError( + throw new MultipleMetadataValidationErrors( errors, 'Multiple validation errors occurred while creating field', ); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts index bfc2adc69bd..78771b6a00a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts @@ -30,6 +30,7 @@ import { FieldMetadataMorphRelationService } from 'src/engine/metadata-modules/f import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service'; import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service'; import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service'; +import { FieldMetadataServiceV2 } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2'; import { areFieldMetadatasTypeRelationOrMorphRelation } from 'src/engine/metadata-modules/field-metadata/utils/are-field-metadatas-type-relation-or-morph-relation.util'; import { assertDoesNotNullifyDefaultValueForNonNullableField } from 'src/engine/metadata-modules/field-metadata/utils/assert-does-not-nullify-default-value-for-non-nullable-field.util'; import { buildUpdatableStandardFieldInput } from 'src/engine/metadata-modules/field-metadata/utils/build-updatable-standard-field-input.util'; @@ -95,6 +96,7 @@ export class FieldMetadataService extends TypeOrmQueryService { + const isWorkspaceMigrationV2Enabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED, + fieldMetadataInput.workspaceId, + ); + + if (isWorkspaceMigrationV2Enabled) { + return this.fieldMetadataServiceV2.createOne(fieldMetadataInput); + } + const [createdFieldMetadata] = await this.createMany([fieldMetadataInput]); if (!isDefined(createdFieldMetadata)) { @@ -566,14 +578,22 @@ export class FieldMetadataService extends TypeOrmQueryService { + return []; + }, + ADDRESS: async (_args) => { + return []; + }, + ARRAY: async (_args) => { + return []; + }, + BOOLEAN: async (_args) => { + return []; + }, + CURRENCY: async (_args) => { + return []; + }, + DATE: async (_args) => { + return []; + }, + DATE_TIME: async (_args) => { + return []; + }, + EMAILS: async (_args) => { + return []; + }, + FULL_NAME: async (_args) => { + return []; + }, + LINKS: async (_args) => { + return []; + }, + MORPH_RELATION: async ({ workspaceId }) => { + const isMorphRelationEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_MORPH_RELATION_ENABLED, + workspaceId, + ); + + if (!isMorphRelationEnabled) { + return [ + new FieldMetadataException( + 'Morph relation feature is disabled', + FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + ), + ]; + } + + return []; + }, + MULTI_SELECT: (args) => { + if (!isEnumValidateOneFieldMetadata(args)) { + throw new FieldMetadataException( + 'Should never occur, invaliad enum field metadata type', + FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + return validateEnumSelectFlatFieldMetadata(args); + }, + NUMBER: async (_args) => { + return []; + }, + NUMERIC: async (_args) => { + return []; + }, + PHONES: async (_args) => { + return []; + }, + POSITION: async (_args) => { + return []; + }, + RATING: (args) => { + if (!isEnumValidateOneFieldMetadata(args)) { + throw new FieldMetadataException( + 'Should never occur, invaliad enum field metadata type', + FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + return validateEnumSelectFlatFieldMetadata(args); + }, + RAW_JSON: async (_args) => { + return []; + }, + RELATION: validateRelationFlatFieldMetadata, + RICH_TEXT: async (_args) => { + return []; + }, + RICH_TEXT_V2: async (_args) => { + return []; + }, + SELECT: (args) => { + if (!isEnumValidateOneFieldMetadata(args)) { + throw new FieldMetadataException( + 'Should never occur, invaliad enum field metadata type', + FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + return validateEnumSelectFlatFieldMetadata(args); + }, + TEXT: async (_args) => { + return []; + }, + TS_VECTOR: async (_args) => { + return []; + }, + UUID: async (_args) => { + return []; + }, + }; +} 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 a2bb1faf026..5d266d029e2 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,16 +1,14 @@ import { Injectable } from '@nestjs/common'; -import { Expect } from 'twenty-shared/testing'; import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -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 { FailedFlatFieldMetadataValidation } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type'; +import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service'; +import { FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type'; import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { validateFlatFieldMetadataNameAvailability } from 'src/engine/metadata-modules/flat-field-metadata/validators/validate-flat-field-metadata-name-availability.validator'; import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; @@ -25,25 +23,32 @@ import { import { validateMetadataNameOrThrow } 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'; -type ValidateOneFieldMetadataArgs = { +export type ValidateOneFieldMetadataArgs< + T extends FieldMetadataType = FieldMetadataType, +> = { existingFlatObjectMetadatas: FlatObjectMetadata[]; othersFlatObjectMetadataToValidate?: FlatObjectMetadata[]; - flatFieldMetadataToValidate: FlatFieldMetadata; + flatFieldMetadataToValidate: FlatFieldMetadata; workspaceId: string; }; @Injectable() export class FlatFieldMetadataValidatorService { - constructor(private readonly featureFlagService: FeatureFlagService) {} + constructor( + private readonly flatFieldMetadataTypeValidatorService: FlatFieldMetadataTypeValidatorService, + ) {} - async validateOneFlatFieldMetadata({ + async validateOneFlatFieldMetadata< + T extends FieldMetadataType = FieldMetadataType, + >({ existingFlatObjectMetadatas, flatFieldMetadataToValidate, othersFlatObjectMetadataToValidate, workspaceId, - }: ValidateOneFieldMetadataArgs): Promise< - FailedFlatFieldMetadataValidation | undefined + }: ValidateOneFieldMetadataArgs): Promise< + FailedFlatFieldMetadataValidationExceptions[] > { + const errors: FailedFlatFieldMetadataValidationExceptions[] = []; const allFlatObjectMetadata = [ ...existingFlatObjectMetadatas, ...(othersFlatObjectMetadataToValidate ?? []), @@ -55,25 +60,33 @@ export class FlatFieldMetadataValidatorService { ); if (!isDefined(parentFlatObjectMetadata)) { - return { - status: 'fail', - error: new FieldMetadataException( + errors.push( + new FieldMetadataException( isDefined(othersFlatObjectMetadataToValidate) - ? 'Object metadata does not exist in both existing and about to be created object metadatas' - : 'Object metadata does not exist', + ? 'Object metadata not found in both existing and about to be created object metadatas' + : 'Object metadata not found', FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND, ), - }; - } + ); + } else { + if (parentFlatObjectMetadata.isRemote === true) { + errors.push( + new ObjectMetadataException( + 'Remote objects are read-only', + ObjectMetadataExceptionCode.OBJECT_MUTATION_NOT_ALLOWED, + ), + ); + } - if (parentFlatObjectMetadata.isRemote === true) { - return { - status: 'fail', - error: new ObjectMetadataException( - 'Remote objects are read-only', - ObjectMetadataExceptionCode.OBJECT_MUTATION_NOT_ALLOWED, - ), - }; + const failedNameAvailabilityValidation = + validateFlatFieldMetadataNameAvailability({ + name: flatFieldMetadataToValidate.name, + objectMetadata: parentFlatObjectMetadata, + }); + + if (isDefined(failedNameAvailabilityValidation)) { + errors.push(failedNameAvailabilityValidation); + } } if (flatFieldMetadataToValidate.isLabelSyncedWithName) { @@ -82,102 +95,54 @@ export class FlatFieldMetadataValidatorService { ); if (flatFieldMetadataToValidate.name !== computedName) { - return { - status: 'fail', - error: new InvalidMetadataException( + errors.push( + new InvalidMetadataException( `Name is not synced with label. Expected name: "${computedName}", got ${flatFieldMetadataToValidate.name}`, InvalidMetadataExceptionCode.NAME_NOT_SYNCED_WITH_LABEL, ), - }; + ); } } try { validateMetadataNameOrThrow(flatFieldMetadataToValidate.name); } catch (error) { - return { - status: 'fail', - error: new FieldMetadataException( + errors.push( + new FieldMetadataException( error.message, FieldMetadataExceptionCode.INVALID_FIELD_INPUT, { userFriendlyMessage: error.userFriendlyMessage, }, ), - }; + ); } - const failedNameAvailabilityValidation = - validateFlatFieldMetadataNameAvailability({ - name: flatFieldMetadataToValidate.name, - objectMetadata: parentFlatObjectMetadata, - }); + const fieldMetadataTypeValidator = + this.flatFieldMetadataTypeValidatorService + .FIELD_METADATA_TYPE_VALIDATOR_HASHMAP[ + flatFieldMetadataToValidate.type + ]; - if (isDefined(failedNameAvailabilityValidation)) { - return failedNameAvailabilityValidation; + if (!isDefined(fieldMetadataTypeValidator)) { + errors.push( + new FieldMetadataException( + 'Unsupported field metadata type', + FieldMetadataExceptionCode.UNCOVERED_FIELD_METADATA_TYPE_VALIDATION, + ), + ); + } else { + const fieldMetadataTypeValidatorExceptions = + await fieldMetadataTypeValidator({ + existingFlatObjectMetadatas, + flatFieldMetadataToValidate, + workspaceId, + othersFlatObjectMetadataToValidate, + }); + + errors.push(...fieldMetadataTypeValidatorExceptions); } - // We should validate each default value and settings and options - // We should also handle relation and stuff - switch (flatFieldMetadataToValidate.type) { - case FieldMetadataType.MORPH_RELATION: { - const isMorphRelationEnabled = - await this.featureFlagService.isFeatureEnabled( - FeatureFlagKey.IS_MORPH_RELATION_ENABLED, - workspaceId, - ); - - if (!isMorphRelationEnabled) { - return { - status: 'fail', - error: new FieldMetadataException( - 'Morph relation feature is disabled', - FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, - ), - }; - } - - return; - } - case FieldMetadataType.RELATION: - case FieldMetadataType.UUID: - case FieldMetadataType.TEXT: - case FieldMetadataType.PHONES: - case FieldMetadataType.EMAILS: - case FieldMetadataType.DATE_TIME: - case FieldMetadataType.DATE: - case FieldMetadataType.BOOLEAN: - case FieldMetadataType.NUMBER: - case FieldMetadataType.NUMERIC: - case FieldMetadataType.LINKS: - case FieldMetadataType.CURRENCY: - case FieldMetadataType.FULL_NAME: - case FieldMetadataType.RATING: - case FieldMetadataType.SELECT: - case FieldMetadataType.MULTI_SELECT: - case FieldMetadataType.POSITION: - case FieldMetadataType.ADDRESS: - case FieldMetadataType.RAW_JSON: - case FieldMetadataType.RICH_TEXT: - case FieldMetadataType.RICH_TEXT_V2: - case FieldMetadataType.ACTOR: - case FieldMetadataType.ARRAY: - case FieldMetadataType.TS_VECTOR: { - return; - } - default: { - const _staticTypeCheck: Expect< - typeof flatFieldMetadataToValidate.type extends never ? true : false - > = true; - - return { - status: 'fail', - error: new FieldMetadataException( - 'Unsupported field metadata type', - FieldMetadataExceptionCode.UNCOVERED_FIELD_METADATA_TYPE_VALIDATION, - ), - }; - } - } + return errors; } } diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type.ts index 758342769ee..30364e58cc8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type.ts @@ -1,11 +1,45 @@ -import { FieldMetadataException } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { isDefined } from 'twenty-shared/utils'; + +import { + FieldMetadataException, + FieldMetadataExceptionCode, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; import { ObjectMetadataException } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception'; -export type FailedFlatFieldMetadataValidation = { - status: 'fail'; - error: - | FieldMetadataException - | ObjectMetadataException - | InvalidMetadataException; +export type FailedFlatFieldMetadataValidationExceptions = + | FieldMetadataException + | ObjectMetadataException + | InvalidMetadataException; + +export type FlatFieldMetadataValidator = { + validator: (value: T) => boolean; + message: string; }; + +export const runFlatFieldMetadataValidator = ( + elementToValidate: T, + { message, validator }: FlatFieldMetadataValidator, +): FailedFlatFieldMetadataValidationExceptions | undefined => { + const validationFailed = validator(elementToValidate); + + if (validationFailed) { + return new FieldMetadataException( + message, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + { + userFriendlyMessage: message, + }, + ); + } +}; + +export const runFlatFieldMetadataValidators = ( + elementToValidate: T, + validators: FlatFieldMetadataValidator[], +): FailedFlatFieldMetadataValidationExceptions[] => + validators + .map((validator) => + runFlatFieldMetadataValidator(elementToValidate, validator), + ) + .filter(isDefined); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type.ts new file mode 100644 index 00000000000..e762051ff9e --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type.ts @@ -0,0 +1,12 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +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'; + +export type FlatFieldMetadataTypeValidator = { + [P in FieldMetadataType]: ( + args: ValidateOneFieldMetadataArgs

, + ) => + | FailedFlatFieldMetadataValidationExceptions[] + | Promise; +}; 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 f65e8dbc900..fe5cd938072 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 @@ -1,4 +1,4 @@ -import { sanitizeObjectStringFields } from 'twenty-shared/utils'; +import { extractAndSanitizeObjectStringFields } from 'twenty-shared/utils'; import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; import { generateNullable } from 'src/engine/metadata-modules/field-metadata/utils/generate-nullable'; @@ -12,7 +12,7 @@ export const getDefaultFlatFieldMetadata = ({ createFieldInput, fieldMetadataId, }: GetDefaultFlatFieldMetadataArgs) => { - const { defaultValue, settings } = sanitizeObjectStringFields( + const { defaultValue, settings } = extractAndSanitizeObjectStringFields( createFieldInput, ['defaultValue', 'settings'], ); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/is-enum-validate-one-field-metadata-args.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/is-enum-validate-one-field-metadata-args.util.ts new file mode 100644 index 00000000000..538498a0bf7 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/is-enum-validate-one-field-metadata-args.util.ts @@ -0,0 +1,10 @@ +import { EnumFieldMetadataType, FieldMetadataType } from 'twenty-shared/types'; + +import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util'; +import { ValidateOneFieldMetadataArgs } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service'; + +export const isEnumValidateOneFieldMetadata = ( + args: ValidateOneFieldMetadataArgs, +): args is ValidateOneFieldMetadataArgs & + ValidateOneFieldMetadataArgs => + isEnumFieldMetadataType(args.flatFieldMetadataToValidate.type); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-enum-flat-field-metadata.validator.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-enum-flat-field-metadata.validator.ts new file mode 100644 index 00000000000..21efc5bdddd --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-enum-flat-field-metadata.validator.ts @@ -0,0 +1,260 @@ +import { t } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { QUOTED_STRING_REGEX } from 'twenty-shared/constants'; +import { + EnumFieldMetadataType, + FieldMetadataType, + NonNullableRequired, +} from 'twenty-shared/types'; +import { assertUnreachable, isDefined } from 'twenty-shared/utils'; +import { z } from 'zod'; + +import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-options.interface'; + +import { + FieldMetadataComplexOption, + FieldMetadataDefaultOption, +} from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; +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, + FlatFieldMetadataValidator, + runFlatFieldMetadataValidators, +} from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type'; +import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { + beneathDatabaseIdentifierMinimumLength, + exceedsDatabaseIdentifierMaximumLength, +} from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils'; +import { isSnakeCaseString } from 'src/utils/is-snake-case-string'; + +const validateMetadataOptionId = (sanitizedId?: string) => { + const validators: FlatFieldMetadataValidator[] = [ + { + validator: (id) => !isDefined(id), + message: t`Option id is required`, + }, + { + validator: (id) => !z.string().uuid().safeParse(id).success, + message: t`Option id is invalid`, + }, + ]; + + return runFlatFieldMetadataValidators(sanitizedId, validators); +}; + +const validateMetadataOptionLabel = (sanitizedLabel: string) => { + const validators: FlatFieldMetadataValidator[] = [ + { + validator: (label) => !isDefined(label), + message: t`Option label is required`, + }, + { + validator: exceedsDatabaseIdentifierMaximumLength, + message: t`Option label exceeds 63 characters`, + }, + { + validator: beneathDatabaseIdentifierMinimumLength, + message: t`Option label "${sanitizedLabel}" is beneath 1 character`, + }, + { + validator: (label) => label.includes(','), + message: t`Label must not contain a comma`, + }, + { + validator: (label) => !isNonEmptyString(label) || label === ' ', + message: t`Label must not be empty`, + }, + ]; + + return runFlatFieldMetadataValidators(sanitizedLabel, validators); +}; + +const validateMetadataOptionValue = (sanitizedValue: string) => { + const validators: FlatFieldMetadataValidator[] = [ + { + validator: (value) => !isDefined(value), + message: t`Option value is required`, + }, + { + validator: exceedsDatabaseIdentifierMaximumLength, + message: t`Option value exceeds 63 characters`, + }, + { + validator: beneathDatabaseIdentifierMinimumLength, + message: t`Option value "${sanitizedValue}" is beneath 1 character`, + }, + { + validator: (value) => !isSnakeCaseString(value), + message: t`Value must be in UPPER_CASE and follow snake_case "${sanitizedValue}"`, + }, + ]; + + return runFlatFieldMetadataValidators(sanitizedValue, validators); +}; + +const validateDuplicates = ( + options: FieldMetadataOptions, +) => { + const fieldsToCheckForDuplicates = [ + 'position', + 'id', + 'value', + ] as const satisfies (keyof ( + | FieldMetadataDefaultOption[] + | FieldMetadataComplexOption[] + )[number])[]; + const duplicatedValidators = fieldsToCheckForDuplicates.map< + FlatFieldMetadataValidator< + FieldMetadataDefaultOption[] | FieldMetadataComplexOption[] + > + >((field) => ({ + message: t`Duplicated option ${field}`, + validator: () => + new Set(options.map((option) => option[field])).size !== options.length, + })); + + return runFlatFieldMetadataValidators(options, duplicatedValidators); +}; + +const validateFieldMetadataInputOptions = ( + flatFieldMetadata: FlatFieldMetadata, +) => { + const { options } = flatFieldMetadata; + + if (!isDefined(options) || options.length === 0) { + return [ + new FieldMetadataException( + 'Options are required for enum fields', + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ), + ]; + } + + const optionsValidationErrors = options.flatMap((option) => + [ + validateMetadataOptionId(option.id), + validateMetadataOptionValue(option.value), + validateMetadataOptionLabel(option.label), + ].flat(), + ); + + const duplicatedValidationErrors = validateDuplicates(options); + + return [...optionsValidationErrors, ...duplicatedValidationErrors]; +}; + +const validateSelectDefaultValue = ( + options: FieldMetadataOptions, + defaultValue: string | string[], +): FailedFlatFieldMetadataValidationExceptions[] => { + if (typeof defaultValue !== 'string') { + return [ + new FieldMetadataException( + 'Default value for select must be a string', + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ), + ]; + } + + const validators: FlatFieldMetadataValidator[] = [ + { + validator: (value: string) => !QUOTED_STRING_REGEX.test(value), + message: 'Default value should be as quoted string', + }, + { + validator: (value: string) => + !options.some( + (option) => option.value === value.replace(QUOTED_STRING_REGEX, '$1'), + ), + message: `Default value "${defaultValue}" must be one of the option values`, + }, + ]; + + return runFlatFieldMetadataValidators(defaultValue, validators); +}; + +const validateMultiSelectDefaultValue = ( + options: FieldMetadataOptions, + multiSelectDefaultValue: unknown, +): FailedFlatFieldMetadataValidationExceptions[] => { + if (!Array.isArray(multiSelectDefaultValue)) { + return [ + new FieldMetadataException( + 'Default value for multi-select must be an array', + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ), + ]; + } + + const validators: FlatFieldMetadataValidator[] = [ + { + validator: (values) => values.length === 0, + message: 'If defined default value must contain at least one value', + }, + { + validator: (values) => new Set(values).size !== values.length, + message: 'Default values must be unique', + }, + ]; + + return [ + runFlatFieldMetadataValidators(multiSelectDefaultValue, validators), + multiSelectDefaultValue.flatMap((value) => + validateSelectDefaultValue(options, value), + ), + ].flat(); +}; + +const validateFieldMetadataDefaultValue = ( + flatFieldMetadata: Omit< + FlatFieldMetadata, + 'defaultValue' + > & + NonNullableRequired< + Pick, 'defaultValue'> + >, +) => { + switch (flatFieldMetadata.type) { + case FieldMetadataType.SELECT: + case FieldMetadataType.RATING: + return validateSelectDefaultValue( + flatFieldMetadata.options, + flatFieldMetadata.defaultValue, + ); + case FieldMetadataType.MULTI_SELECT: + return validateMultiSelectDefaultValue( + flatFieldMetadata.options, + flatFieldMetadata.defaultValue, + ); + default: { + assertUnreachable( + flatFieldMetadata.type, + 'Should never occur, unknown field metadata enum type', + ); + } + } +}; + +export const validateEnumSelectFlatFieldMetadata = ({ + flatFieldMetadataToValidate, +}: ValidateOneFieldMetadataArgs): FailedFlatFieldMetadataValidationExceptions[] => { + const optionsValidationErrors = validateFieldMetadataInputOptions( + flatFieldMetadataToValidate, + ); + + const defaultValueValidationErrors = isDefined( + flatFieldMetadataToValidate.defaultValue, + ) + ? validateFieldMetadataDefaultValue({ + ...flatFieldMetadataToValidate, + defaultValue: flatFieldMetadataToValidate.defaultValue, + }) + : []; + + return [...optionsValidationErrors, ...defaultValueValidationErrors]; +}; 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 8bd4f88ac9f..588be387042 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 @@ -5,7 +5,7 @@ import { isDefined } from 'twenty-shared/utils'; import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; 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 { FailedFlatFieldMetadataValidation } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type'; +import { FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type'; import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { InvalidMetadataException, @@ -41,7 +41,7 @@ export const validateFlatFieldMetadataNameAvailability = ({ }: { name: string; objectMetadata: FlatObjectMetadata; -}): FailedFlatFieldMetadataValidation | undefined => { +}): FailedFlatFieldMetadataValidationExceptions | undefined => { const reservedCompositeFieldsNames = getReservedCompositeFieldNames(objectMetadata); @@ -53,29 +53,23 @@ export const validateFlatFieldMetadataNameAvailability = ({ `${field.name}Id` === name), ) ) { - return { - status: 'fail', - error: new InvalidMetadataException( - `Name "${name}" is not available as it is already used by another field`, - InvalidMetadataExceptionCode.NOT_AVAILABLE, - { - userFriendlyMessage: t`This name is not available as it is already used by another field`, - }, - ), - }; + return new InvalidMetadataException( + `Name "${name}" is not available as it is already used by another field`, + InvalidMetadataExceptionCode.NOT_AVAILABLE, + { + userFriendlyMessage: t`This name is not available as it is already used by another field`, + }, + ); } if (reservedCompositeFieldsNames.includes(name)) { - return { - status: 'fail', - error: new InvalidMetadataException( - `Name "${name}" is not available`, - InvalidMetadataExceptionCode.RESERVED_KEYWORD, - { - userFriendlyMessage: t`This name is not available.`, - }, - ), - }; + return new InvalidMetadataException( + `Name "${name}" is not available`, + InvalidMetadataExceptionCode.RESERVED_KEYWORD, + { + userFriendlyMessage: t`This name is not available.`, + }, + ); } return undefined; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-relation-flat-field-metadata.validator.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-relation-flat-field-metadata.validator.ts new file mode 100644 index 00000000000..0daf457fd9f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-relation-flat-field-metadata.validator.ts @@ -0,0 +1,82 @@ +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined, isValidUuid } from 'twenty-shared/utils'; + +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 { mergeTwoFlatObjectMetadatas } from 'src/engine/metadata-modules/flat-object-metadata/utils/merge-two-flat-object-metadatas.util'; + +// Remark: This is duplicated with api metadata transpilers exceptions handlers +// We might wanna have an NotValidatedFlatFieldMetadata and NotValidatedFlatObjectMetadata, in order to avoid +export const validateRelationFlatFieldMetadata = async ({ + existingFlatObjectMetadatas, + flatFieldMetadataToValidate: { + relationTargetFieldMetadataId, + relationTargetObjectMetadataId, + }, + othersFlatObjectMetadataToValidate, +}: ValidateOneFieldMetadataArgs): Promise< + FailedFlatFieldMetadataValidationExceptions[] +> => { + const uuidsValidation = [ + relationTargetObjectMetadataId, + relationTargetFieldMetadataId, + ].flatMap((id) => + isValidUuid(id) + ? [] + : new FieldMetadataException( + `Invalid uuid ${id}`, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ), + ); + + if (uuidsValidation.length > 0) { + return uuidsValidation; + } + + const errors: FailedFlatFieldMetadataValidationExceptions[] = []; + const allFlatObjectMetadata = isDefined(othersFlatObjectMetadataToValidate) + ? mergeTwoFlatObjectMetadatas({ + destFlatObjectMetadatas: existingFlatObjectMetadatas, + toMergeFlatObjectMetadatas: othersFlatObjectMetadataToValidate, + }) + : existingFlatObjectMetadatas; + + const targetRelationFlatObjectMetadata = allFlatObjectMetadata.find( + (flatObjectMetadata) => + flatObjectMetadata.id === relationTargetObjectMetadataId, + ); + + if (!isDefined(targetRelationFlatObjectMetadata)) { + errors.push( + new FieldMetadataException( + isDefined(othersFlatObjectMetadataToValidate) + ? 'Relation target object metadata not found in both existing and about to be created object metadatas' + : 'Relation target object metadata not found', + FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND, + ), + ); + } + + const targetRelationFlatFieldMetadata = + targetRelationFlatObjectMetadata?.flatFieldMetadatas.find( + (flatFieldMetadata) => + flatFieldMetadata.id === relationTargetFieldMetadataId, + ); + + if (!isDefined(targetRelationFlatFieldMetadata)) { + errors.push( + new FieldMetadataException( + isDefined(othersFlatObjectMetadataToValidate) + ? 'Relation field target metadata not found in both existing and about to be created field metadatas' + : 'Relation field target metadata not found', + FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND, + ), + ); + } + + return errors; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory.ts index f4e93610229..09aa5e55997 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; -import { FieldMetadataType } from 'twenty-shared/types'; +import { EnumFieldMetadataType, FieldMetadataType } from 'twenty-shared/types'; import { WorkspaceColumnActionOptions } from 'src/engine/metadata-modules/workspace-migration/interfaces/workspace-column-action-options.interface'; @@ -19,11 +19,6 @@ import { WorkspaceMigrationExceptionCode, } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.exception'; -export type EnumFieldMetadataType = - | FieldMetadataType.RATING - | FieldMetadataType.SELECT - | FieldMetadataType.MULTI_SELECT; - @Injectable() export class EnumColumnActionFactory extends ColumnActionAbstractFactory { protected readonly logger = new Logger(EnumColumnActionFactory.name); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-metadata-migration-runner/workspace-metadata-object-action-runner.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-metadata-migration-runner/workspace-metadata-object-action-runner.service.ts index ea89cfb549e..402e4152fa9 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-metadata-migration-runner/workspace-metadata-object-action-runner.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-metadata-migration-runner/workspace-metadata-object-action-runner.service.ts @@ -49,6 +49,7 @@ export class WorkspaceMetadataObjectActionRunnerService ...flatObjectMetadataWithoutFields, dataSourceId: lastDataSourceMetadata.id, targetTableName: 'DEPRECATED', + // TODO call for each provided field too or pass fields here }); }; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts index de0220ee183..728df6d9932 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts @@ -7,14 +7,13 @@ import { createOneObjectMetadata } from 'test/integration/metadata/suites/object import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input'; import { EachTestingContext } from 'twenty-shared/testing'; -import { FieldMetadataType } from 'twenty-shared/types'; +import { EnumFieldMetadataType, FieldMetadataType } from 'twenty-shared/types'; import { isDefined, parseJson } from 'twenty-shared/utils'; import { FieldMetadataComplexOption, FieldMetadataDefaultOption, } from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; -import { EnumFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory'; type Option = FieldMetadataDefaultOption | FieldMetadataComplexOption; diff --git a/packages/twenty-shared/src/constants/QuotedStringRegex.ts b/packages/twenty-shared/src/constants/QuotedStringRegex.ts new file mode 100644 index 00000000000..82705232491 --- /dev/null +++ b/packages/twenty-shared/src/constants/QuotedStringRegex.ts @@ -0,0 +1 @@ +export const QUOTED_STRING_REGEX = /^['"](.*)['"]$/; diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts index 448031a871d..c7a000e3348 100644 --- a/packages/twenty-shared/src/constants/index.ts +++ b/packages/twenty-shared/src/constants/index.ts @@ -14,6 +14,7 @@ export { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from './LabelIdentifierFieldMet export { MUTATION_MAX_MERGE_RECORDS } from './MutationMaxMergeRecords'; export { PermissionsOnAllObjectRecords } from './PermissionsOnAllObjectRecords'; export { QUERY_MAX_RECORDS } from './QueryMaxRecords'; +export { QUOTED_STRING_REGEX } from './QuotedStringRegex'; export { RELATION_NESTED_QUERY_KEYWORDS } from './RelationNestedQueriesKeyword'; export { STANDARD_OBJECT_RECORDS_UNDER_OBJECT_RECORDS_PERMISSIONS } from './StandardObjectRecordsUnderObjectRecordsPermissions'; export { TWENTY_COMPANIES_BASE_URL } from './TwentyCompaniesBaseUrl'; diff --git a/packages/twenty-shared/src/types/EnumFieldMetadataType.ts b/packages/twenty-shared/src/types/EnumFieldMetadataType.ts new file mode 100644 index 00000000000..b0c66fe2c54 --- /dev/null +++ b/packages/twenty-shared/src/types/EnumFieldMetadataType.ts @@ -0,0 +1,6 @@ +import { FieldMetadataType } from './FieldMetadataType'; + +export type EnumFieldMetadataType = + | FieldMetadataType.RATING + | FieldMetadataType.SELECT + | FieldMetadataType.MULTI_SELECT; diff --git a/packages/twenty-shared/src/types/NonNullableRequired.ts b/packages/twenty-shared/src/types/NonNullableRequired.ts new file mode 100644 index 00000000000..88300116d3d --- /dev/null +++ b/packages/twenty-shared/src/types/NonNullableRequired.ts @@ -0,0 +1,3 @@ +export type NonNullableRequired = { + [P in keyof T]-?: NonNullable; +}; diff --git a/packages/twenty-shared/src/types/NullablePartial.ts b/packages/twenty-shared/src/types/NullablePartial.ts index 369da30f3fe..5b05dd7d0b3 100644 --- a/packages/twenty-shared/src/types/NullablePartial.ts +++ b/packages/twenty-shared/src/types/NullablePartial.ts @@ -1,3 +1,3 @@ export type NullablePartial = { [P in keyof T]: T[P] | null; -}; \ No newline at end of file +}; diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index bb95c46cc4e..ae7c777897a 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -9,9 +9,11 @@ export type { ConfigVariableValue } from './ConfigVariableValue'; export { ConnectedAccountProvider } from './ConnectedAccountProvider'; +export type { EnumFieldMetadataType } from './EnumFieldMetadataType'; export { FieldMetadataType } from './FieldMetadataType'; export type { FromTo } from './FromToType'; export type { IsExactly } from './IsExactly'; +export type { NonNullableRequired } from './NonNullableRequired'; export type { NullablePartial } from './NullablePartial'; export type { ObjectRecordsPermissions } from './ObjectRecordsPermissions'; export type { ObjectRecordsPermissionsByRoleId } from './ObjectRecordsPermissionsByRoleId'; diff --git a/packages/twenty-shared/src/utils/__tests__/deepMerge.test.ts b/packages/twenty-shared/src/utils/__tests__/deepMerge.test.ts index 2762d0608dc..9ca7a878674 100644 --- a/packages/twenty-shared/src/utils/__tests__/deepMerge.test.ts +++ b/packages/twenty-shared/src/utils/__tests__/deepMerge.test.ts @@ -11,7 +11,9 @@ describe('deepMerge', () => { describe('primitive values', () => { type PrimitiveValue = { value: string | number | boolean }; - const primitiveTestCases: EachTestingContext>[] = [ + const primitiveTestCases: EachTestingContext< + DeepMergeTestCase + >[] = [ { title: 'should override string values', context: { @@ -38,15 +40,20 @@ describe('deepMerge', () => { }, ]; - it.each(primitiveTestCases)('$title', ({ context: { source, target, expected } }) => { - expect(deepMerge(source, target)).toEqual(expected); - }); + it.each(primitiveTestCases)( + '$title', + ({ context: { source, target, expected } }) => { + expect(deepMerge(source, target)).toEqual(expected); + }, + ); }); describe('null and undefined handling', () => { type NullableValue = { value: string | null }; - const nullTestCases: EachTestingContext>[] = [ + const nullTestCases: EachTestingContext< + DeepMergeTestCase + >[] = [ { title: 'should preserve null values from target', context: { @@ -73,13 +80,18 @@ describe('deepMerge', () => { }, ]; - it.each(nullTestCases)('$title', ({ context: { source, target, expected } }) => { - expect(deepMerge(source, target)).toEqual(expected); - }); + it.each(nullTestCases)( + '$title', + ({ context: { source, target, expected } }) => { + expect(deepMerge(source, target)).toEqual(expected); + }, + ); type MixedNullValue = { a: number | null; b: number }; - const mixedNullTestCase: EachTestingContext> = { + const mixedNullTestCase: EachTestingContext< + DeepMergeTestCase + > = { title: 'should handle mixed null and undefined values', context: { source: { a: 1, b: 2 }, @@ -97,44 +109,48 @@ describe('deepMerge', () => { describe('array handling', () => { type ArrayValue = { arr: Array | null }; - const arrayTestCases: EachTestingContext>[] = [ - { - title: 'should concatenate arrays', - context: { - source: { arr: [1, 2] }, - target: { arr: [3, 4] }, - expected: { arr: [1, 2, 3, 4] }, + const arrayTestCases: EachTestingContext>[] = + [ + { + title: 'should concatenate arrays', + context: { + source: { arr: [1, 2] }, + target: { arr: [3, 4] }, + expected: { arr: [1, 2, 3, 4] }, + }, }, - }, - { - title: 'should handle empty target array', - context: { - source: { arr: [1, 2] }, - target: { arr: [] }, - expected: { arr: [1, 2] }, + { + title: 'should handle empty target array', + context: { + source: { arr: [1, 2] }, + target: { arr: [] }, + expected: { arr: [1, 2] }, + }, }, - }, - { - title: 'should handle empty source array', - context: { - source: { arr: [] }, - target: { arr: [1, 2] }, - expected: { arr: [1, 2] }, + { + title: 'should handle empty source array', + context: { + source: { arr: [] }, + target: { arr: [1, 2] }, + expected: { arr: [1, 2] }, + }, }, - }, - { - title: 'should handle null target array', - context: { - source: { arr: ['a', 'b'] }, - target: { arr: null }, - expected: { arr: null }, + { + title: 'should handle null target array', + context: { + source: { arr: ['a', 'b'] }, + target: { arr: null }, + expected: { arr: null }, + }, }, - }, - ]; + ]; - it.each(arrayTestCases)('$title', ({ context: { source, target, expected } }) => { - expect(deepMerge(source, target)).toEqual(expected); - }); + it.each(arrayTestCases)( + '$title', + ({ context: { source, target, expected } }) => { + expect(deepMerge(source, target)).toEqual(expected); + }, + ); }); describe('nested objects', () => { @@ -154,7 +170,9 @@ describe('deepMerge', () => { } | null; }; - const nestedTestCases: EachTestingContext>[] = [ + const nestedTestCases: EachTestingContext< + DeepMergeTestCase + >[] = [ { title: 'should merge nested objects', context: { @@ -166,9 +184,33 @@ describe('deepMerge', () => { { title: 'should merge deeply nested objects', context: { - source: { nested: { a: 1, b: 2, deep: { a: 1, b: 2 }, arr: [], obj: { a: 1, b: 2 } } }, - target: { nested: { a: 1, b: 2, deep: { a: 1, b: 3 }, arr: [], obj: { a: 1, b: 2 } } }, - expected: { nested: { a: 1, b: 2, deep: { a: 1, b: 3 }, arr: [], obj: { a: 1, b: 2 } } }, + source: { + nested: { + a: 1, + b: 2, + deep: { a: 1, b: 2 }, + arr: [], + obj: { a: 1, b: 2 }, + }, + }, + target: { + nested: { + a: 1, + b: 2, + deep: { a: 1, b: 3 }, + arr: [], + obj: { a: 1, b: 2 }, + }, + }, + expected: { + nested: { + a: 1, + b: 2, + deep: { a: 1, b: 3 }, + arr: [], + obj: { a: 1, b: 2 }, + }, + }, }, }, { @@ -210,9 +252,12 @@ describe('deepMerge', () => { }, ]; - it.each(nestedTestCases)('$title', ({ context: { source, target, expected } }) => { - expect(deepMerge(source, target)).toEqual(expected); - }); + it.each(nestedTestCases)( + '$title', + ({ context: { source, target, expected } }) => { + expect(deepMerge(source, target)).toEqual(expected); + }, + ); }); describe('edge cases', () => { @@ -248,27 +293,32 @@ describe('deepMerge', () => { }, ]; - it.each(edgeTestCases)('$title', ({ context: { source, target, expected } }) => { - expect(deepMerge(source, target)).toEqual(expected); - }); + it.each(edgeTestCases)( + '$title', + ({ context: { source, target, expected } }) => { + expect(deepMerge(source, target)).toEqual(expected); + }, + ); type NestedDateValue = { a: { value: Date }; b: { value: Date }; }; - const nestedDateTestCase: EachTestingContext> = { + const nestedDateTestCase: EachTestingContext< + DeepMergeTestCase + > = { title: 'should handle Date objects in nested structures', context: { - source: { + source: { a: { value: new Date('2023-01-01') }, b: { value: new Date('2023-01-01') }, }, - target: { + target: { a: { value: new Date('2023-12-31') }, b: { value: new Date('2023-12-31') }, }, - expected: { + expected: { a: { value: new Date('2023-12-31') }, b: { value: new Date('2023-12-31') }, }, diff --git a/packages/twenty-shared/src/utils/__tests__/sanitizeObjectStringFields.test.ts b/packages/twenty-shared/src/utils/__tests__/sanitizeObjectStringFields.test.ts index 6b548de8297..73abbfd9f3d 100644 --- a/packages/twenty-shared/src/utils/__tests__/sanitizeObjectStringFields.test.ts +++ b/packages/twenty-shared/src/utils/__tests__/sanitizeObjectStringFields.test.ts @@ -1,5 +1,6 @@ +import { eachTestingContextFilter } from '@/testing'; import { EachTestingContext } from '@/testing/types/EachTestingContext.type'; -import { sanitizeObjectStringFields } from '../sanitizeObjectStringFields'; +import { extractAndSanitizeObjectStringFields } from '../extractAndSanitizeObjectStringFields'; type TestObject = { name?: string; @@ -25,7 +26,7 @@ type SanitizeTestCase = EachTestingContext<{ expected: object; }>; -describe('sanitizeObjectStringFields', () => { +describe('extractAndSanitizeObjectStringFields', () => { const testCases: SanitizeTestCase[] = [ { title: 'should handle basic string properties and trim whitespaces', @@ -84,6 +85,16 @@ describe('sanitizeObjectStringFields', () => { obj: { name: 'John', age: 30 }, keys: ['city', 'name'], }, + expected: { name: 'John' }, + }, + }, + { + title: 'should handle object with number field', + context: { + input: { + obj: { name: 'John', age: 30 }, + keys: ['age', 'name'], + }, expected: { name: 'John', age: 30 }, }, }, @@ -164,7 +175,7 @@ describe('sanitizeObjectStringFields', () => { }, ]; - test.each(testCases)( + test.each(eachTestingContextFilter(testCases))( '$title', ({ context: { @@ -172,7 +183,7 @@ describe('sanitizeObjectStringFields', () => { expected, }, }) => { - const result = sanitizeObjectStringFields(obj, keys); + const result = extractAndSanitizeObjectStringFields(obj, keys); expect(result).toEqual(expected); }, diff --git a/packages/twenty-shared/src/utils/deepMerge.ts b/packages/twenty-shared/src/utils/deepMerge.ts index f10b40ad81d..8b50d14caad 100644 --- a/packages/twenty-shared/src/utils/deepMerge.ts +++ b/packages/twenty-shared/src/utils/deepMerge.ts @@ -6,7 +6,7 @@ * - Null values from target are preserved * - Undefined values from target are ignored * - Date and RegExp objects are treated as primitives (replaced, not merged) - * + * * @param source The source object to merge from * @param target The target object to merge into * @returns A new merged object @@ -76,4 +76,4 @@ export const deepMerge = ( }); return output; -}; \ No newline at end of file +}; diff --git a/packages/twenty-shared/src/utils/sanitizeObjectStringFields.ts b/packages/twenty-shared/src/utils/extractAndSanitizeObjectStringFields.ts similarity index 92% rename from packages/twenty-shared/src/utils/sanitizeObjectStringFields.ts rename to packages/twenty-shared/src/utils/extractAndSanitizeObjectStringFields.ts index 968d5b9153a..2e234632534 100644 --- a/packages/twenty-shared/src/utils/sanitizeObjectStringFields.ts +++ b/packages/twenty-shared/src/utils/extractAndSanitizeObjectStringFields.ts @@ -1,12 +1,12 @@ import { trimAndRemoveDuplicatedWhitespacesFromString } from '@/utils/trim-and-remove-duplicated-whitespaces-from-string'; -// TODO rename with extract meaning -export const sanitizeObjectStringFields = < + +export const extractAndSanitizeObjectStringFields = < T extends object, TKeys extends (keyof T)[], >( obj: T, keys: TKeys, - maxDepth: number = 10, + maxDepth = 10, ): { [P in TKeys[number]]: T[P]; } => { diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 81b73ab1200..1bd9e519651 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -9,6 +9,7 @@ export { assertUnreachable } from './assertUnreachable'; export { deepMerge } from './deepMerge'; +export { extractAndSanitizeObjectStringFields } from './extractAndSanitizeObjectStringFields'; export { isFieldMetadataDateKind } from './fieldMetadata/isFieldMetadataDateKind'; export { fromArrayToUniqueKeyRecord } from './from-array-to-unique-key-record.util'; export { getURLSafely } from './getURLSafely'; @@ -21,7 +22,6 @@ export { getUniqueConstraintsFields } from './indexMetadata/getUniqueConstraints export { parseJson } from './parseJson'; export { removePropertiesFromRecord } from './removePropertiesFromRecord'; export { removeUndefinedFields } from './removeUndefinedFields'; -export { sanitizeObjectStringFields } from './sanitizeObjectStringFields'; export { getGenericOperationName } from './sentry/getGenericOperationName'; export { getHumanReadableNameFromCode } from './sentry/getHumanReadableNameFromCode'; export { capitalize } from './strings/capitalize'; diff --git a/packages/twenty-shared/src/utils/validation/isDefined.ts b/packages/twenty-shared/src/utils/validation/isDefined.ts index 81eb67203a0..aa97cf342fb 100644 --- a/packages/twenty-shared/src/utils/validation/isDefined.ts +++ b/packages/twenty-shared/src/utils/validation/isDefined.ts @@ -1,4 +1,5 @@ import { isNull, isUndefined } from '@sniptt/guards'; -export const isDefined = (value: T | null | undefined): value is T => - !isUndefined(value) && !isNull(value); +export const isDefined = ( + value: T | null | undefined, +): value is NonNullable => !isUndefined(value) && !isNull(value);