From d7b7e9def10d2bdf779a8dd2bae44b8918a907db Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:10:40 +0100 Subject: [PATCH] Cascade delete Task targets when tasks deleted - logic + migration command (#17019) Co-authored-by: prastoin --- ...16-update-task-on-delete-action.command.ts | 201 +++++++++++++++ .../1-16-upgrade-version-command.module.ts | 20 ++ .../upgrade-version-command.module.ts | 2 + .../upgrade.command.ts | 9 + ...ph-or-relation-flat-field-metadata.util.ts | 93 ++++++- ...h-or-relation-flat-field-on-delete.util.ts | 29 +++ ...pace-schema-foreign-key-manager.service.ts | 37 +++ ...arget-standard-flat-field-metadata.util.ts | 2 +- .../update-field-action-handler.service.ts | 66 ++++- .../note-target-hooks.integration-spec.ts} | 234 ++++++++++++------ .../task-target-hooks.integration-spec.ts} | 234 ++++++++++++------ ...a-relation-update.integration-spec.ts.snap | 40 +++ ...tadata-relation-update.integration-spec.ts | 65 ++++- 13 files changed, 857 insertions(+), 175 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-16/1-16-update-task-on-delete-action.command.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-16/1-16-upgrade-version-command.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-on-delete.util.ts rename packages/twenty-server/test/integration/graphql/suites/{object-generated/notes-post-hook.integration-spec.ts => hooks/note-target-hooks.integration-spec.ts} (61%) rename packages/twenty-server/test/integration/graphql/suites/{object-generated/tasks-post-hook.integration-spec.ts => hooks/task-target-hooks.integration-spec.ts} (61%) diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-16/1-16-update-task-on-delete-action.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-16/1-16-update-task-on-delete-action.command.ts new file mode 100644 index 0000000000..0808e6dd7d --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-16/1-16-update-task-on-delete-action.command.ts @@ -0,0 +1,201 @@ +import { Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata'; +import { + FieldMetadataRelationSettings, + FieldMetadataType, + RelationOnDeleteAction, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { IsNull, Repository } from 'typeorm'; + +import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service'; +import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { TASK_TARGET_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-migration/constant/standard-field-ids'; + +@Command({ + name: 'upgrade:1-16:update-task-on-delete-action', + description: + 'Update task relation onDelete action to CASCADE and delete orphaned taskTarget records', +}) +export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + protected readonly logger = new Logger(UpdateTaskOnDeleteActionCommand.name); + + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + protected readonly dataSourceService: DataSourceService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly fieldMetadataService: FieldMetadataService, + ) { + super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + this.logger.log( + `Running UpdateTaskOnDeleteActionCommand for workspace ${workspaceId}`, + ); + + await this.updateTaskRelationOnDeleteAction(workspaceId, isDryRun); + + await this.deleteOrphanedTaskTargets(workspaceId, isDryRun); + } + + private async updateTaskRelationOnDeleteAction( + workspaceId: string, + isDryRun: boolean, + ): Promise { + const { flatFieldMetadataMaps, flatObjectMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatFieldMetadataMaps', + 'flatObjectMetadataMaps', + ]); + + const taskTargetObjectMetadata = Object.values( + flatObjectMetadataMaps.byId, + ).find( + (objectMetadata) => + objectMetadata?.standardId === STANDARD_OBJECT_IDS.taskTarget, + ); + + if (!isDefined(taskTargetObjectMetadata)) { + this.logger.warn( + `TaskTarget object metadata not found in workspace ${workspaceId}`, + ); + + return; + } + + const taskTargetFields = findManyFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityIds: taskTargetObjectMetadata.fieldMetadataIds, + flatEntityMaps: flatFieldMetadataMaps, + }); + + const taskField = taskTargetFields.find( + (field) => field.standardId === TASK_TARGET_STANDARD_FIELD_IDS.task, + ); + + if (!isDefined(taskField)) { + this.logger.warn( + `Task field not found on taskTarget object in workspace ${workspaceId}`, + ); + + return; + } + + if (taskField.type !== FieldMetadataType.RELATION) { + this.logger.warn( + `Task field is not a relation field in workspace ${workspaceId}`, + ); + + return; + } + + const taskFieldSettings = + taskField.settings as FieldMetadataRelationSettings; + + if (taskFieldSettings?.onDelete === RelationOnDeleteAction.CASCADE) { + this.logger.log( + `Task relation already has CASCADE onDelete in workspace ${workspaceId}`, + ); + + return; + } + + this.logger.log( + `Updating task relation onDelete from ${taskFieldSettings?.onDelete} to CASCADE in workspace ${workspaceId}`, + ); + + if (!isDryRun) { + const updatedSettings: FieldMetadataRelationSettings = { + ...taskFieldSettings, + onDelete: RelationOnDeleteAction.CASCADE, + }; + + await this.fieldMetadataService.updateOneField({ + updateFieldInput: { + id: taskField.id, + settings: updatedSettings, + }, + workspaceId, + isSystemBuild: true, + }); + + this.logger.log( + `Successfully updated task relation onDelete to CASCADE in workspace ${workspaceId}`, + ); + } else { + this.logger.log( + `DRY RUN: Would update task relation onDelete to CASCADE in workspace ${workspaceId}`, + ); + } + } + + private async deleteOrphanedTaskTargets( + workspaceId: string, + isDryRun: boolean, + ): Promise { + const taskTargetRepository = + await this.globalWorkspaceOrmManager.getRepository( + workspaceId, + 'taskTarget', + { shouldBypassPermissionChecks: true }, + ); + + const orphanedTaskTargets = await taskTargetRepository.find({ + withDeleted: true, + select: ['id'], + where: { + taskId: IsNull(), + }, + }); + + const orphanedCount = orphanedTaskTargets.length; + + this.logger.log( + `Found ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`, + ); + + if (orphanedCount === 0) { + return; + } + + if (!isDryRun) { + const orphanedIds = orphanedTaskTargets.map((record) => record.id); + + const batchSize = 100; + + for (let i = 0; i < orphanedIds.length; i += batchSize) { + const batch = orphanedIds.slice(i, i + batchSize); + + await taskTargetRepository + .createQueryBuilder() + .delete() + .whereInIds(batch) + .execute(); + } + + this.logger.log( + `Deleted ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`, + ); + } else { + this.logger.log( + `DRY RUN: Would delete ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`, + ); + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-16/1-16-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-16/1-16-upgrade-version-command.module.ts new file mode 100644 index 0000000000..2e40de2725 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-16/1-16-upgrade-version-command.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { UpdateTaskOnDeleteActionCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-update-task-on-delete-action.command'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; +import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([WorkspaceEntity]), + DataSourceModule, + WorkspaceCacheModule, + FieldMetadataModule, + ], + providers: [UpdateTaskOnDeleteActionCommand], + exports: [UpdateTaskOnDeleteActionCommand], +}) +export class V1_16_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts index cfd8475546..9eff74051a 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { V1_13_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-13/1-13-upgrade-version-command.module'; import { V1_14_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-14/1-14-upgrade-version-command.module'; import { V1_15_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-15/1-15-upgrade-version-command.module'; +import { V1_16_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-16/1-16-upgrade-version-command.module'; import { UpgradeCommand } from 'src/database/commands/upgrade-version-command/upgrade.command'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; @@ -14,6 +15,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s V1_13_UpgradeVersionCommandModule, V1_14_UpgradeVersionCommandModule, V1_15_UpgradeVersionCommandModule, + V1_16_UpgradeVersionCommandModule, DataSourceModule, ], providers: [UpgradeCommand], diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index 9f76b59db5..8b8ec10121 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -22,6 +22,7 @@ import { AddWorkspaceForeignKeysMigrationCommand } from 'src/database/commands/u import { BackfillUpdatedByFieldCommand } from 'src/database/commands/upgrade-version-command/1-15/1-15-backfill-updated-by-field.command'; import { FixNanPositionValuesInNotesCommand } from 'src/database/commands/upgrade-version-command/1-15/1-15-fix-nan-position-values-in-notes.command'; import { MigratePageLayoutWidgetConfigurationCommand } from 'src/database/commands/upgrade-version-command/1-15/1-15-migrate-page-layout-widget-configuration.command'; +import { UpdateTaskOnDeleteActionCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-update-task-on-delete-action.command'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; @@ -59,6 +60,9 @@ export class UpgradeCommand extends UpgradeCommandRunner { protected readonly fixNanPositionValuesInNotesCommand: FixNanPositionValuesInNotesCommand, protected readonly backfillUpdatedByFieldCommand: BackfillUpdatedByFieldCommand, protected readonly addWorkspaceForeignKeysMigrationCommand: AddWorkspaceForeignKeysMigrationCommand, + + // 1.16 Commands + protected readonly updateTaskOnDeleteActionCommand: UpdateTaskOnDeleteActionCommand, ) { super( workspaceRepository, @@ -92,11 +96,16 @@ export class UpgradeCommand extends UpgradeCommandRunner { this.addWorkspaceForeignKeysMigrationCommand, ]; + const commands_1160: VersionCommands = [ + this.updateTaskOnDeleteActionCommand, + ]; + this.allCommands = { '1.12.0': commands_1120, '1.13.0': commands_1130, '1.14.0': commands_1140, '1.15.0': commands_1150, + '1.16.0': commands_1160, }; } diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-metadata.util.ts index 64490fca7c..13c6a65a79 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-metadata.util.ts @@ -3,13 +3,76 @@ import { isDefined, isValidUuid } from 'twenty-shared/utils'; import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; import { type MorphOrRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/morph-or-relation-field-metadata-type.type'; +import { type FlatEntityPropertiesUpdates } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-properties-updates.type'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { type FlatFieldMetadataTypeValidationArgs } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type'; import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util'; import { validateMorphOrRelationFlatFieldJoinColumName } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-join-column-name.util'; +import { validateMorphOrRelationFlatFieldOnDelete } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-on-delete.util'; +import { type PropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/types/property-update.type'; import { findFlatEntityPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/utils/find-flat-entity-property-update.util'; +type ValidateMorphOrRelationFlatFieldMetadataUpdatesArgs = Omit< + FlatFieldMetadataTypeValidationArgs, + 'updates' +> & { + updates: FlatEntityPropertiesUpdates<'fieldMetadata'>; +}; + +export const validateMorphOrRelationFlatFieldMetadataUpdates = ({ + flatEntityToValidate: flatFieldMetadataToValidate, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatFieldMetadataMaps, + flatObjectMetadataMaps, + }, + updates, + buildOptions, +}: ValidateMorphOrRelationFlatFieldMetadataUpdatesArgs): FlatFieldMetadataValidationError[] => { + const errors: FlatFieldMetadataValidationError[] = []; + + const settingsUpdate = findFlatEntityPropertyUpdate({ + flatEntityUpdates: updates, + property: 'settings', + }) as + | PropertyUpdate< + FlatFieldMetadata, + 'settings' + > + | undefined; + + const toSettings = settingsUpdate?.to; + const fromSettings = settingsUpdate?.from; + + const isJoinColumnNameUpdated = + isDefined(settingsUpdate) && + isDefined(toSettings?.joinColumnName) && + isDefined(fromSettings?.joinColumnName) && + toSettings.joinColumnName !== fromSettings.joinColumnName; + + if (isJoinColumnNameUpdated) { + errors.push( + ...validateMorphOrRelationFlatFieldJoinColumName({ + buildOptions, + flatFieldMetadata: flatFieldMetadataToValidate, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatFieldMetadataMaps, + flatObjectMetadataMaps, + }, + }), + ); + } + + errors.push( + ...validateMorphOrRelationFlatFieldOnDelete({ + flatFieldMetadata: flatFieldMetadataToValidate, + }), + ); + + return errors; +}; + export const validateMorphOrRelationFlatFieldMetadata = ({ flatEntityToValidate: flatFieldMetadataToValidate, optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { @@ -19,6 +82,8 @@ export const validateMorphOrRelationFlatFieldMetadata = ({ updates, remainingFlatEntityMapsToValidate, buildOptions, + workspaceId, + additionalCacheDataMaps, }: FlatFieldMetadataTypeValidationArgs): FlatFieldMetadataValidationError[] => { const { relationTargetFieldMetadataId, relationTargetObjectMetadataId } = flatFieldMetadataToValidate; @@ -120,15 +185,23 @@ export const validateMorphOrRelationFlatFieldMetadata = ({ } } - if ( - !isDefined(updates) || - isDefined( - findFlatEntityPropertyUpdate({ - flatEntityUpdates: updates, - property: 'settings', + // TODO prastoin refactor FlatFieldMetadataTypeValidator to implement two code flow: create and update https://github.com/twentyhq/core-team-issues/issues/2044 + if (isDefined(updates)) { + errors.push( + ...validateMorphOrRelationFlatFieldMetadataUpdates({ + flatEntityToValidate: flatFieldMetadataToValidate, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatFieldMetadataMaps, + flatObjectMetadataMaps, + }, + remainingFlatEntityMapsToValidate, + workspaceId, + updates, + buildOptions, + additionalCacheDataMaps, }), - ) - ) + ); + } else { errors.push( ...validateMorphOrRelationFlatFieldJoinColumName({ buildOptions, @@ -138,7 +211,11 @@ export const validateMorphOrRelationFlatFieldMetadata = ({ flatObjectMetadataMaps, }, }), + ...validateMorphOrRelationFlatFieldOnDelete({ + flatFieldMetadata: flatFieldMetadataToValidate, + }), ); + } return errors; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-on-delete.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-on-delete.util.ts new file mode 100644 index 0000000000..05c1f1ade4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-on-delete.util.ts @@ -0,0 +1,29 @@ +import { msg } from '@lingui/core/macro'; +import { RelationType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { type MorphOrRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/morph-or-relation-field-metadata-type.type'; +import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; + +export const validateMorphOrRelationFlatFieldOnDelete = ({ + flatFieldMetadata, +}: { + flatFieldMetadata: FlatFieldMetadata; +}): FlatFieldMetadataValidationError[] => { + const errors: FlatFieldMetadataValidationError[] = []; + + if ( + isDefined(flatFieldMetadata.settings.onDelete) && + flatFieldMetadata.settings.relationType !== RelationType.MANY_TO_ONE + ) { + errors.push({ + code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + message: 'On delete action is only supported for many to one relations', + userFriendlyMessage: msg`On delete action is only supported for many to one relations`, + }); + } + + return errors; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-foreign-key-manager.service.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-foreign-key-manager.service.ts index 0f0e374552..b502a7e453 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-foreign-key-manager.service.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-foreign-key-manager.service.ts @@ -89,4 +89,41 @@ export class WorkspaceSchemaForeignKeyManagerService { await queryRunner.query(sql); } + + async getForeignKeyName({ + queryRunner, + schemaName, + tableName, + columnName, + }: { + queryRunner: QueryRunner; + schemaName: string; + tableName: string; + columnName: string; + }): Promise { + const safeSchemaName = removeSqlDDLInjection(schemaName); + const safeTableName = removeSqlDDLInjection(tableName); + const safeColumnName = removeSqlDDLInjection(columnName); + + const foreignKeys = await queryRunner.query( + ` + SELECT + tc.constraint_name AS constraint_name + FROM + information_schema.table_constraints AS tc + JOIN + information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE + tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = $1 + AND tc.table_name = $2 + AND kcu.column_name = $3 + `, + [safeSchemaName, safeTableName, safeColumnName], + ); + + return foreignKeys[0]?.constraint_name; + } } diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-task-target-standard-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-task-target-standard-flat-field-metadata.util.ts index 016cdbae49..9888e4f477 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-task-target-standard-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-task-target-standard-flat-field-metadata.util.ts @@ -124,7 +124,7 @@ export const buildTaskTargetStandardFlatFieldMetadatas = ({ targetFieldName: 'taskTargets', settings: { relationType: RelationType.MANY_TO_ONE, - onDelete: RelationOnDeleteAction.SET_NULL, + onDelete: RelationOnDeleteAction.CASCADE, joinColumnName: 'taskId', }, }, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/update-field-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/update-field-action-handler.service.ts index 09816ad541..df18204a83 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/update-field-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/update-field-action-handler.service.ts @@ -18,11 +18,13 @@ import { isEnumFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field- import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util'; import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service'; +import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; import { isMorphOrRelationFieldMetadataType } from 'src/engine/utils/is-morph-or-relation-field-metadata-type.util'; import { PropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/types/property-update.type'; +import { convertOnDeleteActionToOnDelete } from 'src/engine/workspace-manager/workspace-migration/utils/convert-on-delete-action-to-on-delete.util'; import { findFlatEntityPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/utils/find-flat-entity-property-update.util'; import { isPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/utils/is-property-update.util'; -import { type UpdateFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action'; +import { UpdateFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action'; import { serializeDefaultValue } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/utils/serialize-default-value.util'; import { WorkspaceMigrationRunnerException, @@ -221,6 +223,68 @@ export class UpdateFieldActionHandlerService extends WorkspaceMigrationRunnerAct settings: update.to, }; } + + if ( + isMorphOrRelationFlatFieldMetadata(optimisticFlatFieldMetadata) && + isDefined(optimisticFlatFieldMetadata.settings.joinColumnName) && + isPropertyUpdate(update, 'settings') && + isDefined(update.from?.onDelete) && + isDefined(update.to?.onDelete) && + update.to.onDelete !== update.from.onDelete + ) { + const foreignKeyName = + await this.workspaceSchemaManagerService.foreignKeyManager.getForeignKeyName( + { + queryRunner, + schemaName, + tableName, + columnName: optimisticFlatFieldMetadata.settings.joinColumnName, + }, + ); + + if (!isDefined(foreignKeyName)) { + throw new WorkspaceMigrationRunnerException( + 'Foreign key not found', + WorkspaceMigrationRunnerExceptionCode.NOT_SUPPORTED, + ); + } + + await this.workspaceSchemaManagerService.foreignKeyManager.dropForeignKey( + { + queryRunner, + schemaName, + tableName, + foreignKeyName, + }, + ); + + const targetFlatObjectMetadata = + findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: + optimisticFlatFieldMetadata.relationTargetObjectMetadataId, + flatEntityMaps: flatObjectMetadataMaps, + }); + + const referencedTableName = computeObjectTargetTable( + targetFlatObjectMetadata, + ); + + await this.workspaceSchemaManagerService.foreignKeyManager.createForeignKey( + { + queryRunner, + schemaName, + foreignKey: { + tableName, + columnName: update.to.joinColumnName, + referencedTableName, + referencedColumnName: 'id', + onDelete: + convertOnDeleteActionToOnDelete(update.to.onDelete) ?? + 'CASCADE', + }, + }, + ); + } } } diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/notes-post-hook.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/hooks/note-target-hooks.integration-spec.ts similarity index 61% rename from packages/twenty-server/test/integration/graphql/suites/object-generated/notes-post-hook.integration-spec.ts rename to packages/twenty-server/test/integration/graphql/suites/hooks/note-target-hooks.integration-spec.ts index 8f3c8d0853..da24da4100 100644 --- a/packages/twenty-server/test/integration/graphql/suites/object-generated/notes-post-hook.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/hooks/note-target-hooks.integration-spec.ts @@ -1,9 +1,10 @@ import { randomUUID } from 'crypto'; -import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.util'; import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util'; import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util'; import { destroyManyOperationFactory } from 'test/integration/graphql/utils/destroy-many-operation-factory.util'; +import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util'; import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util'; import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; import { restoreManyOperationFactory } from 'test/integration/graphql/utils/restore-many-operation-factory.util'; @@ -21,7 +22,7 @@ const NOTE_TARGET_GQL_FIELDS = ` deletedAt `; -describe('Note post-query hooks', () => { +describe('noteTargets hooks on note actions', () => { const noteIds: string[] = []; const noteTargetIds: string[] = []; @@ -49,29 +50,25 @@ describe('Note post-query hooks', () => { } }); - it('deleteOne should soft delete related noteTargets', async () => { + it('deleteOne note should soft delete related noteTargets', async () => { const noteId = randomUUID(); const noteTargetId = randomUUID(); noteIds.push(noteId); noteTargetIds.push(noteTargetId); - const createNoteOperation = createOneOperationFactory({ + await createOneOperation({ objectMetadataSingularName: 'note', gqlFields: NOTE_GQL_FIELDS, - data: { id: noteId, title: 'Test Note for DeleteOne' }, + input: { id: noteId, title: 'Test Note for DeleteOne' }, }); - await makeGraphqlAPIRequest(createNoteOperation); - - const createNoteTargetOperation = createOneOperationFactory({ + await createOneOperation({ objectMetadataSingularName: 'noteTarget', gqlFields: NOTE_TARGET_GQL_FIELDS, - data: { id: noteTargetId, noteId }, + input: { id: noteTargetId, noteId }, }); - await makeGraphqlAPIRequest(createNoteTargetOperation); - const deleteNoteOperation = deleteOneOperationFactory({ objectMetadataSingularName: 'note', gqlFields: NOTE_GQL_FIELDS, @@ -103,7 +100,7 @@ describe('Note post-query hooks', () => { ).not.toBeNull(); }); - it('deleteMany should soft delete related noteTargets', async () => { + it('deleteMany notes should soft delete related noteTargets', async () => { const noteId1 = randomUUID(); const noteId2 = randomUUID(); const noteTargetId1 = randomUUID(); @@ -112,38 +109,30 @@ describe('Note post-query hooks', () => { noteIds.push(noteId1, noteId2); noteTargetIds.push(noteTargetId1, noteTargetId2); - const createNote1Operation = createOneOperationFactory({ - objectMetadataSingularName: 'note', - gqlFields: NOTE_GQL_FIELDS, - data: { id: noteId1, title: 'Test Note 1 for DeleteMany' }, - }); - - const createNote2Operation = createOneOperationFactory({ - objectMetadataSingularName: 'note', - gqlFields: NOTE_GQL_FIELDS, - data: { id: noteId2, title: 'Test Note 2 for DeleteMany' }, - }); - await Promise.all([ - makeGraphqlAPIRequest(createNote1Operation), - makeGraphqlAPIRequest(createNote2Operation), + createOneOperation({ + objectMetadataSingularName: 'note', + gqlFields: NOTE_GQL_FIELDS, + input: { id: noteId1, title: 'Test Note 1 for DeleteMany' }, + }), + createOneOperation({ + objectMetadataSingularName: 'note', + gqlFields: NOTE_GQL_FIELDS, + input: { id: noteId2, title: 'Test Note 2 for DeleteMany' }, + }), ]); - const createNoteTarget1Operation = createOneOperationFactory({ - objectMetadataSingularName: 'noteTarget', - gqlFields: NOTE_TARGET_GQL_FIELDS, - data: { id: noteTargetId1, noteId: noteId1 }, - }); - - const createNoteTarget2Operation = createOneOperationFactory({ - objectMetadataSingularName: 'noteTarget', - gqlFields: NOTE_TARGET_GQL_FIELDS, - data: { id: noteTargetId2, noteId: noteId2 }, - }); - await Promise.all([ - makeGraphqlAPIRequest(createNoteTarget1Operation), - makeGraphqlAPIRequest(createNoteTarget2Operation), + createOneOperation({ + objectMetadataSingularName: 'noteTarget', + gqlFields: NOTE_TARGET_GQL_FIELDS, + input: { id: noteTargetId1, noteId: noteId1 }, + }), + createOneOperation({ + objectMetadataSingularName: 'noteTarget', + gqlFields: NOTE_TARGET_GQL_FIELDS, + input: { id: noteTargetId2, noteId: noteId2 }, + }), ]); const deleteNotesOperation = deleteManyOperationFactory({ @@ -180,29 +169,25 @@ describe('Note post-query hooks', () => { ).not.toBeNull(); }); - it('restoreOne should restore related noteTargets', async () => { + it('restoreOne note should restore related noteTargets', async () => { const noteId = randomUUID(); const noteTargetId = randomUUID(); noteIds.push(noteId); noteTargetIds.push(noteTargetId); - const createNoteOperation = createOneOperationFactory({ + await createOneOperation({ objectMetadataSingularName: 'note', gqlFields: NOTE_GQL_FIELDS, - data: { id: noteId, title: 'Test Note for RestoreOne' }, + input: { id: noteId, title: 'Test Note for RestoreOne' }, }); - await makeGraphqlAPIRequest(createNoteOperation); - - const createNoteTargetOperation = createOneOperationFactory({ + await createOneOperation({ objectMetadataSingularName: 'noteTarget', gqlFields: NOTE_TARGET_GQL_FIELDS, - data: { id: noteTargetId, noteId }, + input: { id: noteTargetId, noteId }, }); - await makeGraphqlAPIRequest(createNoteTargetOperation); - const deleteNoteOperation = deleteOneOperationFactory({ objectMetadataSingularName: 'note', gqlFields: NOTE_GQL_FIELDS, @@ -239,7 +224,7 @@ describe('Note post-query hooks', () => { ).toBeNull(); }); - it('restoreMany should restore related noteTargets', async () => { + it('restoreMany notes should restore related noteTargets', async () => { const noteId1 = randomUUID(); const noteId2 = randomUUID(); const noteTargetId1 = randomUUID(); @@ -248,38 +233,30 @@ describe('Note post-query hooks', () => { noteIds.push(noteId1, noteId2); noteTargetIds.push(noteTargetId1, noteTargetId2); - const createNote1Operation = createOneOperationFactory({ - objectMetadataSingularName: 'note', - gqlFields: NOTE_GQL_FIELDS, - data: { id: noteId1, title: 'Test Note 1 for RestoreMany' }, - }); - - const createNote2Operation = createOneOperationFactory({ - objectMetadataSingularName: 'note', - gqlFields: NOTE_GQL_FIELDS, - data: { id: noteId2, title: 'Test Note 2 for RestoreMany' }, - }); - await Promise.all([ - makeGraphqlAPIRequest(createNote1Operation), - makeGraphqlAPIRequest(createNote2Operation), + createOneOperation({ + objectMetadataSingularName: 'note', + gqlFields: NOTE_GQL_FIELDS, + input: { id: noteId1, title: 'Test Note 1 for RestoreMany' }, + }), + createOneOperation({ + objectMetadataSingularName: 'note', + gqlFields: NOTE_GQL_FIELDS, + input: { id: noteId2, title: 'Test Note 2 for RestoreMany' }, + }), ]); - const createNoteTarget1Operation = createOneOperationFactory({ - objectMetadataSingularName: 'noteTarget', - gqlFields: NOTE_TARGET_GQL_FIELDS, - data: { id: noteTargetId1, noteId: noteId1 }, - }); - - const createNoteTarget2Operation = createOneOperationFactory({ - objectMetadataSingularName: 'noteTarget', - gqlFields: NOTE_TARGET_GQL_FIELDS, - data: { id: noteTargetId2, noteId: noteId2 }, - }); - await Promise.all([ - makeGraphqlAPIRequest(createNoteTarget1Operation), - makeGraphqlAPIRequest(createNoteTarget2Operation), + createOneOperation({ + objectMetadataSingularName: 'noteTarget', + gqlFields: NOTE_TARGET_GQL_FIELDS, + input: { id: noteTargetId1, noteId: noteId1 }, + }), + createOneOperation({ + objectMetadataSingularName: 'noteTarget', + gqlFields: NOTE_TARGET_GQL_FIELDS, + input: { id: noteTargetId2, noteId: noteId2 }, + }), ]); const deleteNotesOperation = deleteManyOperationFactory({ @@ -321,4 +298,105 @@ describe('Note post-query hooks', () => { noteTargetResponse.body.data.noteTargets.edges[1].node.deletedAt, ).toBeNull(); }); + + it('destroyOne note should destroy related noteTargets', async () => { + const noteId = randomUUID(); + const noteTargetId = randomUUID(); + + noteIds.push(noteId); + + await createOneOperation({ + objectMetadataSingularName: 'note', + gqlFields: NOTE_GQL_FIELDS, + input: { id: noteId, title: 'Test Note for DestroyOne' }, + }); + + await createOneOperation({ + objectMetadataSingularName: 'noteTarget', + gqlFields: NOTE_TARGET_GQL_FIELDS, + input: { id: noteTargetId, noteId }, + }); + + const destroyNoteOperation = destroyOneOperationFactory({ + objectMetadataSingularName: 'note', + gqlFields: 'id', + recordId: noteId, + }); + + const destroyResponse = await makeGraphqlAPIRequest(destroyNoteOperation); + + expect(destroyResponse.body.data.destroyNote).toBeDefined(); + + const findNoteTargetsOperation = findManyOperationFactory({ + objectMetadataSingularName: 'noteTarget', + objectMetadataPluralName: 'noteTargets', + gqlFields: NOTE_TARGET_GQL_FIELDS, + filter: { id: { eq: noteTargetId } }, + }); + + const noteTargetResponse = await makeGraphqlAPIRequest( + findNoteTargetsOperation, + ); + + expect(noteTargetResponse.body.data.noteTargets.edges).toHaveLength(0); + }); + + it('destroyMany notes should destroy related noteTargets', async () => { + const noteId1 = randomUUID(); + const noteId2 = randomUUID(); + const noteTargetId1 = randomUUID(); + const noteTargetId2 = randomUUID(); + + noteIds.push(noteId1, noteId2); + + await Promise.all([ + createOneOperation({ + objectMetadataSingularName: 'note', + gqlFields: NOTE_GQL_FIELDS, + input: { id: noteId1, title: 'Test Note 1 for DestroyMany' }, + }), + createOneOperation({ + objectMetadataSingularName: 'note', + gqlFields: NOTE_GQL_FIELDS, + input: { id: noteId2, title: 'Test Note 2 for DestroyMany' }, + }), + ]); + + await Promise.all([ + createOneOperation({ + objectMetadataSingularName: 'noteTarget', + gqlFields: NOTE_TARGET_GQL_FIELDS, + input: { id: noteTargetId1, noteId: noteId1 }, + }), + createOneOperation({ + objectMetadataSingularName: 'noteTarget', + gqlFields: NOTE_TARGET_GQL_FIELDS, + input: { id: noteTargetId2, noteId: noteId2 }, + }), + ]); + + const destroyNotesOperation = destroyManyOperationFactory({ + objectMetadataSingularName: 'note', + objectMetadataPluralName: 'notes', + gqlFields: 'id', + filter: { id: { in: [noteId1, noteId2] } }, + }); + + const destroyResponse = await makeGraphqlAPIRequest(destroyNotesOperation); + + expect(destroyResponse.body.data.destroyNotes).toHaveLength(2); + + const findNoteTargetsOperation = findManyOperationFactory({ + objectMetadataSingularName: 'noteTarget', + objectMetadataPluralName: 'noteTargets', + gqlFields: NOTE_TARGET_GQL_FIELDS, + filter: { id: { in: [noteTargetId1, noteTargetId2] } }, + }); + + const noteTargetResponse = await makeGraphqlAPIRequest( + findNoteTargetsOperation, + ); + + expect(noteTargetResponse.body.data.noteTargets.edges).toHaveLength(0); + }); }); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/tasks-post-hook.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/hooks/task-target-hooks.integration-spec.ts similarity index 61% rename from packages/twenty-server/test/integration/graphql/suites/object-generated/tasks-post-hook.integration-spec.ts rename to packages/twenty-server/test/integration/graphql/suites/hooks/task-target-hooks.integration-spec.ts index 758535385e..48d86815a3 100644 --- a/packages/twenty-server/test/integration/graphql/suites/object-generated/tasks-post-hook.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/hooks/task-target-hooks.integration-spec.ts @@ -1,9 +1,10 @@ import { randomUUID } from 'crypto'; -import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.util'; import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util'; import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util'; import { destroyManyOperationFactory } from 'test/integration/graphql/utils/destroy-many-operation-factory.util'; +import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util'; import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util'; import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; import { restoreManyOperationFactory } from 'test/integration/graphql/utils/restore-many-operation-factory.util'; @@ -21,7 +22,7 @@ const TASK_TARGET_GQL_FIELDS = ` deletedAt `; -describe('Task post-query hooks', () => { +describe('taskTargets hooks on task actions', () => { const taskIds: string[] = []; const taskTargetIds: string[] = []; @@ -49,29 +50,25 @@ describe('Task post-query hooks', () => { } }); - it('deleteOne should soft delete related taskTargets', async () => { + it('deleteOne task should soft delete related taskTargets', async () => { const taskId = randomUUID(); const taskTargetId = randomUUID(); taskIds.push(taskId); taskTargetIds.push(taskTargetId); - const createTaskOperation = createOneOperationFactory({ + await createOneOperation({ objectMetadataSingularName: 'task', gqlFields: TASK_GQL_FIELDS, - data: { id: taskId, title: 'Test Task for DeleteOne' }, + input: { id: taskId, title: 'Test Task for DeleteOne' }, }); - await makeGraphqlAPIRequest(createTaskOperation); - - const createTaskTargetOperation = createOneOperationFactory({ + await createOneOperation({ objectMetadataSingularName: 'taskTarget', gqlFields: TASK_TARGET_GQL_FIELDS, - data: { id: taskTargetId, taskId }, + input: { id: taskTargetId, taskId }, }); - await makeGraphqlAPIRequest(createTaskTargetOperation); - const deleteTaskOperation = deleteOneOperationFactory({ objectMetadataSingularName: 'task', gqlFields: TASK_GQL_FIELDS, @@ -103,7 +100,7 @@ describe('Task post-query hooks', () => { ).not.toBeNull(); }); - it('deleteMany should soft delete related taskTargets', async () => { + it('deleteMany tasks should soft delete related taskTargets', async () => { const taskId1 = randomUUID(); const taskId2 = randomUUID(); const taskTargetId1 = randomUUID(); @@ -112,38 +109,30 @@ describe('Task post-query hooks', () => { taskIds.push(taskId1, taskId2); taskTargetIds.push(taskTargetId1, taskTargetId2); - const createTask1Operation = createOneOperationFactory({ - objectMetadataSingularName: 'task', - gqlFields: TASK_GQL_FIELDS, - data: { id: taskId1, title: 'Test Task 1 for DeleteMany' }, - }); - - const createTask2Operation = createOneOperationFactory({ - objectMetadataSingularName: 'task', - gqlFields: TASK_GQL_FIELDS, - data: { id: taskId2, title: 'Test Task 2 for DeleteMany' }, - }); - await Promise.all([ - makeGraphqlAPIRequest(createTask1Operation), - makeGraphqlAPIRequest(createTask2Operation), + createOneOperation({ + objectMetadataSingularName: 'task', + gqlFields: TASK_GQL_FIELDS, + input: { id: taskId1, title: 'Test Task 1 for DeleteMany' }, + }), + createOneOperation({ + objectMetadataSingularName: 'task', + gqlFields: TASK_GQL_FIELDS, + input: { id: taskId2, title: 'Test Task 2 for DeleteMany' }, + }), ]); - const createTaskTarget1Operation = createOneOperationFactory({ - objectMetadataSingularName: 'taskTarget', - gqlFields: TASK_TARGET_GQL_FIELDS, - data: { id: taskTargetId1, taskId: taskId1 }, - }); - - const createTaskTarget2Operation = createOneOperationFactory({ - objectMetadataSingularName: 'taskTarget', - gqlFields: TASK_TARGET_GQL_FIELDS, - data: { id: taskTargetId2, taskId: taskId2 }, - }); - await Promise.all([ - makeGraphqlAPIRequest(createTaskTarget1Operation), - makeGraphqlAPIRequest(createTaskTarget2Operation), + createOneOperation({ + objectMetadataSingularName: 'taskTarget', + gqlFields: TASK_TARGET_GQL_FIELDS, + input: { id: taskTargetId1, taskId: taskId1 }, + }), + createOneOperation({ + objectMetadataSingularName: 'taskTarget', + gqlFields: TASK_TARGET_GQL_FIELDS, + input: { id: taskTargetId2, taskId: taskId2 }, + }), ]); const deleteTasksOperation = deleteManyOperationFactory({ @@ -180,29 +169,25 @@ describe('Task post-query hooks', () => { ).not.toBeNull(); }); - it('restoreOne should restore related taskTargets', async () => { + it('restoreOne task should restore related taskTargets', async () => { const taskId = randomUUID(); const taskTargetId = randomUUID(); taskIds.push(taskId); taskTargetIds.push(taskTargetId); - const createTaskOperation = createOneOperationFactory({ + await createOneOperation({ objectMetadataSingularName: 'task', gqlFields: TASK_GQL_FIELDS, - data: { id: taskId, title: 'Test Task for RestoreOne' }, + input: { id: taskId, title: 'Test Task for RestoreOne' }, }); - await makeGraphqlAPIRequest(createTaskOperation); - - const createTaskTargetOperation = createOneOperationFactory({ + await createOneOperation({ objectMetadataSingularName: 'taskTarget', gqlFields: TASK_TARGET_GQL_FIELDS, - data: { id: taskTargetId, taskId }, + input: { id: taskTargetId, taskId }, }); - await makeGraphqlAPIRequest(createTaskTargetOperation); - const deleteTaskOperation = deleteOneOperationFactory({ objectMetadataSingularName: 'task', gqlFields: TASK_GQL_FIELDS, @@ -239,7 +224,7 @@ describe('Task post-query hooks', () => { ).toBeNull(); }); - it('restoreMany should restore related taskTargets', async () => { + it('restoreMany tasks should restore related taskTargets', async () => { const taskId1 = randomUUID(); const taskId2 = randomUUID(); const taskTargetId1 = randomUUID(); @@ -248,38 +233,30 @@ describe('Task post-query hooks', () => { taskIds.push(taskId1, taskId2); taskTargetIds.push(taskTargetId1, taskTargetId2); - const createTask1Operation = createOneOperationFactory({ - objectMetadataSingularName: 'task', - gqlFields: TASK_GQL_FIELDS, - data: { id: taskId1, title: 'Test Task 1 for RestoreMany' }, - }); - - const createTask2Operation = createOneOperationFactory({ - objectMetadataSingularName: 'task', - gqlFields: TASK_GQL_FIELDS, - data: { id: taskId2, title: 'Test Task 2 for RestoreMany' }, - }); - await Promise.all([ - makeGraphqlAPIRequest(createTask1Operation), - makeGraphqlAPIRequest(createTask2Operation), + createOneOperation({ + objectMetadataSingularName: 'task', + gqlFields: TASK_GQL_FIELDS, + input: { id: taskId1, title: 'Test Task 1 for RestoreMany' }, + }), + createOneOperation({ + objectMetadataSingularName: 'task', + gqlFields: TASK_GQL_FIELDS, + input: { id: taskId2, title: 'Test Task 2 for RestoreMany' }, + }), ]); - const createTaskTarget1Operation = createOneOperationFactory({ - objectMetadataSingularName: 'taskTarget', - gqlFields: TASK_TARGET_GQL_FIELDS, - data: { id: taskTargetId1, taskId: taskId1 }, - }); - - const createTaskTarget2Operation = createOneOperationFactory({ - objectMetadataSingularName: 'taskTarget', - gqlFields: TASK_TARGET_GQL_FIELDS, - data: { id: taskTargetId2, taskId: taskId2 }, - }); - await Promise.all([ - makeGraphqlAPIRequest(createTaskTarget1Operation), - makeGraphqlAPIRequest(createTaskTarget2Operation), + createOneOperation({ + objectMetadataSingularName: 'taskTarget', + gqlFields: TASK_TARGET_GQL_FIELDS, + input: { id: taskTargetId1, taskId: taskId1 }, + }), + createOneOperation({ + objectMetadataSingularName: 'taskTarget', + gqlFields: TASK_TARGET_GQL_FIELDS, + input: { id: taskTargetId2, taskId: taskId2 }, + }), ]); const deleteTasksOperation = deleteManyOperationFactory({ @@ -321,4 +298,105 @@ describe('Task post-query hooks', () => { taskTargetResponse.body.data.taskTargets.edges[1].node.deletedAt, ).toBeNull(); }); + + it('destroyOne task should destroy related taskTargets', async () => { + const taskId = randomUUID(); + const taskTargetId = randomUUID(); + + taskIds.push(taskId); + + await createOneOperation({ + objectMetadataSingularName: 'task', + gqlFields: TASK_GQL_FIELDS, + input: { id: taskId, title: 'Test Task for DestroyOne' }, + }); + + await createOneOperation({ + objectMetadataSingularName: 'taskTarget', + gqlFields: TASK_TARGET_GQL_FIELDS, + input: { id: taskTargetId, taskId }, + }); + + const destroyTaskOperation = destroyOneOperationFactory({ + objectMetadataSingularName: 'task', + gqlFields: 'id', + recordId: taskId, + }); + + const destroyResponse = await makeGraphqlAPIRequest(destroyTaskOperation); + + expect(destroyResponse.body.data.destroyTask).toBeDefined(); + + const findTaskTargetsOperation = findManyOperationFactory({ + objectMetadataSingularName: 'taskTarget', + objectMetadataPluralName: 'taskTargets', + gqlFields: TASK_TARGET_GQL_FIELDS, + filter: { id: { eq: taskTargetId } }, + }); + + const taskTargetResponse = await makeGraphqlAPIRequest( + findTaskTargetsOperation, + ); + + expect(taskTargetResponse.body.data.taskTargets.edges).toHaveLength(0); + }); + + it('destroyMany tasks should destroy related taskTargets', async () => { + const taskId1 = randomUUID(); + const taskId2 = randomUUID(); + const taskTargetId1 = randomUUID(); + const taskTargetId2 = randomUUID(); + + taskIds.push(taskId1, taskId2); + + await Promise.all([ + createOneOperation({ + objectMetadataSingularName: 'task', + gqlFields: TASK_GQL_FIELDS, + input: { id: taskId1, title: 'Test Task 1 for DestroyMany' }, + }), + createOneOperation({ + objectMetadataSingularName: 'task', + gqlFields: TASK_GQL_FIELDS, + input: { id: taskId2, title: 'Test Task 2 for DestroyMany' }, + }), + ]); + + await Promise.all([ + createOneOperation({ + objectMetadataSingularName: 'taskTarget', + gqlFields: TASK_TARGET_GQL_FIELDS, + input: { id: taskTargetId1, taskId: taskId1 }, + }), + createOneOperation({ + objectMetadataSingularName: 'taskTarget', + gqlFields: TASK_TARGET_GQL_FIELDS, + input: { id: taskTargetId2, taskId: taskId2 }, + }), + ]); + + const destroyTasksOperation = destroyManyOperationFactory({ + objectMetadataSingularName: 'task', + objectMetadataPluralName: 'tasks', + gqlFields: 'id', + filter: { id: { in: [taskId1, taskId2] } }, + }); + + const destroyResponse = await makeGraphqlAPIRequest(destroyTasksOperation); + + expect(destroyResponse.body.data.destroyTasks).toHaveLength(2); + + const findTaskTargetsOperation = findManyOperationFactory({ + objectMetadataSingularName: 'taskTarget', + objectMetadataPluralName: 'taskTargets', + gqlFields: TASK_TARGET_GQL_FIELDS, + filter: { id: { in: [taskTargetId1, taskTargetId2] } }, + }); + + const taskTargetResponse = await makeGraphqlAPIRequest( + findTaskTargetsOperation, + ); + + expect(taskTargetResponse.body.data.taskTargets.edges).toHaveLength(0); + }); }); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/__snapshots__/failing-field-metadata-relation-update.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/__snapshots__/failing-field-metadata-relation-update.integration-spec.ts.snap index 70f7ff55eb..ea96e5b8cf 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/__snapshots__/failing-field-metadata-relation-update.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/__snapshots__/failing-field-metadata-relation-update.integration-spec.ts.snap @@ -46,3 +46,43 @@ exports[`Field metadata relation update should fail relation when name is not in }, ] `; + +exports[`Field metadata relation update should fail relation when updating ONE_TO_MANY relation with onDelete action 1`] = ` +[ + { + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "fieldMetadata": [ + { + "errors": [ + { + "code": "INVALID_FIELD_INPUT", + "message": "On delete action is only supported for many to one relations", + "userFriendlyMessage": "On delete action is only supported for many to one relations", + }, + ], + "flatEntityMinimalInformation": { + "id": Any, + "name": "staff", + "objectMetadataId": Any, + "universalIdentifier": Any, + }, + "metadataName": "fieldMetadata", + "status": "fail", + "type": "update", + }, + ], + }, + "message": "Validation failed for 1 fieldMetadata", + "summary": { + "fieldMetadata": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Metadata validation failed", + }, + "message": "Multiple validation errors occurred while updating field", + "name": "GraphQLError", + }, +] +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/failing-field-metadata-relation-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/failing-field-metadata-relation-update.integration-spec.ts index c68d90e4d7..b82265e0a0 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/failing-field-metadata-relation-update.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/failing-field-metadata-relation-update.integration-spec.ts @@ -6,18 +6,31 @@ import { getMockCreateObjectInput } from 'test/integration/metadata/suites/objec import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any'; import { type EachTestingContext } from 'twenty-shared/testing'; -import { FieldMetadataType } from 'twenty-shared/types'; +import { FieldMetadataType, RelationOnDeleteAction } from 'twenty-shared/types'; import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; -type UpdateOneFieldMetadataTestingContext = EachTestingContext<{ - name: string; -}>; +type UpdateOneFieldMetadataTestingContext = EachTestingContext< + | { + fieldKey: 'employerFieldMetadataId'; + updatePayload: { name: string }; + } + | { + fieldKey: 'employeesOneToManyFieldMetadataId'; + updatePayload: { + settings: { + relationType: RelationType; + onDelete: RelationOnDeleteAction; + }; + }; + } +>; const globalTestContext = { employeeObjectId: '', enterpriseObjectId: '', employerFieldMetadataId: '', + employeesOneToManyFieldMetadataId: '', }; describe('Field metadata relation update should fail', () => { @@ -25,7 +38,22 @@ describe('Field metadata relation update should fail', () => { [ { title: 'when name is not in camel case', - context: { name: 'New Name' }, + context: { + fieldKey: 'employerFieldMetadataId', + updatePayload: { name: 'New Name' }, + }, + }, + { + title: 'when updating ONE_TO_MANY relation with onDelete action', + context: { + fieldKey: 'employeesOneToManyFieldMetadataId', + updatePayload: { + settings: { + relationType: RelationType.ONE_TO_MANY, + onDelete: RelationOnDeleteAction.SET_NULL, + }, + }, + }, }, ]; @@ -73,6 +101,27 @@ describe('Field metadata relation update should fail', () => { globalTestContext.employerFieldMetadataId = data.createOneField.id; expect(data).toBeDefined(); + + const { data: oneToManyData } = await createOneFieldMetadata({ + input: { + objectMetadataId: enterpriseObjectId, + name: 'staff', + label: 'Staff', + isLabelSyncedWithName: false, + type: FieldMetadataType.RELATION, + relationCreationPayload: { + targetFieldLabel: 'company', + type: RelationType.ONE_TO_MANY, + targetObjectMetadataId: employeeObjectId, + targetFieldIcon: 'IconUsers', + }, + }, + }); + + globalTestContext.employeesOneToManyFieldMetadataId = + oneToManyData.createOneField.id; + + expect(oneToManyData).toBeDefined(); }); afterAll(async () => { @@ -103,10 +152,8 @@ describe('Field metadata relation update should fail', () => { const { errors } = await updateOneFieldMetadata({ expectToFail: true, input: { - idToUpdate: globalTestContext.employerFieldMetadataId, - updatePayload: { - name: context.name, - }, + idToUpdate: globalTestContext[context.fieldKey], + updatePayload: context.updatePayload, }, });