From eeb91d9a96dbf448ee21aca743ec1283fd6b527c Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Wed, 31 Dec 2025 12:52:21 +0100 Subject: [PATCH] `[DO_NOT_RELEASE_MAIN_UNTIL_MERGED]` Prevent migration failure due to workspace orphan metadata rows (#16863) # Introduction The `AddWorkspaceForeignKeys1767002571103` migration would fail when released in production right now, as `foreignKey` be applicable as there's a lof of orphan entries in database As a workaround in order not to block any patch release we're fallbacking the migration using save point and an upgrade command that will attempt to apply the `foreignKey` on every workspace upgrade until it succeed We should keep in mind that any new fresh self installation will have the foreignKey double checked that it would not implies regression on workspace deletion using the integration tests ## Cleaning upgrade command We won't implement the cleaning command in this PR yet either will I as discussed with @Weiko someone else might be taking the subject starting next week --- > [!NOTE] > Strengthens workspace data integrity and makes the FK migration resilient. > > - Adds `upgrade:1-16:add-workspace-foreign-keys-migration` command to apply `workspaceId` FKs once per run; wires into `V1_16_UpgradeVersionCommandModule` and 1.16 upgrade sequence > - Refactors migration `1767002571103` to use `addWorkspaceForeignKeysQueries` util and wrap in a savepoint, swallowing errors to avoid blocking releases > - Extracts FK DDL into `utils/1767002571103-addWorkspaceForeignKeys.util` for reuse by command and migration > - Removes duplicate `workspaceId` columns from entities (e.g., `cronTrigger`, `databaseEventTrigger`, `indexMetadata`, `objectMetadata`, `roleTarget`, `role`, `serverlessFunction`) relying on `SyncableEntity`; keeps indexes/relations > - Marks legacy delete paths as deprecated; temporarily extends `WorkspaceManagerService.delete` to also delete `serverlessFunction` by `workspaceId` > - Updates wiring to inject `ServerlessFunctionEntity` repository in `workspace-manager` module/service and corresponding unit test > - Extends integration tests and adds GraphQL helpers to create serverless functions and triggers; verifies cascade deletion of related metadata on workspace removal > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6805bf5d1b32828b4bb1e9f130bfe6e478f66aee. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- ...orkspace-foreign-keys-migration.command.ts | 68 +++++++++++++++++ .../1-16-upgrade-version-command.module.ts | 11 ++- .../upgrade.command.ts | 7 +- .../1767002571103-addWorkspaceForeignKeys.ts | 74 +++++++------------ ...7002571103-addWorkspaceForeignKeys.util.ts | 54 ++++++++++++++ .../__tests__/workspace.service.spec.ts | 7 ++ .../workspace/services/workspace.service.ts | 20 +++-- .../workspace/workspace.module.ts | 2 + .../entities/cron-trigger.entity.ts | 5 +- .../entities/database-event-trigger.entity.ts | 5 +- .../index-metadata/index-metadata.entity.ts | 5 +- .../object-metadata/object-metadata.entity.ts | 6 +- .../role-target/role-target.entity.ts | 5 +- .../metadata-modules/role/role.entity.ts | 5 +- .../serverless-function.entity.ts | 5 +- .../workspace-manager.service.spec.ts | 21 ++++-- .../workspace-manager.module.ts | 2 + .../workspace-manager.service.ts | 14 +++- ...-workspace-creation-v1.integration-spec.ts | 55 +++++++++++++- ...and-workspace-creation.integration-spec.ts | 53 +++++++++++++ ...ate-one-cron-trigger-query-factory.util.ts | 27 +++++++ .../utils/create-one-cron-trigger.util.ts | 43 +++++++++++ ...tabase-event-trigger-query-factory.util.ts | 28 +++++++ .../create-one-database-event-trigger.util.ts | 44 +++++++++++ ...te-one-route-trigger-query-factory.util.ts | 29 ++++++++ .../utils/create-one-route-trigger.util.ts | 43 +++++++++++ ...-serverless-function-query-factory.util.ts | 34 +++++++++ .../create-one-serverless-function.util.ts | 44 +++++++++++ 28 files changed, 617 insertions(+), 99 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-16/1-16-add-workspace-foreign-keys-migration.command.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/utils/1767002571103-addWorkspaceForeignKeys.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-cron-trigger-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-cron-trigger.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-database-event-trigger-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-database-event-trigger.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-route-trigger-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-route-trigger.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-serverless-function-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-serverless-function.util.ts diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-16/1-16-add-workspace-foreign-keys-migration.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-16/1-16-add-workspace-foreign-keys-migration.command.ts new file mode 100644 index 0000000000..16d6e4109e --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-16/1-16-add-workspace-foreign-keys-migration.command.ts @@ -0,0 +1,68 @@ +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { DataSource, 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 { addWorkspaceForeignKeysQueries } from 'src/database/typeorm/core/migrations/utils/1767002571103-addWorkspaceForeignKeys.util'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; + +@Command({ + name: 'upgrade:1-16:add-workspace-foreign-keys-migration', + description: 'Add foreign key constraints for workspaceId on core tables', +}) +export class AddWorkspaceForeignKeysMigrationCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + private hasRunOnce = false; + + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager, + protected readonly dataSourceService: DataSourceService, + @InjectDataSource() + private readonly coreDataSource: DataSource, + ) { + super(workspaceRepository, twentyORMGlobalManager, dataSourceService); + } + + override async runOnWorkspace({ + options, + }: RunOnWorkspaceArgs): Promise { + if (this.hasRunOnce) { + this.logger.log( + 'Skipping has already been run once AddWorkspaceForeignKeysMigrationCommand', + ); + + return; + } + + if (options.dryRun) { + return; + } + + const queryRunner = this.coreDataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + await addWorkspaceForeignKeysQueries(queryRunner); + + await queryRunner.commitTransaction(); + this.logger.log( + 'Successfully run AddWorkspaceForeignKeysMigrationCommand', + ); + this.hasRunOnce = true; + } catch (error) { + await queryRunner.rollbackTransaction(); + this.logger.log( + `Rollbacking AddWorkspaceForeignKeysMigrationCommand: ${error.message}`, + ); + } finally { + await queryRunner.release(); + } + } +} 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 index 0d2ea350ae..1e726e9a29 100644 --- 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 @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AddWorkspaceForeignKeysMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-add-workspace-foreign-keys-migration.command'; import { BackfillUpdatedByFieldCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-backfill-updated-by-field.command'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -26,7 +27,13 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache WorkspaceCacheModule, ApplicationModule, ], - providers: [BackfillUpdatedByFieldCommand], - exports: [BackfillUpdatedByFieldCommand], + providers: [ + BackfillUpdatedByFieldCommand, + AddWorkspaceForeignKeysMigrationCommand, + ], + exports: [ + BackfillUpdatedByFieldCommand, + AddWorkspaceForeignKeysMigrationCommand, + ], }) export class V1_16_UpgradeVersionCommandModule {} 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 a5eb65b348..f86fa11791 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 @@ -20,6 +20,7 @@ import { DeleteRemovedAgentsCommand } from 'src/database/commands/upgrade-versio import { UpdateCreatedByEnumCommand } from 'src/database/commands/upgrade-version-command/1-14/1-14-update-created-by-enum.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 { AddWorkspaceForeignKeysMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-add-workspace-foreign-keys-migration.command'; import { BackfillUpdatedByFieldCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-backfill-updated-by-field.command'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -59,6 +60,7 @@ export class UpgradeCommand extends UpgradeCommandRunner { // 1.16 Commands protected readonly backfillUpdatedByFieldCommand: BackfillUpdatedByFieldCommand, + protected readonly addWorkspaceForeignKeysMigrationCommand: AddWorkspaceForeignKeysMigrationCommand, ) { super( workspaceRepository, @@ -90,7 +92,10 @@ export class UpgradeCommand extends UpgradeCommandRunner { this.fixNanPositionValuesInNotesCommand, ]; - const commands_1160: VersionCommands = [this.backfillUpdatedByFieldCommand]; + const commands_1160: VersionCommands = [ + this.backfillUpdatedByFieldCommand, + this.addWorkspaceForeignKeysMigrationCommand, + ]; this.allCommands = { '1.12.0': commands_1120, diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1767002571103-addWorkspaceForeignKeys.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1767002571103-addWorkspaceForeignKeys.ts index c5a61d8737..d6fbb3aaa3 100644 --- a/packages/twenty-server/src/database/typeorm/core/migrations/common/1767002571103-addWorkspaceForeignKeys.ts +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1767002571103-addWorkspaceForeignKeys.ts @@ -1,59 +1,37 @@ import { type MigrationInterface, type QueryRunner } from 'typeorm'; +import { addWorkspaceForeignKeysQueries } from 'src/database/typeorm/core/migrations/utils/1767002571103-addWorkspaceForeignKeys.util'; + export class AddWorkspaceForeignKeys1767002571103 implements MigrationInterface { name = 'AddWorkspaceForeignKeys1767002571103'; public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `ALTER TABLE "core"."indexMetadata" ADD CONSTRAINT "FK_5c988136a6d6f25a100c1064789" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "core"."roleTarget" ADD CONSTRAINT "FK_a86894bed7b7e1cc8b3f1d6186f" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "core"."role" ADD CONSTRAINT "FK_d2532f520d84f8c22ee45681c5a" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "core"."fieldMetadata" ADD CONSTRAINT "FK_9ce5ba7878f498bcf79e447a9a6" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "core"."objectMetadata" ADD CONSTRAINT "FK_d82a05a204136c01388ea80bc7a" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "core"."cronTrigger" ADD CONSTRAINT "FK_058c319eeb9799a4637908ce362" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "core"."databaseEventTrigger" ADD CONSTRAINT "FK_cf158c3199dcf2d52b0da05c33b" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "core"."routeTrigger" ADD CONSTRAINT "FK_5e004929fcf5e67398544b43885" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "core"."serverlessFunction" ADD CONSTRAINT "FK_ef5dde6a681970b9c1e10563498" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "core"."dataSource" ADD CONSTRAINT "FK_e1914827ee8b22fba4254578311" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "core"."objectPermission" ADD CONSTRAINT "FK_edcd87df18d3284141757bf6e16" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "core"."permissionFlag" ADD CONSTRAINT "FK_835bc9f7ef959debfc5cd268049" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "core"."serverlessFunctionLayer" ADD CONSTRAINT "FK_ca0699c3c906e903d7381c6a771" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "core"."workspaceMigration" ADD CONSTRAINT "FK_bebfcf8dcb299fd39ad04ed4c7f" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "core"."remoteTable" ADD CONSTRAINT "FK_30b429b14ddc6aab7495fa884f3" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "core"."remoteServer" ADD CONSTRAINT "FK_d1293835c5a0bb89fc0ed45713f" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); + const savepointName = 'sp_add_workspace_foreign_keys'; + + try { + await queryRunner.query(`SAVEPOINT ${savepointName}`); + + await addWorkspaceForeignKeysQueries(queryRunner); + + await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`); + } catch (e) { + try { + await queryRunner.query(`ROLLBACK TO SAVEPOINT ${savepointName}`); + await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`); + } catch (rollbackError) { + // eslint-disable-next-line no-console + console.error( + 'Failed to rollback to savepoint in AddWorkspaceForeignKeys1767002571103', + rollbackError, + ); + throw rollbackError; + } + + // eslint-disable-next-line no-console + console.error('Swallowing AddWorkspaceForeignKeys1767002571103 error', e); + } } public async down(queryRunner: QueryRunner): Promise { diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/utils/1767002571103-addWorkspaceForeignKeys.util.ts b/packages/twenty-server/src/database/typeorm/core/migrations/utils/1767002571103-addWorkspaceForeignKeys.util.ts new file mode 100644 index 0000000000..ed6f8d0c16 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/utils/1767002571103-addWorkspaceForeignKeys.util.ts @@ -0,0 +1,54 @@ +import { type QueryRunner } from 'typeorm'; + +export const addWorkspaceForeignKeysQueries = async ( + queryRunner: QueryRunner, +): Promise => { + await queryRunner.query( + `ALTER TABLE "core"."indexMetadata" ADD CONSTRAINT "FK_5c988136a6d6f25a100c1064789" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."roleTarget" ADD CONSTRAINT "FK_a86894bed7b7e1cc8b3f1d6186f" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."role" ADD CONSTRAINT "FK_d2532f520d84f8c22ee45681c5a" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."fieldMetadata" ADD CONSTRAINT "FK_9ce5ba7878f498bcf79e447a9a6" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."objectMetadata" ADD CONSTRAINT "FK_d82a05a204136c01388ea80bc7a" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."cronTrigger" ADD CONSTRAINT "FK_058c319eeb9799a4637908ce362" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."databaseEventTrigger" ADD CONSTRAINT "FK_cf158c3199dcf2d52b0da05c33b" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."routeTrigger" ADD CONSTRAINT "FK_5e004929fcf5e67398544b43885" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."serverlessFunction" ADD CONSTRAINT "FK_ef5dde6a681970b9c1e10563498" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."dataSource" ADD CONSTRAINT "FK_e1914827ee8b22fba4254578311" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."objectPermission" ADD CONSTRAINT "FK_edcd87df18d3284141757bf6e16" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."permissionFlag" ADD CONSTRAINT "FK_835bc9f7ef959debfc5cd268049" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."serverlessFunctionLayer" ADD CONSTRAINT "FK_ca0699c3c906e903d7381c6a771" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspaceMigration" ADD CONSTRAINT "FK_bebfcf8dcb299fd39ad04ed4c7f" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."remoteTable" ADD CONSTRAINT "FK_30b429b14ddc6aab7495fa884f3" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."remoteServer" ADD CONSTRAINT "FK_d1293835c5a0bb89fc0ed45713f" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); +}; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/__tests__/workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/__tests__/workspace.service.spec.ts index e89f1b6c9d..1eb09c40ed 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/__tests__/workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/__tests__/workspace.service.spec.ts @@ -28,6 +28,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; describe('WorkspaceService', () => { @@ -116,6 +117,12 @@ describe('WorkspaceService', () => { flush: jest.fn(), }, }, + { + provide: WorkspaceDataSourceService, + useValue: { + deleteWorkspaceDBSchema: jest.fn(), + }, + }, { provide: WorkspaceManyOrAllFlatEntityMapsCacheService, useValue: { diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 70c67e4755..011ce90a24 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -45,6 +45,7 @@ import { } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags'; import { extractVersionMajorMinorPatch } from 'src/utils/version/extract-version-major-minor-patch'; @@ -95,6 +96,7 @@ export class WorkspaceService extends TypeOrmQueryService { private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, private readonly subdomainManagerService: SubdomainManagerService, + private readonly workspaceDataSourceService: WorkspaceDataSourceService, private readonly customDomainManagerService: CustomDomainManagerService, @InjectMessageQueue(MessageQueue.deleteCascadeQueue) private readonly messageQueueService: MessageQueueService, @@ -276,15 +278,15 @@ export class WorkspaceService extends TypeOrmQueryService { }); } + /** + * @deprecated Should be removed once AddWorkspaceForeignKeysMigrationCommand has been run successfully in production + * As we will be able to rely on foreignKey delete cascading + */ async deleteMetadataSchemaCacheAndUserWorkspace(workspace: WorkspaceEntity) { await this.userWorkspaceService.deleteUserWorkspace({ userWorkspaceId: workspace.id, }); - if (this.billingService.isBillingEnabled()) { - await this.billingSubscriptionService.deleteSubscriptions(workspace.id); - } - await this.workspaceManagerService.delete(workspace.id); return workspace; @@ -323,11 +325,11 @@ export class WorkspaceService extends TypeOrmQueryService { }); this.logger.log(`workspace ${id} cache flushed`); - if (softDelete) { - if (this.billingService.isBillingEnabled()) { - await this.billingSubscriptionService.deleteSubscriptions(workspace.id); - } + if (this.billingService.isBillingEnabled()) { + await this.billingSubscriptionService.deleteSubscriptions(workspace.id); + } + if (softDelete) { await this.workspaceRepository.softDelete({ id }); this.logger.log(`workspace ${id} soft deleted`); @@ -337,6 +339,8 @@ export class WorkspaceService extends TypeOrmQueryService { await this.deleteMetadataSchemaCacheAndUserWorkspace(workspace); + await this.workspaceDataSourceService.deleteWorkspaceDBSchema(workspace.id); + await this.messageQueueService.add( FileWorkspaceFolderDeletionJob.name, { workspaceId: id }, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts index 72da403d1c..c37e813420 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts @@ -35,6 +35,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { ViewModule } from 'src/engine/metadata-modules/view/view.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; +import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; @Module({ @@ -59,6 +60,7 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m FeatureFlagModule, DataSourceModule, OnboardingModule, + WorkspaceDataSourceModule, TypeORMModule, PermissionsModule, WorkspaceCacheStorageModule, diff --git a/packages/twenty-server/src/engine/metadata-modules/cron-trigger/entities/cron-trigger.entity.ts b/packages/twenty-server/src/engine/metadata-modules/cron-trigger/entities/cron-trigger.entity.ts index 471d5dd430..b57ae409a6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/cron-trigger/entities/cron-trigger.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/cron-trigger/entities/cron-trigger.entity.ts @@ -10,8 +10,8 @@ import { UpdateDateColumn, } from 'typeorm'; -import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface'; import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; +import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface'; export type CronTriggerSettings = { pattern: string; @@ -40,9 +40,6 @@ export class CronTriggerEntity @Column({ nullable: false, type: 'uuid' }) serverlessFunctionId: string; - @Column({ nullable: false, type: 'uuid' }) - workspaceId: string; - @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; diff --git a/packages/twenty-server/src/engine/metadata-modules/database-event-trigger/entities/database-event-trigger.entity.ts b/packages/twenty-server/src/engine/metadata-modules/database-event-trigger/entities/database-event-trigger.entity.ts index 79ea16f4c0..3a3d6ad80f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/database-event-trigger/entities/database-event-trigger.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/database-event-trigger/entities/database-event-trigger.entity.ts @@ -10,8 +10,8 @@ import { UpdateDateColumn, } from 'typeorm'; -import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface'; import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; +import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface'; export type DatabaseEventTriggerSettings = { eventName: string; @@ -40,9 +40,6 @@ export class DatabaseEventTriggerEntity @Column({ nullable: false, type: 'uuid' }) serverlessFunctionId: string; - @Column({ nullable: false, type: 'uuid' }) - workspaceId: string; - @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts index 8dd14a0670..49d2813aaa 100644 --- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts @@ -12,10 +12,10 @@ import { UpdateDateColumn, } from 'typeorm'; -import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface'; import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.entity'; import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface'; @Unique('IDX_INDEX_METADATA_NAME_WORKSPACE_ID_OBJECT_METADATA_ID_UNIQUE', [ 'name', @@ -43,9 +43,6 @@ export class IndexMetadataEntity @Column({ nullable: false }) name: string; - @Column({ nullable: false, type: 'uuid' }) - workspaceId: string; - @Column({ nullable: false, type: 'uuid' }) objectMetadataId: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts index 4130900961..fd392a46f1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts @@ -10,7 +10,6 @@ import { UpdateDateColumn, } from 'typeorm'; -import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface'; import { type WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type'; import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; @@ -19,6 +18,7 @@ import { type ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/obj import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity'; import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity'; import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; +import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface'; @Entity('objectMetadata') @Unique('IDX_OBJECT_METADATA_NAME_SINGULAR_WORKSPACE_ID_UNIQUE', [ @@ -106,10 +106,6 @@ export class ObjectMetadataEntity @Column({ default: false }) isLabelSyncedWithName: boolean; - // TODO create a relation to workspace with cascade delete will also create foreignKey - @Column({ nullable: false, type: 'uuid' }) - workspaceId: string; - @OneToMany(() => FieldMetadataEntity, (field) => field.object, { cascade: true, }) diff --git a/packages/twenty-server/src/engine/metadata-modules/role-target/role-target.entity.ts b/packages/twenty-server/src/engine/metadata-modules/role-target/role-target.entity.ts index 15d78846e0..ca883ea35f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role-target/role-target.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role-target/role-target.entity.ts @@ -12,9 +12,9 @@ import { UpdateDateColumn, } from 'typeorm'; -import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface'; import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface'; @Entity('roleTarget') @Unique('IDX_ROLE_TARGET_UNIQUE_USER_WORKSPACE', [ @@ -34,9 +34,6 @@ export class RoleTargetEntity extends SyncableEntity { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ nullable: false, type: 'uuid' }) - workspaceId: string; - @Column({ nullable: false, type: 'uuid' }) roleId: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts index cb6e9b5999..4c23775ee2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts @@ -9,11 +9,11 @@ import { UpdateDateColumn, } from 'typeorm'; -import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface'; import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity'; import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity'; import { PermissionFlagEntity } from 'src/engine/metadata-modules/permission-flag/permission-flag.entity'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; +import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface'; @Entity('role') @Unique('IDX_ROLE_LABEL_WORKSPACE_ID_UNIQUE', ['label', 'workspaceId']) @@ -51,9 +51,6 @@ export class RoleEntity extends SyncableEntity implements Required { @Column({ nullable: true, type: 'varchar' }) icon: string | null; - @Column({ nullable: false, type: 'uuid' }) - workspaceId: string; - @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts index 95143acaf9..b818a61e7f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts @@ -13,11 +13,11 @@ import { UpdateDateColumn, } from 'typeorm'; -import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface'; import { CronTriggerEntity } from 'src/engine/metadata-modules/cron-trigger/entities/cron-trigger.entity'; import { DatabaseEventTriggerEntity } from 'src/engine/metadata-modules/database-event-trigger/entities/database-event-trigger.entity'; import { RouteTriggerEntity } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity'; import { ServerlessFunctionLayerEntity } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.entity'; +import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface'; const DEFAULT_SERVERLESS_TIMEOUT_SECONDS = 300; // 5 minutes @@ -63,9 +63,6 @@ export class ServerlessFunctionEntity @Check(`"timeoutSeconds" >= 1 AND "timeoutSeconds" <= 900`) timeoutSeconds: number; - @Column({ nullable: false, type: 'uuid' }) - workspaceId: string; - @Column({ nullable: true, type: 'text' }) checksum: string | null; diff --git a/packages/twenty-server/src/engine/workspace-manager/__tests__/workspace-manager.service.spec.ts b/packages/twenty-server/src/engine/workspace-manager/__tests__/workspace-manager.service.spec.ts index 5aacc97b43..c80d0ce4bd 100644 --- a/packages/twenty-server/src/engine/workspace-manager/__tests__/workspace-manager.service.spec.ts +++ b/packages/twenty-server/src/engine/workspace-manager/__tests__/workspace-manager.service.spec.ts @@ -18,6 +18,7 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { RoleService } from 'src/engine/metadata-modules/role/role.service'; +import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service'; @@ -31,9 +32,9 @@ describe('WorkspaceManagerService', () => { let service: WorkspaceManagerService; let workspaceMigrationRepository: Repository; let dataSourceRepository: Repository; - let workspaceDataSourceService: WorkspaceDataSourceService; let roleTargetRepository: Repository; let roleRepository: Repository; + let serverlessFunctionRepository: Repository; let mockDataSource: jest.Mocked; let objectMetadataService: ObjectMetadataService; @@ -95,6 +96,12 @@ describe('WorkspaceManagerService', () => { delete: jest.fn(), }, }, + { + provide: getRepositoryToken(ServerlessFunctionEntity), + useValue: { + delete: jest.fn(), + }, + }, { provide: PermissionsService, useValue: {}, @@ -169,15 +176,15 @@ describe('WorkspaceManagerService', () => { dataSourceRepository = module.get>( getRepositoryToken(DataSourceEntity), ); - workspaceDataSourceService = module.get( - WorkspaceDataSourceService, - ); roleTargetRepository = module.get>( getRepositoryToken(RoleTargetEntity), ); roleRepository = module.get>( getRepositoryToken(RoleEntity), ); + serverlessFunctionRepository = module.get< + Repository + >(getRepositoryToken(ServerlessFunctionEntity)); objectMetadataService = module.get( ObjectMetadataService, ); @@ -205,9 +212,9 @@ describe('WorkspaceManagerService', () => { expect(roleRepository.delete).toHaveBeenCalledWith({ workspaceId: 'workspace-id', }); - expect( - workspaceDataSourceService.deleteWorkspaceDBSchema, - ).toHaveBeenCalled(); + expect(serverlessFunctionRepository.delete).toHaveBeenCalledWith({ + workspaceId: 'workspace-id', + }); }); }); }); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts index 3ba364f9ce..9d44cbefcf 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts @@ -10,6 +10,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; +import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; @@ -46,6 +47,7 @@ import { WorkspaceManagerService } from './workspace-manager.service'; FieldMetadataEntity, RoleTargetEntity, RoleEntity, + ServerlessFunctionEntity, ]), ], exports: [WorkspaceManagerService], diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts index dcb659b603..6afa4dfec6 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts @@ -17,6 +17,7 @@ import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metada import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { RoleService } from 'src/engine/metadata-modules/role/role.service'; +import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; @@ -49,6 +50,8 @@ export class WorkspaceManagerService { private readonly roleRepository: Repository, @InjectRepository(RoleTargetEntity) private readonly roleTargetRepository: Repository, + @InjectRepository(ServerlessFunctionEntity) + private readonly serverlessFunctionRepository: Repository, protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, private readonly applicationService: ApplicationService, private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, @@ -182,8 +185,10 @@ export class WorkspaceManagerService { }); } - // TODO investigate why some entities are not on cascade delete - // Are foreign keys correctly applied ? + /** + * @deprecated Should be removed once AddWorkspaceForeignKeysMigrationCommand has been run successfully in production + * As we will be able to rely on foreignKey delete cascading + */ public async delete(workspaceId: string): Promise { await this.roleTargetRepository.delete({ workspaceId, @@ -192,13 +197,16 @@ export class WorkspaceManagerService { workspaceId, }); + await this.serverlessFunctionRepository.delete({ + workspaceId, + }); + await this.objectMetadataService.deleteWorkspaceAllObjectMetadata({ workspaceId, }); await this.workspaceMigrationService.deleteAllWithinWorkspace(workspaceId); await this.dataSourceService.delete(workspaceId); - await this.workspaceDataSourceService.deleteWorkspaceDBSchema(workspaceId); } private async setupDefaultRoles({ diff --git a/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation-v1.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation-v1.integration-spec.ts index 40e25a2d37..1cabb2214c 100644 --- a/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation-v1.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation-v1.integration-spec.ts @@ -8,10 +8,15 @@ import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user. import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util'; import { signUp } from 'test/integration/graphql/utils/sign-up.util'; import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { createOneCronTrigger } from 'test/integration/metadata/suites/serverless-function/utils/create-one-cron-trigger.util'; +import { createOneDatabaseEventTrigger } from 'test/integration/metadata/suites/serverless-function/utils/create-one-database-event-trigger.util'; +import { createOneRouteTrigger } from 'test/integration/metadata/suites/serverless-function/utils/create-one-route-trigger.util'; +import { createOneServerlessFunction } from 'test/integration/metadata/suites/serverless-function/utils/create-one-serverless-function.util'; +import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util'; import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; import { isDefined } from 'twenty-shared/utils'; +import { HTTPMethod } from 'twenty-shared/types'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; -import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications'; @@ -206,6 +211,54 @@ describe('Successful user and workspace creation', () => { expectToFail: false, }); + // Create a serverless function with route, cron, and database event triggers + const { + data: { createOneServerlessFunction: serverlessFunction }, + } = await createOneServerlessFunction({ + input: { + name: 'test-function-for-deletion', + description: 'A test serverless function for workspace deletion test', + }, + token: newWorkspaceAccessToken, + expectToFail: false, + }); + + // Create a route trigger for the serverless function + await createOneRouteTrigger({ + input: { + path: '/test-route-for-deletion', + isAuthRequired: true, + httpMethod: HTTPMethod.GET, + serverlessFunctionId: serverlessFunction.id, + }, + token: newWorkspaceAccessToken, + expectToFail: false, + }); + + // Create a cron trigger for the serverless function + await createOneCronTrigger({ + input: { + settings: { + pattern: '0 0 * * *', + }, + serverlessFunctionId: serverlessFunction.id, + }, + token: newWorkspaceAccessToken, + expectToFail: false, + }); + + // Create a database event trigger for the serverless function + await createOneDatabaseEventTrigger({ + input: { + settings: { + eventName: 'company.created', + }, + serverlessFunctionId: serverlessFunction.id, + }, + token: newWorkspaceAccessToken, + expectToFail: false, + }); + const workspaceBeforeDeletion = await testDataSource.query( 'SELECT * FROM core.workspace WHERE id = $1', [workspaceId], diff --git a/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation.integration-spec.ts index 2fc70af8fb..e21052e5e5 100644 --- a/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation.integration-spec.ts @@ -8,7 +8,12 @@ import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user. import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util'; import { signUp } from 'test/integration/graphql/utils/sign-up.util'; import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { createOneCronTrigger } from 'test/integration/metadata/suites/serverless-function/utils/create-one-cron-trigger.util'; +import { createOneDatabaseEventTrigger } from 'test/integration/metadata/suites/serverless-function/utils/create-one-database-event-trigger.util'; +import { createOneRouteTrigger } from 'test/integration/metadata/suites/serverless-function/utils/create-one-route-trigger.util'; +import { createOneServerlessFunction } from 'test/integration/metadata/suites/serverless-function/utils/create-one-serverless-function.util'; import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; +import { HTTPMethod } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; @@ -188,6 +193,54 @@ describe('Successful user and workspace creation', () => { expectToFail: false, }); + // Create a serverless function with route, cron, and database event triggers + const { + data: { createOneServerlessFunction: serverlessFunction }, + } = await createOneServerlessFunction({ + input: { + name: 'test-function-for-deletion', + description: 'A test serverless function for workspace deletion test', + }, + token: newWorkspaceAccessToken, + expectToFail: false, + }); + + // Create a route trigger for the serverless function + await createOneRouteTrigger({ + input: { + path: '/test-route-for-deletion', + isAuthRequired: true, + httpMethod: HTTPMethod.GET, + serverlessFunctionId: serverlessFunction.id, + }, + token: newWorkspaceAccessToken, + expectToFail: false, + }); + + // Create a cron trigger for the serverless function + await createOneCronTrigger({ + input: { + settings: { + pattern: '0 0 * * *', + }, + serverlessFunctionId: serverlessFunction.id, + }, + token: newWorkspaceAccessToken, + expectToFail: false, + }); + + // Create a database event trigger for the serverless function + await createOneDatabaseEventTrigger({ + input: { + settings: { + eventName: 'company.created', + }, + serverlessFunctionId: serverlessFunction.id, + }, + token: newWorkspaceAccessToken, + expectToFail: false, + }); + const workspaceBeforeDeletion = await testDataSource.query( 'SELECT * FROM core.workspace WHERE id = $1', [workspaceId], diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-cron-trigger-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-cron-trigger-query-factory.util.ts new file mode 100644 index 0000000000..07de1b997b --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-cron-trigger-query-factory.util.ts @@ -0,0 +1,27 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +import { type CreateCronTriggerInput } from 'src/engine/metadata-modules/cron-trigger/dtos/create-cron-trigger.input'; + +export type CreateOneCronTriggerFactoryInput = CreateCronTriggerInput; + +const DEFAULT_CRON_TRIGGER_GQL_FIELDS = ` + id + settings +`; + +export const createOneCronTriggerQueryFactory = ({ + input, + gqlFields = DEFAULT_CRON_TRIGGER_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + mutation CreateOneCronTrigger($input: CreateCronTriggerInput!) { + createOneCronTrigger(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-cron-trigger.util.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-cron-trigger.util.ts new file mode 100644 index 0000000000..84349c9d79 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-cron-trigger.util.ts @@ -0,0 +1,43 @@ +import { + type CreateOneCronTriggerFactoryInput, + createOneCronTriggerQueryFactory, +} from 'test/integration/metadata/suites/serverless-function/utils/create-one-cron-trigger-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type CronTriggerDTO } from 'src/engine/metadata-modules/cron-trigger/dtos/cron-trigger.dto'; + +export const createOneCronTrigger = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + createOneCronTrigger: CronTriggerDTO; +}> => { + const graphqlOperation = createOneCronTriggerQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Cron Trigger creation should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Cron Trigger creation has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-database-event-trigger-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-database-event-trigger-query-factory.util.ts new file mode 100644 index 0000000000..6cfadb3b0e --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-database-event-trigger-query-factory.util.ts @@ -0,0 +1,28 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +import { type CreateDatabaseEventTriggerInput } from 'src/engine/metadata-modules/database-event-trigger/dtos/create-database-event-trigger.input'; + +export type CreateOneDatabaseEventTriggerFactoryInput = + CreateDatabaseEventTriggerInput; + +const DEFAULT_DATABASE_EVENT_TRIGGER_GQL_FIELDS = ` + id + settings +`; + +export const createOneDatabaseEventTriggerQueryFactory = ({ + input, + gqlFields = DEFAULT_DATABASE_EVENT_TRIGGER_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + mutation CreateOneDatabaseEventTrigger($input: CreateDatabaseEventTriggerInput!) { + createOneDatabaseEventTrigger(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-database-event-trigger.util.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-database-event-trigger.util.ts new file mode 100644 index 0000000000..b3c6aae4ef --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-database-event-trigger.util.ts @@ -0,0 +1,44 @@ +import { + type CreateOneDatabaseEventTriggerFactoryInput, + createOneDatabaseEventTriggerQueryFactory, +} from 'test/integration/metadata/suites/serverless-function/utils/create-one-database-event-trigger-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type DatabaseEventTriggerDTO } from 'src/engine/metadata-modules/database-event-trigger/dtos/database-event-trigger.dto'; + +export const createOneDatabaseEventTrigger = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + createOneDatabaseEventTrigger: DatabaseEventTriggerDTO; +}> => { + const graphqlOperation = createOneDatabaseEventTriggerQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: + 'Database Event Trigger creation should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Database Event Trigger creation has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-route-trigger-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-route-trigger-query-factory.util.ts new file mode 100644 index 0000000000..84f6b8c1d6 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-route-trigger-query-factory.util.ts @@ -0,0 +1,29 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +import { type CreateRouteTriggerInput } from 'src/engine/metadata-modules/route-trigger/dtos/create-route-trigger.input'; + +export type CreateOneRouteTriggerFactoryInput = CreateRouteTriggerInput; + +const DEFAULT_ROUTE_TRIGGER_GQL_FIELDS = ` + id + path + isAuthRequired + httpMethod +`; + +export const createOneRouteTriggerQueryFactory = ({ + input, + gqlFields = DEFAULT_ROUTE_TRIGGER_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + mutation CreateOneRouteTrigger($input: CreateRouteTriggerInput!) { + createOneRouteTrigger(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-route-trigger.util.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-route-trigger.util.ts new file mode 100644 index 0000000000..f70d796ff8 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-route-trigger.util.ts @@ -0,0 +1,43 @@ +import { + type CreateOneRouteTriggerFactoryInput, + createOneRouteTriggerQueryFactory, +} from 'test/integration/metadata/suites/serverless-function/utils/create-one-route-trigger-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type RouteTriggerDTO } from 'src/engine/metadata-modules/route-trigger/dtos/route-trigger.dto'; + +export const createOneRouteTrigger = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + createOneRouteTrigger: RouteTriggerDTO; +}> => { + const graphqlOperation = createOneRouteTriggerQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Route Trigger creation should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Route Trigger creation has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-serverless-function-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-serverless-function-query-factory.util.ts new file mode 100644 index 0000000000..dd7cd93cf0 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-serverless-function-query-factory.util.ts @@ -0,0 +1,34 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +import { type CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input'; + +export type CreateOneServerlessFunctionFactoryInput = + CreateServerlessFunctionInput; + +const DEFAULT_SERVERLESS_FUNCTION_GQL_FIELDS = ` + id + name + description + runtime + latestVersion + publishedVersions + createdAt + updatedAt +`; + +export const createOneServerlessFunctionQueryFactory = ({ + input, + gqlFields = DEFAULT_SERVERLESS_FUNCTION_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + mutation CreateOneServerlessFunction($input: CreateServerlessFunctionInput!) { + createOneServerlessFunction(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-serverless-function.util.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-serverless-function.util.ts new file mode 100644 index 0000000000..121d5d4218 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/create-one-serverless-function.util.ts @@ -0,0 +1,44 @@ +import { + type CreateOneServerlessFunctionFactoryInput, + createOneServerlessFunctionQueryFactory, +} from 'test/integration/metadata/suites/serverless-function/utils/create-one-serverless-function-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto'; + +export const createOneServerlessFunction = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + createOneServerlessFunction: ServerlessFunctionDTO; +}> => { + const graphqlOperation = createOneServerlessFunctionQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: + 'Serverless Function creation should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Serverless Function creation has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +};