From 0e98ab1c87ab118d46bfb529237bdc538dde72ec Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Tue, 30 Sep 2025 01:25:42 +0500 Subject: [PATCH 01/18] feat: add workspace trash retention properties to workspace entity and update sentry-cron-monitor --- ...759174515979-addWorkspaceTrashRetention.ts | 22 +++++++++++++++++++ .../cron/sentry-cron-monitor.decorator.ts | 4 ++-- .../workspace/workspace.entity.ts | 12 ++++++++++ .../sanitize-default-value.util.spec.ts | 21 ++++++++++++++++++ .../utils/sanitize-default-value.util.ts | 2 +- 5 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1759174515979-addWorkspaceTrashRetention.ts diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1759174515979-addWorkspaceTrashRetention.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1759174515979-addWorkspaceTrashRetention.ts new file mode 100644 index 0000000000000..0661b6463573d --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1759174515979-addWorkspaceTrashRetention.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddWorkspaceTrashRetention1759174515979 implements MigrationInterface { + name = 'AddWorkspaceTrashRetention1759174515979' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "core"."workspace" ADD "trashRetentionDays" integer NOT NULL DEFAULT '14'`); + await queryRunner.query(`ALTER TABLE "core"."workspace" ADD "nextTrashCleanupAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + INTERVAL '14 days'`); + await queryRunner.query(`CREATE INDEX "IDX_WORKSPACE_NEXT_TRASH_CLEANUP_AT" ON "core"."workspace" ("nextTrashCleanupAt") `); + + // Backfill existing workspaces with staggered cleanup times to prevent thundering herd + // Spreads cleanup across 24 hours using random distribution + await queryRunner.query(`UPDATE "core"."workspace" SET "nextTrashCleanupAt" = NOW() + (RANDOM() * INTERVAL '24 hours') WHERE "nextTrashCleanupAt" = NOW() + INTERVAL '14 days'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "core"."IDX_WORKSPACE_NEXT_TRASH_CLEANUP_AT"`); + await queryRunner.query(`ALTER TABLE "core"."workspace" DROP COLUMN "nextTrashCleanupAt"`); + await queryRunner.query(`ALTER TABLE "core"."workspace" DROP COLUMN "trashRetentionDays"`); + } + +} diff --git a/packages/twenty-server/src/engine/core-modules/cron/sentry-cron-monitor.decorator.ts b/packages/twenty-server/src/engine/core-modules/cron/sentry-cron-monitor.decorator.ts index 0fe54013d5380..a8457b1a05a7c 100644 --- a/packages/twenty-server/src/engine/core-modules/cron/sentry-cron-monitor.decorator.ts +++ b/packages/twenty-server/src/engine/core-modules/cron/sentry-cron-monitor.decorator.ts @@ -1,6 +1,6 @@ import * as Sentry from '@sentry/node'; -export function SentryCronMonitor(monitorSlug: string, schedule: string) { +export function SentryCronMonitor(monitorSlug: string, schedule: string, maxRuntime: number = 5) { return function ( // eslint-disable-next-line @typescript-eslint/no-explicit-any _target: any, @@ -29,7 +29,7 @@ export function SentryCronMonitor(monitorSlug: string, schedule: string) { value: schedule, }, checkinMargin: 1, - maxRuntime: 5, + maxRuntime, timezone: 'UTC', }, ); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index db80d446172f4..aa58ea9337197 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -92,6 +92,18 @@ export class Workspace { @Column({ default: true }) isPublicInviteLinkEnabled: boolean; + @Field() + @Column({ type: 'integer', default: 14 }) + trashRetentionDays: number; + + @Field() + @Column({ + type: 'timestamptz', + default: () => "NOW() + INTERVAL '14 days'", + }) + @Index('IDX_WORKSPACE_NEXT_TRASH_CLEANUP_AT') + nextTrashCleanupAt: Date; + // Relations @OneToMany(() => AppToken, (appToken) => appToken.workspace, { cascade: true, diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/__tests__/sanitize-default-value.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/__tests__/sanitize-default-value.util.spec.ts index 1246d92bd39ca..63087173be37b 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/__tests__/sanitize-default-value.util.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/__tests__/sanitize-default-value.util.spec.ts @@ -29,6 +29,27 @@ describe('sanitizeDefaultValue', () => { expect(sanitizeDefaultValue('NOW()')).toBe('NOW()'); }); + + it('should allow now() + interval expressions for trash cleanup', () => { + // Prepare + const input = "now() + interval '14 days'"; + + // Act + const result = sanitizeDefaultValue(input); + + // Assert + expect(result).toBe("now() + interval '14 days'"); + }); + + it('should be case insensitive for interval expressions', () => { + // Act & Assert + expect(sanitizeDefaultValue("NOW() + INTERVAL '14 days'")).toBe( + "NOW() + INTERVAL '14 days'", + ); + expect(sanitizeDefaultValue("Now() + Interval '14 days'")).toBe( + "Now() + Interval '14 days'", + ); + }); }); describe('SQL injection prevention', () => { diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts index 466a144ec24d8..e50e8578991d6 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts @@ -7,7 +7,7 @@ export const sanitizeDefaultValue = ( return 'NULL'; } - const allowedFunctions = ['public.uuid_generate_v4()', 'now()']; + const allowedFunctions = ['public.uuid_generate_v4()', 'now()', "now() + interval '14 days'"]; if (typeof defaultValue === 'string') { if (allowedFunctions.includes(defaultValue.toLowerCase())) { From 19cc5d71491260686c52c56fb1afff9251a5a3e6 Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Wed, 1 Oct 2025 03:38:52 +0500 Subject: [PATCH 02/18] feat: add workspace trash cleanup cron with SOLID refactoring --- .../commands/cron-register-all.command.ts | 6 + .../commands/database-command.module.ts | 2 + ...759174515979-addWorkspaceTrashRetention.ts | 22 -- ...182953990-add-workspace-trash-retention.ts | 37 ++++ .../cron/sentry-cron-monitor.decorator.ts | 6 +- .../twenty-config/config-variables.ts | 19 ++ .../workspace/workspace.entity.ts | 1 + .../utils/sanitize-default-value.util.ts | 6 +- .../workspace-manager.module.ts | 3 + .../workspace-trash-cleanup.cron.command.ts | 30 +++ .../workspace-trash-cleanup.constants.ts | 1 + .../crons/workspace-trash-cleanup.cron.job.ts | 26 +++ .../workspace-trash-cleanup.resolver.ts | 17 ++ .../workspace-trash-cleanup.service.ts | 194 ++++++++++++++++++ .../workspace-trash-deletion.service.ts | 68 ++++++ ...workspace-trash-table-discovery.service.ts | 58 ++++++ .../workspace-trash-cleanup.module.ts | 24 +++ 17 files changed, 496 insertions(+), 24 deletions(-) delete mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1759174515979-addWorkspaceTrashRetention.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1759182953990-add-workspace-trash-retention.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/resolvers/workspace-trash-cleanup.resolver.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-deletion.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-table-discovery.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts diff --git a/packages/twenty-server/src/database/commands/cron-register-all.command.ts b/packages/twenty-server/src/database/commands/cron-register-all.command.ts index d409fe1a7557e..39adc8328083a 100644 --- a/packages/twenty-server/src/database/commands/cron-register-all.command.ts +++ b/packages/twenty-server/src/database/commands/cron-register-all.command.ts @@ -6,6 +6,7 @@ import { CleanupOrphanedFilesCronCommand } from 'src/engine/core-modules/file/cr import { CronTriggerCronCommand } from 'src/engine/metadata-modules/trigger/crons/commands/cron-trigger.cron.command'; import { CleanOnboardingWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command'; import { CleanSuspendedWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-suspended-workspaces.cron.command'; +import { WorkspaceTrashCleanupCronCommand } from 'src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command'; import { CalendarEventListFetchCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-event-list-fetch.cron.command'; import { CalendarEventsImportCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-import.cron.command'; import { CalendarOngoingStaleCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-ongoing-stale.cron.command'; @@ -44,6 +45,7 @@ export class CronRegisterAllCommand extends CommandRunner { private readonly cronTriggerCronCommand: CronTriggerCronCommand, private readonly cleanSuspendedWorkspacesCronCommand: CleanSuspendedWorkspacesCronCommand, private readonly cleanOnboardingWorkspacesCronCommand: CleanOnboardingWorkspacesCronCommand, + private readonly workspaceTrashCleanupCronCommand: WorkspaceTrashCleanupCronCommand, ) { super(); } @@ -116,6 +118,10 @@ export class CronRegisterAllCommand extends CommandRunner { name: 'CleanOnboardingWorkspaces', command: this.cleanOnboardingWorkspacesCronCommand, }, + { + name: 'WorkspaceTrashCleanup', + command: this.workspaceTrashCleanupCronCommand, + }, ]; let successCount = 0; diff --git a/packages/twenty-server/src/database/commands/database-command.module.ts b/packages/twenty-server/src/database/commands/database-command.module.ts index 16968661104eb..877e4c0945f8c 100644 --- a/packages/twenty-server/src/database/commands/database-command.module.ts +++ b/packages/twenty-server/src/database/commands/database-command.module.ts @@ -18,6 +18,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ import { DevSeederModule } from 'src/engine/workspace-manager/dev-seeder/dev-seeder.module'; import { WorkspaceCleanerModule } from 'src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; +import { WorkspaceTrashCleanupModule } from 'src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module'; import { CalendarEventImportManagerModule } from 'src/modules/calendar/calendar-event-import-manager/calendar-event-import-manager.module'; import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module'; import { WorkflowRunQueueModule } from 'src/modules/workflow/workflow-runner/workflow-run-queue/workflow-run-queue.module'; @@ -48,6 +49,7 @@ import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public FeatureFlagModule, TriggerModule, WorkspaceCleanerModule, + WorkspaceTrashCleanupModule, PublicDomainModule, ], providers: [ diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1759174515979-addWorkspaceTrashRetention.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1759174515979-addWorkspaceTrashRetention.ts deleted file mode 100644 index 0661b6463573d..0000000000000 --- a/packages/twenty-server/src/database/typeorm/core/migrations/common/1759174515979-addWorkspaceTrashRetention.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class AddWorkspaceTrashRetention1759174515979 implements MigrationInterface { - name = 'AddWorkspaceTrashRetention1759174515979' - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "core"."workspace" ADD "trashRetentionDays" integer NOT NULL DEFAULT '14'`); - await queryRunner.query(`ALTER TABLE "core"."workspace" ADD "nextTrashCleanupAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + INTERVAL '14 days'`); - await queryRunner.query(`CREATE INDEX "IDX_WORKSPACE_NEXT_TRASH_CLEANUP_AT" ON "core"."workspace" ("nextTrashCleanupAt") `); - - // Backfill existing workspaces with staggered cleanup times to prevent thundering herd - // Spreads cleanup across 24 hours using random distribution - await queryRunner.query(`UPDATE "core"."workspace" SET "nextTrashCleanupAt" = NOW() + (RANDOM() * INTERVAL '24 hours') WHERE "nextTrashCleanupAt" = NOW() + INTERVAL '14 days'`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "core"."IDX_WORKSPACE_NEXT_TRASH_CLEANUP_AT"`); - await queryRunner.query(`ALTER TABLE "core"."workspace" DROP COLUMN "nextTrashCleanupAt"`); - await queryRunner.query(`ALTER TABLE "core"."workspace" DROP COLUMN "trashRetentionDays"`); - } - -} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1759182953990-add-workspace-trash-retention.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1759182953990-add-workspace-trash-retention.ts new file mode 100644 index 0000000000000..06c59fd7108a7 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1759182953990-add-workspace-trash-retention.ts @@ -0,0 +1,37 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddWorkspaceTrashRetention1759182953990 + implements MigrationInterface +{ + name = 'AddWorkspaceTrashRetention1759182953990'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "trashRetentionDays" integer NOT NULL DEFAULT '14'`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "nextTrashCleanupAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + INTERVAL '14 days'`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_WORKSPACE_NEXT_TRASH_CLEANUP_AT" ON "core"."workspace" ("nextTrashCleanupAt") `, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD CONSTRAINT "trash_retention_positive" CHECK ("trashRetentionDays" >= 0)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP CONSTRAINT "trash_retention_positive"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_WORKSPACE_NEXT_TRASH_CLEANUP_AT"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "nextTrashCleanupAt"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "trashRetentionDays"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/cron/sentry-cron-monitor.decorator.ts b/packages/twenty-server/src/engine/core-modules/cron/sentry-cron-monitor.decorator.ts index a8457b1a05a7c..980dc0f526e4a 100644 --- a/packages/twenty-server/src/engine/core-modules/cron/sentry-cron-monitor.decorator.ts +++ b/packages/twenty-server/src/engine/core-modules/cron/sentry-cron-monitor.decorator.ts @@ -1,6 +1,10 @@ import * as Sentry from '@sentry/node'; -export function SentryCronMonitor(monitorSlug: string, schedule: string, maxRuntime: number = 5) { +export function SentryCronMonitor( + monitorSlug: string, + schedule: string, + maxRuntime: number = 5, +) { return function ( // eslint-disable-next-line @typescript-eslint/no-explicit-any _target: any, diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index fda94ae4ecf5d..b5321bdad191b 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -1114,6 +1114,25 @@ export class ConfigVariables { @ValidateIf((env) => env.MAX_NUMBER_OF_WORKSPACES_DELETED_PER_EXECUTION > 0) MAX_NUMBER_OF_WORKSPACES_DELETED_PER_EXECUTION = 5; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.Other, + description: + 'Number of workspaces to process per batch during trash cleanup', + type: ConfigVariableType.NUMBER, + }) + @CastToPositiveNumber() + @IsOptional() + TRASH_CLEANUP_WORKSPACE_BATCH_SIZE = 5; + + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.Other, + description: 'Delay in milliseconds between workspace cleanup batches', + type: ConfigVariableType.NUMBER, + }) + @CastToPositiveNumber() + @IsOptional() + TRASH_CLEANUP_DELAY_BETWEEN_BATCHES_MS = 500; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.RateLimiting, description: 'Throttle limit for workflow execution', diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index aa58ea9337197..d5eeed8c04baa 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -52,6 +52,7 @@ registerEnumType(WorkspaceActivationStatus, { 'onboarded_workspace_requires_default_role', `"activationStatus" IN ('PENDING_CREATION', 'ONGOING_CREATION') OR "defaultRoleId" IS NOT NULL`, ) +@Check('trash_retention_positive', '"trashRetentionDays" >= 0') @Entity({ name: 'workspace', schema: 'core' }) @ObjectType() export class Workspace { diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts index e50e8578991d6..256db57f43453 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts @@ -7,7 +7,11 @@ export const sanitizeDefaultValue = ( return 'NULL'; } - const allowedFunctions = ['public.uuid_generate_v4()', 'now()', "now() + interval '14 days'"]; + const allowedFunctions = [ + 'public.uuid_generate_v4()', + 'now()', + "now() + interval '14 days'", + ]; if (typeof defaultValue === 'string') { if (allowedFunctions.includes(defaultValue.toLowerCase())) { 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 95764eb8a9844..aaf911959aee8 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 @@ -22,6 +22,8 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp import { WorkspaceManagerService } from './workspace-manager.service'; +import { WorkspaceTrashCleanupModule } from './workspace-trash-cleanup/workspace-trash-cleanup.module'; + @Module({ imports: [ WorkspaceDataSourceModule, @@ -32,6 +34,7 @@ import { WorkspaceManagerService } from './workspace-manager.service'; DataSourceModule, WorkspaceSyncMetadataModule, WorkspaceHealthModule, + WorkspaceTrashCleanupModule, FeatureFlagModule, PermissionsModule, AgentModule, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command.ts new file mode 100644 index 0000000000000..01e7b956de8bf --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command.ts @@ -0,0 +1,30 @@ +import { Command, CommandRunner } from 'nest-commander'; + +import { WORKSPACE_TRASH_CLEANUP_CRON_PATTERN } from 'src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants'; +import { WorkspaceTrashCleanupCronJob } from 'src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job'; +import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; + +@Command({ + name: 'cron:workspace:cleanup-trash', + description: 'Starts a cron job to clean up soft-deleted records', +}) +export class WorkspaceTrashCleanupCronCommand extends CommandRunner { + constructor( + @InjectMessageQueue(MessageQueue.cronQueue) + private readonly messageQueueService: MessageQueueService, + ) { + super(); + } + + async run(): Promise { + await this.messageQueueService.addCron({ + jobName: WorkspaceTrashCleanupCronJob.name, + data: undefined, + options: { + repeat: { pattern: WORKSPACE_TRASH_CLEANUP_CRON_PATTERN }, + }, + }); + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants.ts new file mode 100644 index 0000000000000..6a5f18f42f285 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants.ts @@ -0,0 +1 @@ +export const WORKSPACE_TRASH_CLEANUP_CRON_PATTERN = '10 0 * * *'; // 00:10 UTC daily diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts new file mode 100644 index 0000000000000..baef1fed04b60 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; + +import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator'; +import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; +import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { WORKSPACE_TRASH_CLEANUP_CRON_PATTERN } from 'src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants'; +import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service'; + +@Injectable() +@Processor(MessageQueue.cronQueue) +export class WorkspaceTrashCleanupCronJob { + constructor( + private readonly workspaceTrashCleanupService: WorkspaceTrashCleanupService, + ) {} + + @Process(WorkspaceTrashCleanupCronJob.name) + @SentryCronMonitor( + WorkspaceTrashCleanupCronJob.name, + WORKSPACE_TRASH_CLEANUP_CRON_PATTERN, + 30, + ) + async handle(): Promise { + await this.workspaceTrashCleanupService.cleanupWorkspaceTrash(); + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/resolvers/workspace-trash-cleanup.resolver.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/resolvers/workspace-trash-cleanup.resolver.ts new file mode 100644 index 0000000000000..ad1a094e0ff43 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/resolvers/workspace-trash-cleanup.resolver.ts @@ -0,0 +1,17 @@ +import { Mutation, Resolver } from '@nestjs/graphql'; + +import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service'; + +@Resolver() +export class WorkspaceTrashCleanupResolver { + constructor( + private readonly workspaceTrashCleanupService: WorkspaceTrashCleanupService, + ) {} + + @Mutation(() => String) + async triggerWorkspaceTrashCleanup(): Promise { + await this.workspaceTrashCleanupService.cleanupWorkspaceTrash(); + + return 'Workspace trash cleanup triggered successfully'; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts new file mode 100644 index 0000000000000..899a8cf570e8f --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts @@ -0,0 +1,194 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; + +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util'; +import { WorkspaceTrashTableDiscoveryService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-table-discovery.service'; +import { WorkspaceTrashDeletionService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-deletion.service'; + +type WorkspaceForCleanup = Pick; + +@Injectable() +export class WorkspaceTrashCleanupService { + private readonly logger = new Logger(WorkspaceTrashCleanupService.name); + private readonly batchSize: number; + private readonly delayBetweenBatchesMs: number; + + constructor( + @InjectRepository(Workspace) + private readonly workspaceRepository: Repository, + private readonly tableDiscoveryService: WorkspaceTrashTableDiscoveryService, + private readonly deletionService: WorkspaceTrashDeletionService, + private readonly twentyConfigService: TwentyConfigService, + ) { + this.batchSize = this.twentyConfigService.get( + 'TRASH_CLEANUP_WORKSPACE_BATCH_SIZE', + ); + this.delayBetweenBatchesMs = this.twentyConfigService.get( + 'TRASH_CLEANUP_DELAY_BETWEEN_BATCHES_MS', + ); + } + + async cleanupWorkspaceTrash(): Promise { + const workspaces = await this.getWorkspacesDueForCleanup(); + + if (workspaces.length === 0) { + this.logger.log('No workspaces due for trash cleanup'); + + return; + } + + this.logger.log(`Found ${workspaces.length} workspace(s) due for cleanup`); + + const schemaNameMap = this.buildSchemaNameMap(workspaces); + const schemaNames = Array.from(schemaNameMap.values()); + + const tablesBySchema = + await this.tableDiscoveryService.discoverTablesWithSoftDelete( + schemaNames, + ); + + const { succeeded, failed, workspacesToUpdate } = + await this.processWorkspacesInBatches( + workspaces, + schemaNameMap, + tablesBySchema, + ); + + if (workspacesToUpdate.length > 0) { + await this.updateNextCleanupDates(workspacesToUpdate); + } + + this.logger.log( + `Workspace trash cleanup completed. Processed: ${succeeded}, Failed: ${failed}`, + ); + } + + private async getWorkspacesDueForCleanup(): Promise { + return await this.workspaceRepository.find({ + where: { + activationStatus: WorkspaceActivationStatus.ACTIVE, + nextTrashCleanupAt: LessThanOrEqual(new Date()), + trashRetentionDays: MoreThanOrEqual(0), + }, + select: ['id', 'trashRetentionDays'], + order: { nextTrashCleanupAt: 'ASC' }, + }); + } + + private buildSchemaNameMap( + workspaces: WorkspaceForCleanup[], + ): Map { + const schemaNameMap = new Map(); + + for (const workspace of workspaces) { + const schemaName = getWorkspaceSchemaName(workspace.id); + + schemaNameMap.set(workspace.id, schemaName); + } + + return schemaNameMap; + } + + private async processWorkspacesInBatches( + workspaces: WorkspaceForCleanup[], + schemaNameMap: Map, + tablesBySchema: Map, + ): Promise<{ + succeeded: number; + failed: number; + workspacesToUpdate: WorkspaceForCleanup[]; + }> { + const workspacesToUpdate: WorkspaceForCleanup[] = []; + let succeeded = 0; + let failed = 0; + + for (let i = 0; i < workspaces.length; i += this.batchSize) { + const batch = workspaces.slice(i, i + this.batchSize); + + const results = await Promise.all( + batch.map(async (workspace) => { + const schemaName = schemaNameMap.get(workspace.id)!; + const tableNames = tablesBySchema.get(schemaName) || []; + + const result = await this.deletionService.deleteSoftDeletedRecords( + workspace.id, + schemaName, + tableNames, + ); + + return { workspace, result }; + }), + ); + + for (const { workspace, result } of results) { + if (result.success) { + workspacesToUpdate.push(workspace); + succeeded++; + } else { + failed++; + } + } + + if ( + i + this.batchSize < workspaces.length && + this.delayBetweenBatchesMs > 0 + ) { + await this.sleep(this.delayBetweenBatchesMs); + } + } + + return { succeeded, failed, workspacesToUpdate }; + } + + private async updateNextCleanupDates( + workspaces: WorkspaceForCleanup[], + ): Promise { + let caseStatement = 'CASE'; + const parameters: Record = {}; + + for (let i = 0; i < workspaces.length; i++) { + const workspace = workspaces[i]; + const nextDate = this.calculateNextCleanupDate( + workspace.trashRetentionDays, + ); + + caseStatement += ` WHEN id = :id${i} THEN CAST(:date${i} AS TIMESTAMP)`; + parameters[`id${i}`] = workspace.id; + parameters[`date${i}`] = nextDate; + } + caseStatement += ' END'; + + await this.workspaceRepository + .createQueryBuilder() + .update(Workspace) + .set({ + nextTrashCleanupAt: () => caseStatement, + updatedAt: () => 'CURRENT_TIMESTAMP', + }) + .where('id IN (:...ids)', { ids: workspaces.map((w) => w.id) }) + .setParameters(parameters) + .execute(); + + this.logger.log( + `Updated next cleanup dates for ${workspaces.length} workspace(s)`, + ); + } + + private calculateNextCleanupDate(trashRetentionDays: number): Date { + const nextDate = new Date(); + + nextDate.setUTCDate(nextDate.getUTCDate() + 1 + trashRetentionDays); + nextDate.setUTCHours(0, 0, 0, 0); + + return nextDate; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-deletion.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-deletion.service.ts new file mode 100644 index 0000000000000..d1ac62d754056 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-deletion.service.ts @@ -0,0 +1,68 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; + +import { DataSource } from 'typeorm'; + +export type DeletionResult = { + success: boolean; + error?: string; +}; + +@Injectable() +export class WorkspaceTrashDeletionService { + private readonly logger = new Logger(WorkspaceTrashDeletionService.name); + + constructor( + @InjectDataSource() + private readonly dataSource: DataSource, + ) {} + + async deleteSoftDeletedRecords( + workspaceId: string, + schemaName: string, + tableNames: string[], + ): Promise { + try { + if (tableNames.length === 0) { + this.logger.log( + `No tables with deletedAt found in schema ${schemaName} for workspace ${workspaceId} - skipping`, + ); + + return { success: true }; + } + + const deleteStatements = this.buildDeleteStatements( + schemaName, + tableNames, + ); + + await this.dataSource.query(deleteStatements); + + this.logger.log( + `Deleted soft-deleted records from ${tableNames.length} table(s) in workspace ${workspaceId}`, + ); + + return { success: true }; + } catch (error) { + const errorMessage = error?.message || String(error); + + this.logger.error( + `Failed to delete records for workspace ${workspaceId}: ${errorMessage}`, + ); + + return { success: false, error: errorMessage }; + } + } + + private buildDeleteStatements( + schemaName: string, + tableNames: string[], + ): string { + return tableNames + .map( + (tableName) => + `DELETE FROM "${schemaName}"."${tableName}" WHERE "deletedAt" IS NOT NULL`, + ) + .join('; '); + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-table-discovery.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-table-discovery.service.ts new file mode 100644 index 0000000000000..fe2595a198f21 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-table-discovery.service.ts @@ -0,0 +1,58 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; + +import { DataSource } from 'typeorm'; + +export type SchemaTableMap = Map; + +@Injectable() +export class WorkspaceTrashTableDiscoveryService { + private readonly logger = new Logger( + WorkspaceTrashTableDiscoveryService.name, + ); + + constructor( + @InjectDataSource() + private readonly dataSource: DataSource, + ) {} + + async discoverTablesWithSoftDelete( + schemaNames: string[], + ): Promise { + if (schemaNames.length === 0) { + return new Map(); + } + + const result = await this.dataSource.query( + ` + SELECT table_schema, table_name + FROM information_schema.columns + WHERE table_schema = ANY($1) + AND column_name = 'deletedAt' + GROUP BY table_schema, table_name + `, + [schemaNames], + ); + + return this.groupTablesBySchema(result); + } + + private groupTablesBySchema( + queryResult: Array<{ table_schema: string; table_name: string }>, + ): SchemaTableMap { + const tablesBySchema = new Map(); + + for (const row of queryResult) { + if (!tablesBySchema.has(row.table_schema)) { + tablesBySchema.set(row.table_schema, []); + } + tablesBySchema.get(row.table_schema)!.push(row.table_name); + } + + this.logger.log( + `Discovered tables in ${tablesBySchema.size} schema(s) with soft delete columns`, + ); + + return tablesBySchema; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts new file mode 100644 index 0000000000000..58b6376218abd --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceTrashCleanupCronCommand } from 'src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command'; +import { WorkspaceTrashCleanupCronJob } from 'src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job'; +import { WorkspaceTrashCleanupResolver } from 'src/engine/workspace-manager/workspace-trash-cleanup/resolvers/workspace-trash-cleanup.resolver'; +import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service'; +import { WorkspaceTrashTableDiscoveryService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-table-discovery.service'; +import { WorkspaceTrashDeletionService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-deletion.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Workspace])], + providers: [ + WorkspaceTrashTableDiscoveryService, + WorkspaceTrashDeletionService, + WorkspaceTrashCleanupService, + WorkspaceTrashCleanupCronJob, + WorkspaceTrashCleanupCronCommand, + WorkspaceTrashCleanupResolver, + ], + exports: [WorkspaceTrashCleanupCronCommand], +}) +export class WorkspaceTrashCleanupModule {} From a5042b5950ef209d3a688803cc1eecef3989bae7 Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Wed, 1 Oct 2025 04:08:17 +0500 Subject: [PATCH 03/18] feat: add trashRetentionDays update mutation with auto-recompute nextTrashCleanupAt --- .../workspace/dtos/update-workspace-input.ts | 8 ++++++++ .../workspace/services/workspace.service.ts | 14 +++++++++++++- .../calculate-next-trash-cleanup-date.util.ts | 10 ++++++++++ .../services/workspace-trash-cleanup.service.ts | 12 ++---------- 4 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/utils/calculate-next-trash-cleanup-date.util.ts diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts index d46d5778f2118..26a3ef8cf4c5d 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts @@ -2,11 +2,13 @@ import { Field, InputType } from '@nestjs/graphql'; import { IsBoolean, + IsInt, IsNotIn, IsOptional, IsString, IsUUID, Matches, + Min, } from 'class-validator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @@ -197,4 +199,10 @@ export class UpdateWorkspaceInput { @IsBoolean() @IsOptional() isTwoFactorAuthenticationEnforced?: boolean; + + @Field({ nullable: true }) + @IsInt() + @Min(0) + @IsOptional() + trashRetentionDays?: number; } 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 e142f6d84b10c..14b1b2caabbd2 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 @@ -48,6 +48,7 @@ import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/uti import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated'; import { AuditService } from 'src/engine/core-modules/audit/services/audit.service'; import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity'; +import { calculateNextTrashCleanupDate } from 'src/engine/core-modules/workspace/utils/calculate-next-trash-cleanup-date.util'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -228,6 +229,16 @@ export class WorkspaceService extends TypeOrmQueryService { ); } + // Recompute nextTrashCleanupAt if trashRetentionDays changed + if ( + isDefined(payload.trashRetentionDays) && + payload.trashRetentionDays !== workspace.trashRetentionDays + ) { + payload.nextTrashCleanupAt = calculateNextTrashCleanupDate( + payload.trashRetentionDays, + ); + } + try { return await this.workspaceRepository.save({ ...workspace, @@ -471,7 +482,8 @@ export class WorkspaceService extends TypeOrmQueryService { 'displayName' in payload || 'subdomain' in payload || 'customDomain' in payload || - 'logo' in payload + 'logo' in payload || + 'trashRetentionDays' in payload ) { if (!userWorkspaceId) { throw new Error('Missing userWorkspaceId in authContext'); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/utils/calculate-next-trash-cleanup-date.util.ts b/packages/twenty-server/src/engine/core-modules/workspace/utils/calculate-next-trash-cleanup-date.util.ts new file mode 100644 index 0000000000000..d9d82b3f55ffa --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace/utils/calculate-next-trash-cleanup-date.util.ts @@ -0,0 +1,10 @@ +export function calculateNextTrashCleanupDate( + trashRetentionDays: number, +): Date { + const nextDate = new Date(); + + nextDate.setUTCDate(nextDate.getUTCDate() + trashRetentionDays); + nextDate.setUTCHours(0, 0, 0, 0); + + return nextDate; +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts index 899a8cf570e8f..c3d6038bcb3d2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts @@ -6,6 +6,7 @@ import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { calculateNextTrashCleanupDate } from 'src/engine/core-modules/workspace/utils/calculate-next-trash-cleanup-date.util'; import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util'; import { WorkspaceTrashTableDiscoveryService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-table-discovery.service'; import { WorkspaceTrashDeletionService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-deletion.service'; @@ -153,7 +154,7 @@ export class WorkspaceTrashCleanupService { for (let i = 0; i < workspaces.length; i++) { const workspace = workspaces[i]; - const nextDate = this.calculateNextCleanupDate( + const nextDate = calculateNextTrashCleanupDate( workspace.trashRetentionDays, ); @@ -179,15 +180,6 @@ export class WorkspaceTrashCleanupService { ); } - private calculateNextCleanupDate(trashRetentionDays: number): Date { - const nextDate = new Date(); - - nextDate.setUTCDate(nextDate.getUTCDate() + 1 + trashRetentionDays); - nextDate.setUTCHours(0, 0, 0, 0); - - return nextDate; - } - private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } From 2645a37f18f57de18f6f39f769b88eb0950bdeb1 Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Wed, 1 Oct 2025 04:20:23 +0500 Subject: [PATCH 04/18] refactor: remove WorkspaceTrashCleanupResolver --- .../workspace-trash-cleanup.resolver.ts | 17 ----------------- .../workspace-trash-cleanup.module.ts | 2 -- 2 files changed, 19 deletions(-) delete mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/resolvers/workspace-trash-cleanup.resolver.ts diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/resolvers/workspace-trash-cleanup.resolver.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/resolvers/workspace-trash-cleanup.resolver.ts deleted file mode 100644 index ad1a094e0ff43..0000000000000 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/resolvers/workspace-trash-cleanup.resolver.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Mutation, Resolver } from '@nestjs/graphql'; - -import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service'; - -@Resolver() -export class WorkspaceTrashCleanupResolver { - constructor( - private readonly workspaceTrashCleanupService: WorkspaceTrashCleanupService, - ) {} - - @Mutation(() => String) - async triggerWorkspaceTrashCleanup(): Promise { - await this.workspaceTrashCleanupService.cleanupWorkspaceTrash(); - - return 'Workspace trash cleanup triggered successfully'; - } -} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts index 58b6376218abd..85095f6351d7f 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts @@ -4,7 +4,6 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceTrashCleanupCronCommand } from 'src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command'; import { WorkspaceTrashCleanupCronJob } from 'src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job'; -import { WorkspaceTrashCleanupResolver } from 'src/engine/workspace-manager/workspace-trash-cleanup/resolvers/workspace-trash-cleanup.resolver'; import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service'; import { WorkspaceTrashTableDiscoveryService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-table-discovery.service'; import { WorkspaceTrashDeletionService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-deletion.service'; @@ -17,7 +16,6 @@ import { WorkspaceTrashDeletionService } from 'src/engine/workspace-manager/work WorkspaceTrashCleanupService, WorkspaceTrashCleanupCronJob, WorkspaceTrashCleanupCronCommand, - WorkspaceTrashCleanupResolver, ], exports: [WorkspaceTrashCleanupCronCommand], }) From 69ce83f605daa2818e060c9960342529ef47763a Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Thu, 2 Oct 2025 21:45:06 +0500 Subject: [PATCH 05/18] feat: frontend implementation --- .../src/generated-metadata/graphql.ts | 8 +- .../twenty-front/src/generated/graphql.ts | 3 + .../services/__tests__/apollo.factory.test.ts | 1 + .../auth/states/currentWorkspaceState.ts | 1 + ...ColumnDefinitionsFromFieldMetadata.test.ts | 1 + .../SettingsOptionCardContentInput.tsx | 75 +++++++++++++++++ ...SettingsOptionCardContentInput.stories.tsx | 84 +++++++++++++++++++ .../graphql/fragments/userQueryFragment.ts | 1 + .../modules/users/hooks/useLoadCurrentUser.ts | 6 +- .../settings/security/SettingsSecurity.tsx | 70 +++++++++++++++- .../src/testing/mock-data/users.ts | 3 + 11 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentInput.tsx create mode 100644 packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentInput.stories.tsx diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 0c912abec40ec..4ff1623fbfba5 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -4060,6 +4060,7 @@ export type UpdateWorkspaceInput = { isTwoFactorAuthenticationEnforced?: InputMaybe; logo?: InputMaybe; subdomain?: InputMaybe; + trashRetentionDays?: InputMaybe; }; export type UpsertFieldPermissionsInput = { @@ -4347,7 +4348,9 @@ export type Workspace = { isTwoFactorAuthenticationEnforced: Scalars['Boolean']; logo?: Maybe; metadataVersion: Scalars['Float']; + nextTrashCleanupAt: Scalars['DateTime']; subdomain: Scalars['String']; + trashRetentionDays: Scalars['Float']; updatedAt: Scalars['DateTime']; version?: Maybe; viewFields?: Maybe>; @@ -5430,7 +5433,7 @@ export type BillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscri export type CurrentBillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null, views?: Array<{ __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, fieldMetadataId: string, isVisible: boolean, fieldValue: string, position: number, viewId: string }> }> | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null, views?: Array<{ __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, fieldMetadataId: string, isVisible: boolean, fieldValue: string, position: number, viewId: string }> }> | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; export type WorkspaceUrlsFragmentFragment = { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }; @@ -5449,7 +5452,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null, views?: Array<{ __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, fieldMetadataId: string, isVisible: boolean, fieldValue: string, position: number, viewId: string }> }> | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null, views?: Array<{ __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, fieldMetadataId: string, isVisible: boolean, fieldValue: string, position: number, viewId: string }> }> | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; export type ViewFieldFragmentFragment = { __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }; @@ -6354,6 +6357,7 @@ export const UserQueryFragmentFragmentDoc = gql` id } isTwoFactorAuthenticationEnforced + trashRetentionDays views { ...ViewFragment } diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 565e666294080..42372b5a932aa 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -3820,6 +3820,7 @@ export type UpdateWorkspaceInput = { isTwoFactorAuthenticationEnforced?: InputMaybe; logo?: InputMaybe; subdomain?: InputMaybe; + trashRetentionDays?: InputMaybe; }; export type UpsertFieldPermissionsInput = { @@ -4097,7 +4098,9 @@ export type Workspace = { isTwoFactorAuthenticationEnforced: Scalars['Boolean']; logo?: Maybe; metadataVersion: Scalars['Float']; + nextTrashCleanupAt: Scalars['DateTime']; subdomain: Scalars['String']; + trashRetentionDays: Scalars['Float']; updatedAt: Scalars['DateTime']; version?: Maybe; viewFields?: Maybe>; diff --git a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts index a134773cbc57a..4d0bd6dda50bc 100644 --- a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts +++ b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts @@ -57,6 +57,7 @@ const mockWorkspace = { customUrl: 'test.com', }, isTwoFactorAuthenticationEnforced: false, + trashRetentionDays: 14, }; const createMockOptions = (): Options => ({ diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index 138503087f277..de03d7fb375cb 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -24,6 +24,7 @@ export type CurrentWorkspace = Pick< | 'workspaceUrls' | 'metadataVersion' | 'isTwoFactorAuthenticationEnforced' + | 'trashRetentionDays' > & { defaultRole?: Omit | null; defaultAgent?: { id: string } | null; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts index 9e7ffc96ffa16..d57a9164b37d9 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts @@ -50,6 +50,7 @@ const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({ }, ], isTwoFactorAuthenticationEnforced: false, + trashRetentionDays: 14, }); }, }); diff --git a/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentInput.tsx b/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentInput.tsx new file mode 100644 index 0000000000000..18deab846143f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentInput.tsx @@ -0,0 +1,75 @@ +import { + StyledSettingsOptionCardContent, + StyledSettingsOptionCardDescription, + StyledSettingsOptionCardIcon, + StyledSettingsOptionCardTitle, +} from '@/settings/components/SettingsOptions/SettingsOptionCardContentBase'; +import { SettingsOptionIconCustomizer } from '@/settings/components/SettingsOptions/SettingsOptionIconCustomizer'; +import { TextInput } from '@/ui/input/components/TextInput'; +import styled from '@emotion/styled'; +import { useState } from 'react'; +import { type IconComponent } from 'twenty-ui/display'; + +const StyledInputContainer = styled.div` + width: 80px; +`; + +const StyledSettingsOptionCardContentInfo = styled.div` + display: flex; + flex: 1; + flex-direction: column; +`; + +type SettingsOptionCardContentInputProps = { + Icon?: IconComponent; + title: React.ReactNode; + description?: string; + disabled?: boolean; + value: string; + onBlur: (value: string) => void; + placeholder?: string; +}; + +export const SettingsOptionCardContentInput = ({ + Icon, + title, + description, + disabled = false, + value: initialValue, + onBlur, + placeholder, +}: SettingsOptionCardContentInputProps) => { + const [value, setValue] = useState(initialValue); + + const handleBlur = () => { + onBlur(value); + }; + + return ( + + {Icon && ( + + + + )} + + {title} + {description && ( + + {description} + + )} + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentInput.stories.tsx b/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentInput.stories.tsx new file mode 100644 index 0000000000000..13424f4eda142 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentInput.stories.tsx @@ -0,0 +1,84 @@ +import { SettingsOptionCardContentInput } from '@/settings/components/SettingsOptions/SettingsOptionCardContentInput'; +import styled from '@emotion/styled'; +import { type Meta, type StoryObj } from '@storybook/react'; +import { ComponentDecorator } from 'twenty-ui/testing'; +import { IconTrash, IconCalendar, IconDatabase } from 'twenty-ui/display'; + +const StyledContainer = styled.div` + width: 480px; +`; + +const SettingsOptionCardContentInputWrapper = ( + args: React.ComponentProps, +) => { + const handleBlur = (value: string) => { + console.log('Value on blur:', value); + }; + + return ( + + + + ); +}; + +const meta: Meta = { + title: 'Modules/Settings/SettingsOptionCardContentInput', + component: SettingsOptionCardContentInputWrapper, + decorators: [ComponentDecorator], + parameters: { + maxWidth: 800, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + Icon: IconTrash, + title: 'Erasure of soft-deleted records', + description: 'Permanent deletion. Enter the number of days.', + value: '14', + placeholder: '14', + }, + argTypes: { + Icon: { control: false }, + onBlur: { control: false }, + }, +}; + +export const Disabled: Story = { + args: { + Icon: IconDatabase, + title: 'Database Retention', + description: 'This setting is currently locked', + value: '30', + disabled: true, + }, +}; + +export const WithoutIcon: Story = { + args: { + title: 'Simple Input', + description: 'A basic input without an icon', + value: '7', + placeholder: '0', + }, +}; + +export const WithoutDescription: Story = { + args: { + Icon: IconCalendar, + title: 'Days to Keep', + value: '90', + }, +}; diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index ea645140c1e75..f78fcdecc6a4d 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -80,6 +80,7 @@ export const USER_QUERY_FRAGMENT = gql` id } isTwoFactorAuthenticationEnforced + trashRetentionDays views { ...ViewFragment } diff --git a/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts b/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts index 105b043f7f435..2ebf2fa9fb62c 100644 --- a/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts +++ b/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts @@ -93,7 +93,11 @@ export const useLoadCurrentUser = () => { const workspace = user.currentWorkspace ?? null; - setCurrentWorkspace(workspace); + setCurrentWorkspace(workspace ? { + ...workspace, + defaultRole: workspace.defaultRole ?? null, + defaultAgent: workspace.defaultAgent ?? null, + } : null); if (isDefined(workspace) && isOnAWorkspace) { setLastAuthenticateWorkspaceDomain({ diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx index 11b929cadacc7..72cdf0a460cdf 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx @@ -1,19 +1,23 @@ import styled from '@emotion/styled'; import { Trans, useLingui } from '@lingui/react/macro'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard'; import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList'; - +import { SettingsOptionCardContentInput } from '@/settings/components/SettingsOptions/SettingsOptionCardContentInput'; import { ToggleImpersonate } from '@/settings/workspace/components/ToggleImpersonate'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; -import { useRecoilValue } from 'recoil'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { ApolloError } from '@apollo/client'; +import { useRecoilState, useRecoilValue } from 'recoil'; import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; import { Tag } from 'twenty-ui/components'; -import { H2Title, IconLock } from 'twenty-ui/display'; -import { Section } from 'twenty-ui/layout'; +import { H2Title, IconLock, IconTrash } from 'twenty-ui/display'; +import { Card, Section } from 'twenty-ui/layout'; +import { useUpdateWorkspaceMutation } from '~/generated-metadata/graphql'; const StyledContainer = styled.div` width: 100%; @@ -32,8 +36,50 @@ const StyledSection = styled(Section)` export const SettingsSecurity = () => { const { t } = useLingui(); + const { enqueueErrorSnackBar } = useSnackBar(); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + const [currentWorkspace, setCurrentWorkspace] = useRecoilState( + currentWorkspaceState, + ); + const [updateWorkspace] = useUpdateWorkspaceMutation(); + + const handleTrashRetentionDaysBlur = async (value: string) => { + const numValue = parseInt(value, 10); + + // Validate input + if (isNaN(numValue) || numValue < 0) { + return; + } + + // Don't make API call if value hasn't changed + if (numValue === currentWorkspace?.trashRetentionDays) { + return; + } + + try { + if (!currentWorkspace?.id) { + throw new Error('User is not logged in'); + } + + await updateWorkspace({ + variables: { + input: { + trashRetentionDays: numValue, + }, + }, + }); + + setCurrentWorkspace({ + ...currentWorkspace, + trashRetentionDays: numValue, + }); + } catch (err: any) { + enqueueErrorSnackBar({ + apolloError: err instanceof ApolloError ? err : undefined, + }); + } + }; return ( { )} +
+ + + + +
diff --git a/packages/twenty-front/src/testing/mock-data/users.ts b/packages/twenty-front/src/testing/mock-data/users.ts index 6d70084b0f822..1b1ab0f92a555 100644 --- a/packages/twenty-front/src/testing/mock-data/users.ts +++ b/packages/twenty-front/src/testing/mock-data/users.ts @@ -84,6 +84,8 @@ export const mockCurrentWorkspace: Workspace = { createdAt: '2023-04-26T10:23:42.33625+00:00', updatedAt: '2023-04-26T10:23:42.33625+00:00', metadataVersion: 1, + trashRetentionDays: 14, + nextTrashCleanupAt: new Date().toISOString(), currentBillingSubscription: { __typename: 'BillingSubscription', id: '7efbc3f7-6e5e-4128-957e-8d86808cdf6a', @@ -151,6 +153,7 @@ export const mockCurrentWorkspace: Workspace = { databaseSchema: '', databaseUrl: '', isTwoFactorAuthenticationEnforced: false, + __typename: 'Workspace', }; export const mockedWorkspaceMemberData: WorkspaceMember = { From 9647be12cd5daf1d8fc12300e883f1e05e3448f6 Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Fri, 3 Oct 2025 02:15:25 +0500 Subject: [PATCH 06/18] refactor: trash cleanup to use BullMQ rate limiting, remove nextTrashCleanupAt field --- .../src/generated-metadata/graphql.ts | 1 - .../twenty-front/src/generated/graphql.ts | 1 - .../components/RecordCalendarAddNew.tsx | 1 - ...SettingsOptionCardContentInput.stories.tsx | 1 + .../modules/users/hooks/useLoadCurrentUser.ts | 14 +- .../src/testing/mock-data/users.ts | 1 - ...182953990-add-workspace-trash-retention.ts | 12 - .../cron/sentry-cron-monitor.decorator.ts | 8 +- .../twenty-config/config-variables.ts | 13 +- .../workspace/services/workspace.service.ts | 11 - .../calculate-next-trash-cleanup-date.util.ts | 10 - .../workspace/workspace.entity.ts | 8 - .../sanitize-default-value.util.spec.ts | 21 -- .../utils/sanitize-default-value.util.ts | 6 +- .../workspace-trash-cleanup.cron.command.ts | 8 +- .../workspace-trash-cleanup.constants.ts | 3 +- .../crons/workspace-trash-cleanup.cron.job.ts | 80 ++++- .../jobs/workspace-trash-cleanup.job.ts | 41 +++ .../workspace-trash-cleanup.service.ts | 276 +++++++++--------- .../workspace-trash-deletion.service.ts | 68 ----- ...workspace-trash-table-discovery.service.ts | 58 ---- .../workspace-trash-cleanup.module.ts | 9 +- 22 files changed, 275 insertions(+), 376 deletions(-) delete mode 100644 packages/twenty-server/src/engine/core-modules/workspace/utils/calculate-next-trash-cleanup-date.util.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job.ts delete mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-deletion.service.ts delete mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-table-discovery.service.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 4ff1623fbfba5..6a85c091fa28b 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -4348,7 +4348,6 @@ export type Workspace = { isTwoFactorAuthenticationEnforced: Scalars['Boolean']; logo?: Maybe; metadataVersion: Scalars['Float']; - nextTrashCleanupAt: Scalars['DateTime']; subdomain: Scalars['String']; trashRetentionDays: Scalars['Float']; updatedAt: Scalars['DateTime']; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 42372b5a932aa..c7edb79841f81 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -4098,7 +4098,6 @@ export type Workspace = { isTwoFactorAuthenticationEnforced: Scalars['Boolean']; logo?: Maybe; metadataVersion: Scalars['Float']; - nextTrashCleanupAt: Scalars['DateTime']; subdomain: Scalars['String']; trashRetentionDays: Scalars['Float']; updatedAt: Scalars['DateTime']; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarAddNew.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarAddNew.tsx index d2c5618c39396..13854ee91a0ae 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarAddNew.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarAddNew.tsx @@ -56,7 +56,6 @@ export const RecordCalendarAddNew = ({ return null; } - return ( { diff --git a/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentInput.stories.tsx b/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentInput.stories.tsx index 13424f4eda142..286e9faf3231a 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentInput.stories.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentInput.stories.tsx @@ -12,6 +12,7 @@ const SettingsOptionCardContentInputWrapper = ( args: React.ComponentProps, ) => { const handleBlur = (value: string) => { + // eslint-disable-next-line no-console console.log('Value on blur:', value); }; diff --git a/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts b/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts index 2ebf2fa9fb62c..bab018f779241 100644 --- a/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts +++ b/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts @@ -93,11 +93,15 @@ export const useLoadCurrentUser = () => { const workspace = user.currentWorkspace ?? null; - setCurrentWorkspace(workspace ? { - ...workspace, - defaultRole: workspace.defaultRole ?? null, - defaultAgent: workspace.defaultAgent ?? null, - } : null); + setCurrentWorkspace( + workspace + ? { + ...workspace, + defaultRole: workspace.defaultRole ?? null, + defaultAgent: workspace.defaultAgent ?? null, + } + : null, + ); if (isDefined(workspace) && isOnAWorkspace) { setLastAuthenticateWorkspaceDomain({ diff --git a/packages/twenty-front/src/testing/mock-data/users.ts b/packages/twenty-front/src/testing/mock-data/users.ts index 1b1ab0f92a555..776b06b2de6c8 100644 --- a/packages/twenty-front/src/testing/mock-data/users.ts +++ b/packages/twenty-front/src/testing/mock-data/users.ts @@ -85,7 +85,6 @@ export const mockCurrentWorkspace: Workspace = { updatedAt: '2023-04-26T10:23:42.33625+00:00', metadataVersion: 1, trashRetentionDays: 14, - nextTrashCleanupAt: new Date().toISOString(), currentBillingSubscription: { __typename: 'BillingSubscription', id: '7efbc3f7-6e5e-4128-957e-8d86808cdf6a', diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1759182953990-add-workspace-trash-retention.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1759182953990-add-workspace-trash-retention.ts index 06c59fd7108a7..b6ad7594bba33 100644 --- a/packages/twenty-server/src/database/typeorm/core/migrations/common/1759182953990-add-workspace-trash-retention.ts +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1759182953990-add-workspace-trash-retention.ts @@ -9,12 +9,6 @@ export class AddWorkspaceTrashRetention1759182953990 await queryRunner.query( `ALTER TABLE "core"."workspace" ADD "trashRetentionDays" integer NOT NULL DEFAULT '14'`, ); - await queryRunner.query( - `ALTER TABLE "core"."workspace" ADD "nextTrashCleanupAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + INTERVAL '14 days'`, - ); - await queryRunner.query( - `CREATE INDEX "IDX_WORKSPACE_NEXT_TRASH_CLEANUP_AT" ON "core"."workspace" ("nextTrashCleanupAt") `, - ); await queryRunner.query( `ALTER TABLE "core"."workspace" ADD CONSTRAINT "trash_retention_positive" CHECK ("trashRetentionDays" >= 0)`, ); @@ -24,12 +18,6 @@ export class AddWorkspaceTrashRetention1759182953990 await queryRunner.query( `ALTER TABLE "core"."workspace" DROP CONSTRAINT "trash_retention_positive"`, ); - await queryRunner.query( - `DROP INDEX "core"."IDX_WORKSPACE_NEXT_TRASH_CLEANUP_AT"`, - ); - await queryRunner.query( - `ALTER TABLE "core"."workspace" DROP COLUMN "nextTrashCleanupAt"`, - ); await queryRunner.query( `ALTER TABLE "core"."workspace" DROP COLUMN "trashRetentionDays"`, ); diff --git a/packages/twenty-server/src/engine/core-modules/cron/sentry-cron-monitor.decorator.ts b/packages/twenty-server/src/engine/core-modules/cron/sentry-cron-monitor.decorator.ts index 980dc0f526e4a..0fe54013d5380 100644 --- a/packages/twenty-server/src/engine/core-modules/cron/sentry-cron-monitor.decorator.ts +++ b/packages/twenty-server/src/engine/core-modules/cron/sentry-cron-monitor.decorator.ts @@ -1,10 +1,6 @@ import * as Sentry from '@sentry/node'; -export function SentryCronMonitor( - monitorSlug: string, - schedule: string, - maxRuntime: number = 5, -) { +export function SentryCronMonitor(monitorSlug: string, schedule: string) { return function ( // eslint-disable-next-line @typescript-eslint/no-explicit-any _target: any, @@ -33,7 +29,7 @@ export function SentryCronMonitor( value: schedule, }, checkinMargin: 1, - maxRuntime, + maxRuntime: 5, timezone: 'UTC', }, ); diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 144bc5a4c118d..57e9bc2dbb1c6 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -1108,21 +1108,12 @@ export class ConfigVariables { @ConfigVariablesMetadata({ group: ConfigVariablesGroup.Other, description: - 'Number of workspaces to process per batch during trash cleanup', + 'Maximum number of records to delete per workspace during trash cleanup', type: ConfigVariableType.NUMBER, }) @CastToPositiveNumber() @IsOptional() - TRASH_CLEANUP_WORKSPACE_BATCH_SIZE = 5; - - @ConfigVariablesMetadata({ - group: ConfigVariablesGroup.Other, - description: 'Delay in milliseconds between workspace cleanup batches', - type: ConfigVariableType.NUMBER, - }) - @CastToPositiveNumber() - @IsOptional() - TRASH_CLEANUP_DELAY_BETWEEN_BATCHES_MS = 500; + TRASH_CLEANUP_MAX_RECORDS_PER_WORKSPACE = 100000; @ConfigVariablesMetadata({ group: ConfigVariablesGroup.RateLimiting, 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 14b1b2caabbd2..f97532793b7ac 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 @@ -48,7 +48,6 @@ import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/uti import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated'; import { AuditService } from 'src/engine/core-modules/audit/services/audit.service'; import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity'; -import { calculateNextTrashCleanupDate } from 'src/engine/core-modules/workspace/utils/calculate-next-trash-cleanup-date.util'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -229,16 +228,6 @@ export class WorkspaceService extends TypeOrmQueryService { ); } - // Recompute nextTrashCleanupAt if trashRetentionDays changed - if ( - isDefined(payload.trashRetentionDays) && - payload.trashRetentionDays !== workspace.trashRetentionDays - ) { - payload.nextTrashCleanupAt = calculateNextTrashCleanupDate( - payload.trashRetentionDays, - ); - } - try { return await this.workspaceRepository.save({ ...workspace, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/utils/calculate-next-trash-cleanup-date.util.ts b/packages/twenty-server/src/engine/core-modules/workspace/utils/calculate-next-trash-cleanup-date.util.ts deleted file mode 100644 index d9d82b3f55ffa..0000000000000 --- a/packages/twenty-server/src/engine/core-modules/workspace/utils/calculate-next-trash-cleanup-date.util.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function calculateNextTrashCleanupDate( - trashRetentionDays: number, -): Date { - const nextDate = new Date(); - - nextDate.setUTCDate(nextDate.getUTCDate() + trashRetentionDays); - nextDate.setUTCHours(0, 0, 0, 0); - - return nextDate; -} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index d5eeed8c04baa..df2b5d11c38e6 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -97,14 +97,6 @@ export class Workspace { @Column({ type: 'integer', default: 14 }) trashRetentionDays: number; - @Field() - @Column({ - type: 'timestamptz', - default: () => "NOW() + INTERVAL '14 days'", - }) - @Index('IDX_WORKSPACE_NEXT_TRASH_CLEANUP_AT') - nextTrashCleanupAt: Date; - // Relations @OneToMany(() => AppToken, (appToken) => appToken.workspace, { cascade: true, diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/__tests__/sanitize-default-value.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/__tests__/sanitize-default-value.util.spec.ts index 63087173be37b..1246d92bd39ca 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/__tests__/sanitize-default-value.util.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/__tests__/sanitize-default-value.util.spec.ts @@ -29,27 +29,6 @@ describe('sanitizeDefaultValue', () => { expect(sanitizeDefaultValue('NOW()')).toBe('NOW()'); }); - - it('should allow now() + interval expressions for trash cleanup', () => { - // Prepare - const input = "now() + interval '14 days'"; - - // Act - const result = sanitizeDefaultValue(input); - - // Assert - expect(result).toBe("now() + interval '14 days'"); - }); - - it('should be case insensitive for interval expressions', () => { - // Act & Assert - expect(sanitizeDefaultValue("NOW() + INTERVAL '14 days'")).toBe( - "NOW() + INTERVAL '14 days'", - ); - expect(sanitizeDefaultValue("Now() + Interval '14 days'")).toBe( - "Now() + Interval '14 days'", - ); - }); }); describe('SQL injection prevention', () => { diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts index 256db57f43453..466a144ec24d8 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/sanitize-default-value.util.ts @@ -7,11 +7,7 @@ export const sanitizeDefaultValue = ( return 'NULL'; } - const allowedFunctions = [ - 'public.uuid_generate_v4()', - 'now()', - "now() + interval '14 days'", - ]; + const allowedFunctions = ['public.uuid_generate_v4()', 'now()']; if (typeof defaultValue === 'string') { if (allowedFunctions.includes(defaultValue.toLowerCase())) { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command.ts index 01e7b956de8bf..26b6e0f0d7f6d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command.ts @@ -1,10 +1,10 @@ import { Command, CommandRunner } from 'nest-commander'; -import { WORKSPACE_TRASH_CLEANUP_CRON_PATTERN } from 'src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants'; -import { WorkspaceTrashCleanupCronJob } from 'src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; +import { WORKSPACE_TRASH_CLEANUP_CRON_PATTERN } from 'src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants'; +import { WorkspaceTrashCleanupCronJob } from 'src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job'; @Command({ name: 'cron:workspace:cleanup-trash', @@ -23,7 +23,9 @@ export class WorkspaceTrashCleanupCronCommand extends CommandRunner { jobName: WorkspaceTrashCleanupCronJob.name, data: undefined, options: { - repeat: { pattern: WORKSPACE_TRASH_CLEANUP_CRON_PATTERN }, + repeat: { + pattern: WORKSPACE_TRASH_CLEANUP_CRON_PATTERN, + }, }, }); } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants.ts index 6a5f18f42f285..bdfa481032398 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants.ts @@ -1 +1,2 @@ -export const WORKSPACE_TRASH_CLEANUP_CRON_PATTERN = '10 0 * * *'; // 00:10 UTC daily +// Daily at 00:10 UTC +export const WORKSPACE_TRASH_CLEANUP_CRON_PATTERN = '10 0 * * *'; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts index baef1fed04b60..b4224b7d5a009 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts @@ -1,26 +1,96 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { Repository } from 'typeorm'; import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator'; +import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; import { WORKSPACE_TRASH_CLEANUP_CRON_PATTERN } from 'src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants'; -import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service'; +import { + WorkspaceTrashCleanupJob, + type WorkspaceTrashCleanupJobData, +} from 'src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job'; @Injectable() @Processor(MessageQueue.cronQueue) export class WorkspaceTrashCleanupCronJob { + private readonly logger = new Logger(WorkspaceTrashCleanupCronJob.name); + constructor( - private readonly workspaceTrashCleanupService: WorkspaceTrashCleanupService, + @InjectRepository(Workspace) + private readonly workspaceRepository: Repository, + @InjectMessageQueue(MessageQueue.workspaceQueue) + private readonly messageQueueService: MessageQueueService, ) {} @Process(WorkspaceTrashCleanupCronJob.name) @SentryCronMonitor( WorkspaceTrashCleanupCronJob.name, WORKSPACE_TRASH_CLEANUP_CRON_PATTERN, - 30, ) async handle(): Promise { - await this.workspaceTrashCleanupService.cleanupWorkspaceTrash(); + const workspaces = await this.getActiveWorkspaces(); + + if (workspaces.length === 0) { + this.logger.log('No active workspaces found for trash cleanup'); + + return; + } + + this.logger.log( + `Enqueuing trash cleanup jobs for ${workspaces.length} workspace(s)`, + ); + + await Promise.all( + workspaces.map((workspace) => + this.messageQueueService.add( + WorkspaceTrashCleanupJob.name, + { + workspaceId: workspace.id, + schemaName: workspace.schema, + trashRetentionDays: workspace.trashRetentionDays, + }, + { + priority: 10, + }, + ), + ), + ); + + this.logger.log( + `Successfully enqueued ${workspaces.length} trash cleanup job(s)`, + ); + } + + private async getActiveWorkspaces(): Promise< + Array<{ id: string; trashRetentionDays: number; schema: string }> + > { + const rawResults = await this.workspaceRepository + .createQueryBuilder('workspace') + .innerJoin( + DataSourceEntity, + 'dataSource', + 'dataSource.workspaceId = workspace.id', + ) + .where('workspace.activationStatus = :status', { + status: WorkspaceActivationStatus.ACTIVE, + }) + .andWhere('workspace.trashRetentionDays >= :minDays', { minDays: 0 }) + .select([ + 'workspace.id AS id', + 'workspace.trashRetentionDays AS "trashRetentionDays"', + 'dataSource.schema AS schema', + ]) + .orderBy('workspace.id', 'ASC') + .getRawMany(); + + return rawResults; } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job.ts new file mode 100644 index 0000000000000..dd53d60c0c192 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job.ts @@ -0,0 +1,41 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; +import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service'; + +export type WorkspaceTrashCleanupJobData = { + workspaceId: string; + schemaName: string; + trashRetentionDays: number; +}; + +@Injectable() +@Processor(MessageQueue.workspaceQueue) +export class WorkspaceTrashCleanupJob { + private readonly logger = new Logger(WorkspaceTrashCleanupJob.name); + + constructor( + private readonly workspaceTrashCleanupService: WorkspaceTrashCleanupService, + ) {} + + @Process(WorkspaceTrashCleanupJob.name) + async handle(data: WorkspaceTrashCleanupJobData): Promise { + const { workspaceId, schemaName, trashRetentionDays } = data; + + const result = + await this.workspaceTrashCleanupService.cleanupWorkspaceTrash({ + workspaceId, + schemaName, + trashRetentionDays, + }); + + if (!result.success) { + this.logger.error( + `Trash cleanup failed for workspace ${workspaceId}: ${result.error}`, + ); + throw new Error(result.error); + } + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts index c3d6038bcb3d2..5a208ff42900e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts @@ -1,186 +1,176 @@ import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; -import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; -import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import { DataSource, Repository } from 'typeorm'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { calculateNextTrashCleanupDate } from 'src/engine/core-modules/workspace/utils/calculate-next-trash-cleanup-date.util'; -import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util'; -import { WorkspaceTrashTableDiscoveryService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-table-discovery.service'; -import { WorkspaceTrashDeletionService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-deletion.service'; - -type WorkspaceForCleanup = Pick; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; + +export type WorkspaceTrashCleanupInput = { + workspaceId: string; + schemaName: string; + trashRetentionDays: number; +}; + +export type WorkspaceTrashCleanupResult = { + success: boolean; + deletedCount: number; + error?: string; +}; @Injectable() export class WorkspaceTrashCleanupService { private readonly logger = new Logger(WorkspaceTrashCleanupService.name); - private readonly batchSize: number; - private readonly delayBetweenBatchesMs: number; + private readonly maxRecordsPerWorkspace: number; constructor( - @InjectRepository(Workspace) - private readonly workspaceRepository: Repository, - private readonly tableDiscoveryService: WorkspaceTrashTableDiscoveryService, - private readonly deletionService: WorkspaceTrashDeletionService, + @InjectDataSource() + private readonly dataSource: DataSource, + @InjectRepository(ObjectMetadataEntity) + private readonly objectMetadataRepository: Repository, private readonly twentyConfigService: TwentyConfigService, ) { - this.batchSize = this.twentyConfigService.get( - 'TRASH_CLEANUP_WORKSPACE_BATCH_SIZE', - ); - this.delayBetweenBatchesMs = this.twentyConfigService.get( - 'TRASH_CLEANUP_DELAY_BETWEEN_BATCHES_MS', + this.maxRecordsPerWorkspace = this.twentyConfigService.get( + 'TRASH_CLEANUP_MAX_RECORDS_PER_WORKSPACE', ); } - async cleanupWorkspaceTrash(): Promise { - const workspaces = await this.getWorkspacesDueForCleanup(); + async cleanupWorkspaceTrash( + input: WorkspaceTrashCleanupInput, + ): Promise { + const { workspaceId, schemaName, trashRetentionDays } = input; - if (workspaces.length === 0) { - this.logger.log('No workspaces due for trash cleanup'); - - return; - } + try { + const tableNames = await this.discoverTablesWithSoftDelete(schemaName); - this.logger.log(`Found ${workspaces.length} workspace(s) due for cleanup`); + if (tableNames.length === 0) { + this.logger.log( + `No tables with deletedAt found in workspace ${workspaceId}`, + ); - const schemaNameMap = this.buildSchemaNameMap(workspaces); - const schemaNames = Array.from(schemaNameMap.values()); + return { success: true, deletedCount: 0 }; + } - const tablesBySchema = - await this.tableDiscoveryService.discoverTablesWithSoftDelete( - schemaNames, + const deletedCount = await this.deleteSoftDeletedRecords( + workspaceId, + schemaName, + tableNames, + trashRetentionDays, ); - const { succeeded, failed, workspacesToUpdate } = - await this.processWorkspacesInBatches( - workspaces, - schemaNameMap, - tablesBySchema, + this.logger.log( + `Deleted ${deletedCount} record(s) from workspace ${workspaceId}`, ); - if (workspacesToUpdate.length > 0) { - await this.updateNextCleanupDates(workspacesToUpdate); - } + return { success: true, deletedCount }; + } catch (error) { + const errorMessage = error?.message || String(error); - this.logger.log( - `Workspace trash cleanup completed. Processed: ${succeeded}, Failed: ${failed}`, - ); - } + this.logger.error( + `Failed to cleanup workspace ${workspaceId}: ${errorMessage}`, + ); - private async getWorkspacesDueForCleanup(): Promise { - return await this.workspaceRepository.find({ - where: { - activationStatus: WorkspaceActivationStatus.ACTIVE, - nextTrashCleanupAt: LessThanOrEqual(new Date()), - trashRetentionDays: MoreThanOrEqual(0), - }, - select: ['id', 'trashRetentionDays'], - order: { nextTrashCleanupAt: 'ASC' }, - }); + return { success: false, deletedCount: 0, error: errorMessage }; + } } - private buildSchemaNameMap( - workspaces: WorkspaceForCleanup[], - ): Map { - const schemaNameMap = new Map(); - - for (const workspace of workspaces) { - const schemaName = getWorkspaceSchemaName(workspace.id); - - schemaNameMap.set(workspace.id, schemaName); - } + private async discoverTablesWithSoftDelete( + schemaName: string, + ): Promise { + const rawResults = await this.objectMetadataRepository + .createQueryBuilder('object') + .innerJoin('object.dataSource', 'dataSource') + .select('object.nameSingular', 'nameSingular') + .addSelect('object.isCustom', 'isCustom') + .where('dataSource.schema = :schemaName', { schemaName }) + .andWhere('object.isActive = :isActive', { isActive: true }) + .andWhere((qb) => { + const subQuery = qb + .subQuery() + .select('1') + .from(FieldMetadataEntity, 'field') + .where('field.objectMetadataId = object.id') + .andWhere('field.name = :fieldName', { fieldName: 'deletedAt' }) + .andWhere('field.isActive = :isActive', { isActive: true }) + .getQuery(); + + return `EXISTS ${subQuery}`; + }) + .getRawMany(); - return schemaNameMap; + return rawResults.map((row) => + computeObjectTargetTable({ + nameSingular: row.nameSingular, + isCustom: row.isCustom, + }), + ); } - private async processWorkspacesInBatches( - workspaces: WorkspaceForCleanup[], - schemaNameMap: Map, - tablesBySchema: Map, - ): Promise<{ - succeeded: number; - failed: number; - workspacesToUpdate: WorkspaceForCleanup[]; - }> { - const workspacesToUpdate: WorkspaceForCleanup[] = []; - let succeeded = 0; - let failed = 0; - - for (let i = 0; i < workspaces.length; i += this.batchSize) { - const batch = workspaces.slice(i, i + this.batchSize); - - const results = await Promise.all( - batch.map(async (workspace) => { - const schemaName = schemaNameMap.get(workspace.id)!; - const tableNames = tablesBySchema.get(schemaName) || []; - - const result = await this.deletionService.deleteSoftDeletedRecords( - workspace.id, - schemaName, - tableNames, - ); - - return { workspace, result }; - }), - ); - - for (const { workspace, result } of results) { - if (result.success) { - workspacesToUpdate.push(workspace); - succeeded++; - } else { - failed++; - } + private async deleteSoftDeletedRecords( + workspaceId: string, + schemaName: string, + tableNames: string[], + trashRetentionDays: number, + ): Promise { + let totalDeleted = 0; + const cutoffDate = this.calculateCutoffDate(trashRetentionDays); + + for (const tableName of tableNames) { + if (totalDeleted >= this.maxRecordsPerWorkspace) { + this.logger.log( + `Reached deletion limit (${this.maxRecordsPerWorkspace}) for workspace ${workspaceId}`, + ); + break; } - if ( - i + this.batchSize < workspaces.length && - this.delayBetweenBatchesMs > 0 - ) { - await this.sleep(this.delayBetweenBatchesMs); - } + const remainingQuota = this.maxRecordsPerWorkspace - totalDeleted; + const deleted = await this.deleteFromTable( + schemaName, + tableName, + cutoffDate, + remainingQuota, + ); + + totalDeleted += deleted; } - return { succeeded, failed, workspacesToUpdate }; + return totalDeleted; } - private async updateNextCleanupDates( - workspaces: WorkspaceForCleanup[], - ): Promise { - let caseStatement = 'CASE'; - const parameters: Record = {}; + private calculateCutoffDate(trashRetentionDays: number): Date { + const cutoffDate = new Date(); - for (let i = 0; i < workspaces.length; i++) { - const workspace = workspaces[i]; - const nextDate = calculateNextTrashCleanupDate( - workspace.trashRetentionDays, - ); + cutoffDate.setUTCHours(0, 0, 0, 0); + cutoffDate.setDate(cutoffDate.getDate() - trashRetentionDays + 1); - caseStatement += ` WHEN id = :id${i} THEN CAST(:date${i} AS TIMESTAMP)`; - parameters[`id${i}`] = workspace.id; - parameters[`date${i}`] = nextDate; - } - caseStatement += ' END'; - - await this.workspaceRepository - .createQueryBuilder() - .update(Workspace) - .set({ - nextTrashCleanupAt: () => caseStatement, - updatedAt: () => 'CURRENT_TIMESTAMP', - }) - .where('id IN (:...ids)', { ids: workspaces.map((w) => w.id) }) - .setParameters(parameters) - .execute(); + return cutoffDate; + } - this.logger.log( - `Updated next cleanup dates for ${workspaces.length} workspace(s)`, + private async deleteFromTable( + schemaName: string, + tableName: string, + cutoffDate: Date, + limit: number, + ): Promise { + const result = await this.dataSource.query( + ` + WITH deleted AS ( + DELETE FROM "${schemaName}"."${tableName}" + WHERE ctid IN ( + SELECT ctid FROM "${schemaName}"."${tableName}" + WHERE "deletedAt" IS NOT NULL + AND "deletedAt" < $1 + LIMIT $2 + ) + RETURNING 1 + ) + SELECT COUNT(*) as count FROM deleted + `, + [cutoffDate, limit], ); - } - private sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return parseInt(result[0]?.count || '0', 10); } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-deletion.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-deletion.service.ts deleted file mode 100644 index d1ac62d754056..0000000000000 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-deletion.service.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectDataSource } from '@nestjs/typeorm'; - -import { DataSource } from 'typeorm'; - -export type DeletionResult = { - success: boolean; - error?: string; -}; - -@Injectable() -export class WorkspaceTrashDeletionService { - private readonly logger = new Logger(WorkspaceTrashDeletionService.name); - - constructor( - @InjectDataSource() - private readonly dataSource: DataSource, - ) {} - - async deleteSoftDeletedRecords( - workspaceId: string, - schemaName: string, - tableNames: string[], - ): Promise { - try { - if (tableNames.length === 0) { - this.logger.log( - `No tables with deletedAt found in schema ${schemaName} for workspace ${workspaceId} - skipping`, - ); - - return { success: true }; - } - - const deleteStatements = this.buildDeleteStatements( - schemaName, - tableNames, - ); - - await this.dataSource.query(deleteStatements); - - this.logger.log( - `Deleted soft-deleted records from ${tableNames.length} table(s) in workspace ${workspaceId}`, - ); - - return { success: true }; - } catch (error) { - const errorMessage = error?.message || String(error); - - this.logger.error( - `Failed to delete records for workspace ${workspaceId}: ${errorMessage}`, - ); - - return { success: false, error: errorMessage }; - } - } - - private buildDeleteStatements( - schemaName: string, - tableNames: string[], - ): string { - return tableNames - .map( - (tableName) => - `DELETE FROM "${schemaName}"."${tableName}" WHERE "deletedAt" IS NOT NULL`, - ) - .join('; '); - } -} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-table-discovery.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-table-discovery.service.ts deleted file mode 100644 index fe2595a198f21..0000000000000 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-table-discovery.service.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectDataSource } from '@nestjs/typeorm'; - -import { DataSource } from 'typeorm'; - -export type SchemaTableMap = Map; - -@Injectable() -export class WorkspaceTrashTableDiscoveryService { - private readonly logger = new Logger( - WorkspaceTrashTableDiscoveryService.name, - ); - - constructor( - @InjectDataSource() - private readonly dataSource: DataSource, - ) {} - - async discoverTablesWithSoftDelete( - schemaNames: string[], - ): Promise { - if (schemaNames.length === 0) { - return new Map(); - } - - const result = await this.dataSource.query( - ` - SELECT table_schema, table_name - FROM information_schema.columns - WHERE table_schema = ANY($1) - AND column_name = 'deletedAt' - GROUP BY table_schema, table_name - `, - [schemaNames], - ); - - return this.groupTablesBySchema(result); - } - - private groupTablesBySchema( - queryResult: Array<{ table_schema: string; table_name: string }>, - ): SchemaTableMap { - const tablesBySchema = new Map(); - - for (const row of queryResult) { - if (!tablesBySchema.has(row.table_schema)) { - tablesBySchema.set(row.table_schema, []); - } - tablesBySchema.get(row.table_schema)!.push(row.table_name); - } - - this.logger.log( - `Discovered tables in ${tablesBySchema.size} schema(s) with soft delete columns`, - ); - - return tablesBySchema; - } -} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts index 85095f6351d7f..a9f7ba9de3717 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts @@ -2,18 +2,17 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { WorkspaceTrashCleanupCronCommand } from 'src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command'; import { WorkspaceTrashCleanupCronJob } from 'src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job'; +import { WorkspaceTrashCleanupJob } from 'src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job'; import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service'; -import { WorkspaceTrashTableDiscoveryService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-table-discovery.service'; -import { WorkspaceTrashDeletionService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-deletion.service'; @Module({ - imports: [TypeOrmModule.forFeature([Workspace])], + imports: [TypeOrmModule.forFeature([Workspace, ObjectMetadataEntity])], providers: [ - WorkspaceTrashTableDiscoveryService, - WorkspaceTrashDeletionService, WorkspaceTrashCleanupService, + WorkspaceTrashCleanupJob, WorkspaceTrashCleanupCronJob, WorkspaceTrashCleanupCronCommand, ], From 06f49a8a99bfb8444263fac49786e90f3f84ea46 Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Fri, 3 Oct 2025 02:33:57 +0500 Subject: [PATCH 07/18] fix: add a unit test for trash-cleanup --- .../workspace-trash-cleanup.service.spec.ts | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/__tests__/workspace-trash-cleanup.service.spec.ts diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/__tests__/workspace-trash-cleanup.service.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/__tests__/workspace-trash-cleanup.service.spec.ts new file mode 100644 index 0000000000000..91d560bf6eaca --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/__tests__/workspace-trash-cleanup.service.spec.ts @@ -0,0 +1,213 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { DataSource } from 'typeorm'; + +import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +describe('WorkspaceTrashCleanupService', () => { + let service: WorkspaceTrashCleanupService; + let mockDataSource: any; + let mockObjectMetadataRepository: any; + + beforeEach(async () => { + mockDataSource = { + query: jest.fn(), + }; + + mockObjectMetadataRepository = { + createQueryBuilder: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspaceTrashCleanupService, + { + provide: DataSource, + useValue: mockDataSource, + }, + { + provide: getRepositoryToken(ObjectMetadataEntity), + useValue: mockObjectMetadataRepository, + }, + { + provide: TwentyConfigService, + useValue: { + get: jest.fn().mockReturnValue(100000), + }, + }, + ], + }).compile(); + + service = module.get( + WorkspaceTrashCleanupService, + ); + + // Suppress logger output in tests + jest.spyOn(service['logger'], 'log').mockImplementation(); + jest.spyOn(service['logger'], 'error').mockImplementation(); + + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('cleanupWorkspaceTrash', () => { + it('should return success with deleted count when cleanup succeeds', async () => { + const mockQueryBuilder = { + innerJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { nameSingular: 'company', isCustom: false }, + { nameSingular: 'person', isCustom: false }, + ]), + }; + + mockObjectMetadataRepository.createQueryBuilder.mockReturnValue( + mockQueryBuilder, + ); + + mockDataSource.query.mockResolvedValueOnce([{ count: '30000' }]); + mockDataSource.query.mockResolvedValueOnce([{ count: '20000' }]); + + const result = await service.cleanupWorkspaceTrash({ + workspaceId: 'workspace-id', + schemaName: 'workspace_test', + trashRetentionDays: 14, + }); + + expect(result).toEqual({ + success: true, + deletedCount: 50000, + }); + }); + + it('should return success with zero count when no tables found', async () => { + const mockQueryBuilder = { + innerJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + + mockObjectMetadataRepository.createQueryBuilder.mockReturnValue( + mockQueryBuilder, + ); + + const result = await service.cleanupWorkspaceTrash({ + workspaceId: 'workspace-id', + schemaName: 'workspace_test', + trashRetentionDays: 14, + }); + + expect(result).toEqual({ + success: true, + deletedCount: 0, + }); + }); + + it('should return error result when discovery fails', async () => { + const mockQueryBuilder = { + innerJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockRejectedValue(new Error('Database error')), + }; + + mockObjectMetadataRepository.createQueryBuilder.mockReturnValue( + mockQueryBuilder, + ); + + const result = await service.cleanupWorkspaceTrash({ + workspaceId: 'workspace-id', + schemaName: 'workspace_test', + trashRetentionDays: 14, + }); + + expect(result).toEqual({ + success: false, + deletedCount: 0, + error: 'Database error', + }); + }); + + it('should respect max records limit across multiple tables', async () => { + const mockQueryBuilder = { + innerJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { nameSingular: 'company', isCustom: false }, + { nameSingular: 'person', isCustom: false }, + { nameSingular: 'opportunity', isCustom: false }, + ]), + }; + + mockObjectMetadataRepository.createQueryBuilder.mockReturnValue( + mockQueryBuilder, + ); + + // Mock deletion results: 60k + 40k = 100k (hits limit, third table not processed) + mockDataSource.query + .mockResolvedValueOnce([{ count: '60000' }]) + .mockResolvedValueOnce([{ count: '40000' }]); + + const result = await service.cleanupWorkspaceTrash({ + workspaceId: 'workspace-id', + schemaName: 'workspace_test', + trashRetentionDays: 14, + }); + + expect(result).toEqual({ + success: true, + deletedCount: 100000, + }); + // Should only process 2 tables (company, person) and stop before opportunity + expect(mockDataSource.query).toHaveBeenCalledTimes(2); + }); + + it('should return error result when deletion fails', async () => { + const mockQueryBuilder = { + innerJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawMany: jest + .fn() + .mockResolvedValue([{ nameSingular: 'company', isCustom: false }]), + }; + + mockObjectMetadataRepository.createQueryBuilder.mockReturnValue( + mockQueryBuilder, + ); + + mockDataSource.query.mockRejectedValue(new Error('Deletion failed')); + + const result = await service.cleanupWorkspaceTrash({ + workspaceId: 'workspace-id', + schemaName: 'workspace_test', + trashRetentionDays: 14, + }); + + expect(result).toEqual({ + success: false, + deletedCount: 0, + error: 'Deletion failed', + }); + }); + }); +}); From 97f1088099ebc5ba0a5b4eb9f2e7308163ba7e1b Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Fri, 3 Oct 2025 02:56:33 +0500 Subject: [PATCH 08/18] fix: generate graphql types --- .../src/generated-metadata/graphql.ts | 190 +++++++++--------- 1 file changed, 97 insertions(+), 93 deletions(-) diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index a4aa8bfe26dc9..7e1624c5d0324 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1,5 +1,5 @@ -import * as Apollo from '@apollo/client'; import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; export type Maybe = T | null; export type InputMaybe = Maybe; export type Exact = { [K in keyof T]: T[K] }; @@ -5445,7 +5445,7 @@ export type BillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscri export type CurrentBillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null, views?: Array<{ __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, fieldMetadataId: string, isVisible: boolean, fieldValue: string, position: number, viewId: string }> }> | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; export type WorkspaceUrlsFragmentFragment = { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }; @@ -5464,7 +5464,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null, views?: Array<{ __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, fieldMetadataId: string, isVisible: boolean, fieldValue: string, position: number, viewId: string }> }> | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; export type ViewFieldFragmentFragment = { __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }; @@ -6187,6 +6187,96 @@ export const RoleFragmentFragmentDoc = gql` canBeAssignedToApiKeys } `; +export const ViewFieldFragmentFragmentDoc = gql` + fragment ViewFieldFragment on CoreViewField { + id + fieldMetadataId + viewId + isVisible + position + size + aggregateOperation + createdAt + updatedAt + deletedAt +} + `; +export const ViewFilterFragmentFragmentDoc = gql` + fragment ViewFilterFragment on CoreViewFilter { + id + fieldMetadataId + operand + value + viewFilterGroupId + positionInViewFilterGroup + subFieldName + viewId +} + `; +export const ViewFilterGroupFragmentFragmentDoc = gql` + fragment ViewFilterGroupFragment on CoreViewFilterGroup { + id + parentViewFilterGroupId + logicalOperator + positionInViewFilterGroup + viewId +} + `; +export const ViewSortFragmentFragmentDoc = gql` + fragment ViewSortFragment on CoreViewSort { + id + fieldMetadataId + direction + viewId +} + `; +export const ViewGroupFragmentFragmentDoc = gql` + fragment ViewGroupFragment on CoreViewGroup { + id + fieldMetadataId + isVisible + fieldValue + position + viewId +} + `; +export const ViewFragmentFragmentDoc = gql` + fragment ViewFragment on CoreView { + id + name + objectMetadataId + type + key + icon + position + isCompact + openRecordIn + kanbanAggregateOperation + kanbanAggregateOperationFieldMetadataId + anyFieldFilterValue + calendarFieldMetadataId + calendarLayout + viewFields { + ...ViewFieldFragment + } + viewFilters { + ...ViewFilterFragment + } + viewFilterGroups { + ...ViewFilterGroupFragment + } + viewSorts { + ...ViewSortFragment + } + viewGroups { + ...ViewGroupFragment + } +} + ${ViewFieldFragmentFragmentDoc} +${ViewFilterFragmentFragmentDoc} +${ViewFilterGroupFragmentFragmentDoc} +${ViewSortFragmentFragmentDoc} +${ViewGroupFragmentFragmentDoc}`; export const AvailableWorkspaceFragmentFragmentDoc = gql` fragment AvailableWorkspaceFragment on AvailableWorkspace { id @@ -6286,6 +6376,9 @@ export const UserQueryFragmentFragmentDoc = gql` } isTwoFactorAuthenticationEnforced trashRetentionDays + views { + ...ViewFragment + } } availableWorkspaces { ...AvailableWorkspacesFragment @@ -6300,97 +6393,8 @@ ${WorkspaceUrlsFragmentFragmentDoc} ${CurrentBillingSubscriptionFragmentFragmentDoc} ${BillingSubscriptionFragmentFragmentDoc} ${RoleFragmentFragmentDoc} +${ViewFragmentFragmentDoc} ${AvailableWorkspacesFragmentFragmentDoc}`; -export const ViewFieldFragmentFragmentDoc = gql` - fragment ViewFieldFragment on CoreViewField { - id - fieldMetadataId - viewId - isVisible - position - size - aggregateOperation - createdAt - updatedAt - deletedAt -} - `; -export const ViewFilterFragmentFragmentDoc = gql` - fragment ViewFilterFragment on CoreViewFilter { - id - fieldMetadataId - operand - value - viewFilterGroupId - positionInViewFilterGroup - subFieldName - viewId -} - `; -export const ViewFilterGroupFragmentFragmentDoc = gql` - fragment ViewFilterGroupFragment on CoreViewFilterGroup { - id - parentViewFilterGroupId - logicalOperator - positionInViewFilterGroup - viewId -} - `; -export const ViewSortFragmentFragmentDoc = gql` - fragment ViewSortFragment on CoreViewSort { - id - fieldMetadataId - direction - viewId -} - `; -export const ViewGroupFragmentFragmentDoc = gql` - fragment ViewGroupFragment on CoreViewGroup { - id - fieldMetadataId - isVisible - fieldValue - position - viewId -} - `; -export const ViewFragmentFragmentDoc = gql` - fragment ViewFragment on CoreView { - id - name - objectMetadataId - type - key - icon - position - isCompact - openRecordIn - kanbanAggregateOperation - kanbanAggregateOperationFieldMetadataId - anyFieldFilterValue - calendarFieldMetadataId - calendarLayout - viewFields { - ...ViewFieldFragment - } - viewFilters { - ...ViewFilterFragment - } - viewFilterGroups { - ...ViewFilterGroupFragment - } - viewSorts { - ...ViewSortFragment - } - viewGroups { - ...ViewGroupFragment - } -} - ${ViewFieldFragmentFragmentDoc} -${ViewFilterFragmentFragmentDoc} -${ViewFilterGroupFragmentFragmentDoc} -${ViewSortFragmentFragmentDoc} -${ViewGroupFragmentFragmentDoc}`; export const WorkflowDiffFragmentFragmentDoc = gql` fragment WorkflowDiffFragment on WorkflowVersionStepChanges { triggerDiff From 5fff1132b2ba02fa8425e3c70798ee22b16598cd Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Fri, 3 Oct 2025 03:10:05 +0500 Subject: [PATCH 09/18] fix: remove unrelated change --- .../src/modules/users/hooks/useLoadCurrentUser.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts b/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts index 93458d510fa96..78f06a9909d62 100644 --- a/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts +++ b/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts @@ -101,15 +101,7 @@ export const useLoadCurrentUser = () => { const workspace = user.currentWorkspace ?? null; - setCurrentWorkspace( - workspace - ? { - ...workspace, - defaultRole: workspace.defaultRole ?? null, - defaultAgent: workspace.defaultAgent ?? null, - } - : null, - ); + setCurrentWorkspace(workspace); if (isDefined(workspace) && isOnAWorkspace) { setLastAuthenticateWorkspaceDomain({ From 6d700f3c923362f775f80ee733dddc051d2441a4 Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Fri, 3 Oct 2025 03:29:55 +0500 Subject: [PATCH 10/18] fix: resolve a couple comments --- .../src/pages/settings/security/SettingsSecurity.tsx | 6 +++--- .../workspace-trash-cleanup.module.ts | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx index 72cdf0a460cdf..7573145da458d 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx @@ -3,13 +3,13 @@ import { Trans, useLingui } from '@lingui/react/macro'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; +import { SettingsOptionCardContentInput } from '@/settings/components/SettingsOptions/SettingsOptionCardContentInput'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard'; import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList'; -import { SettingsOptionCardContentInput } from '@/settings/components/SettingsOptions/SettingsOptionCardContentInput'; import { ToggleImpersonate } from '@/settings/workspace/components/ToggleImpersonate'; -import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { ApolloError } from '@apollo/client'; import { useRecoilState, useRecoilValue } from 'recoil'; import { SettingsPath } from 'twenty-shared/types'; @@ -74,7 +74,7 @@ export const SettingsSecurity = () => { ...currentWorkspace, trashRetentionDays: numValue, }); - } catch (err: any) { + } catch (err) { enqueueErrorSnackBar({ apolloError: err instanceof ApolloError ? err : undefined, }); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts index a9f7ba9de3717..80a38151b5abb 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { WorkspaceTrashCleanupCronCommand } from 'src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command'; import { WorkspaceTrashCleanupCronJob } from 'src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job'; @@ -9,7 +10,10 @@ import { WorkspaceTrashCleanupJob } from 'src/engine/workspace-manager/workspace import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service'; @Module({ - imports: [TypeOrmModule.forFeature([Workspace, ObjectMetadataEntity])], + imports: [ + TypeOrmModule.forFeature([Workspace, ObjectMetadataEntity]), + DataSourceModule, + ], providers: [ WorkspaceTrashCleanupService, WorkspaceTrashCleanupJob, From 03c2cdab0db2b9ceba7749f6dd7ac4de0d641bc2 Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Mon, 6 Oct 2025 02:15:11 +0500 Subject: [PATCH 11/18] fix: remove the extra SettingsOptionCardContentInput and update SettingsOptionCardContentCounter with a showButtons prop for reusability --- .../settings/components/SettingsCounter.tsx | 47 +++++----- .../SettingsOptionCardContentCounter.tsx | 3 + .../SettingsOptionCardContentInput.tsx | 75 ---------------- ...ttingsOptionCardContentCounter.stories.tsx | 15 ++++ ...SettingsOptionCardContentInput.stories.tsx | 85 ------------------- .../settings/security/SettingsSecurity.tsx | 50 +++++------ 6 files changed, 71 insertions(+), 204 deletions(-) delete mode 100644 packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentInput.tsx delete mode 100644 packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentInput.stories.tsx diff --git a/packages/twenty-front/src/modules/settings/components/SettingsCounter.tsx b/packages/twenty-front/src/modules/settings/components/SettingsCounter.tsx index 970ab58ac8610..f265a00fecf50 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsCounter.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsCounter.tsx @@ -10,14 +10,16 @@ type SettingsCounterProps = { minValue?: number; maxValue?: number; disabled?: boolean; + showButtons?: boolean; }; -const StyledCounterContainer = styled.div` +const StyledCounterContainer = styled.div<{ showButtons: boolean }>` align-items: center; display: flex; gap: ${({ theme }) => theme.spacing(1)}; margin-left: auto; - width: ${({ theme }) => theme.spacing(30)}; + width: ${({ theme, showButtons }) => + showButtons ? theme.spacing(30) : theme.spacing(16)}; `; const StyledTextInput = styled(SettingsTextInput)` @@ -34,11 +36,12 @@ export const SettingsCounter = ({ value, onChange, minValue = 0, - maxValue = 100, + maxValue, disabled = false, + showButtons = true, }: SettingsCounterProps) => { const handleIncrementCounter = () => { - if (value < maxValue) { + if (maxValue === undefined || value < maxValue) { onChange(value + 1); } }; @@ -60,7 +63,7 @@ export const SettingsCounter = ({ return; } - if (castedNumber > maxValue) { + if (maxValue !== undefined && castedNumber > maxValue) { onChange(maxValue); return; } @@ -68,14 +71,16 @@ export const SettingsCounter = ({ }; return ( - - + + {showButtons && ( + + )} - + {showButtons && ( + + )} ); }; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentCounter.tsx b/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentCounter.tsx index ce4b22e327d31..fbd5b32528d60 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentCounter.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentCounter.tsx @@ -17,6 +17,7 @@ type SettingsOptionCardContentCounterProps = { onChange: (value: number) => void; minValue?: number; maxValue?: number; + showButtons?: boolean; }; export const SettingsOptionCardContentCounter = ({ @@ -28,6 +29,7 @@ export const SettingsOptionCardContentCounter = ({ onChange, minValue, maxValue, + showButtons = true, }: SettingsOptionCardContentCounterProps) => { return ( @@ -50,6 +52,7 @@ export const SettingsOptionCardContentCounter = ({ minValue={minValue} maxValue={maxValue} disabled={disabled} + showButtons={showButtons} /> ); diff --git a/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentInput.tsx b/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentInput.tsx deleted file mode 100644 index 18deab846143f..0000000000000 --- a/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentInput.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { - StyledSettingsOptionCardContent, - StyledSettingsOptionCardDescription, - StyledSettingsOptionCardIcon, - StyledSettingsOptionCardTitle, -} from '@/settings/components/SettingsOptions/SettingsOptionCardContentBase'; -import { SettingsOptionIconCustomizer } from '@/settings/components/SettingsOptions/SettingsOptionIconCustomizer'; -import { TextInput } from '@/ui/input/components/TextInput'; -import styled from '@emotion/styled'; -import { useState } from 'react'; -import { type IconComponent } from 'twenty-ui/display'; - -const StyledInputContainer = styled.div` - width: 80px; -`; - -const StyledSettingsOptionCardContentInfo = styled.div` - display: flex; - flex: 1; - flex-direction: column; -`; - -type SettingsOptionCardContentInputProps = { - Icon?: IconComponent; - title: React.ReactNode; - description?: string; - disabled?: boolean; - value: string; - onBlur: (value: string) => void; - placeholder?: string; -}; - -export const SettingsOptionCardContentInput = ({ - Icon, - title, - description, - disabled = false, - value: initialValue, - onBlur, - placeholder, -}: SettingsOptionCardContentInputProps) => { - const [value, setValue] = useState(initialValue); - - const handleBlur = () => { - onBlur(value); - }; - - return ( - - {Icon && ( - - - - )} - - {title} - {description && ( - - {description} - - )} - - - - - - ); -}; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentCounter.stories.tsx b/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentCounter.stories.tsx index f683e2516a3cb..f3250e4704385 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentCounter.stories.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentCounter.stories.tsx @@ -25,6 +25,7 @@ const SettingsOptionCardContentCounterWrapper = ( disabled={args.disabled} minValue={args.minValue} maxValue={args.maxValue} + showButtons={args.showButtons} /> ); @@ -50,6 +51,7 @@ export const Default: Story = { value: 5, minValue: 1, maxValue: 10, + showButtons: true, }, argTypes: { Icon: { control: false }, @@ -64,6 +66,7 @@ export const WithoutIcon: Story = { value: 20, minValue: 10, maxValue: 50, + showButtons: true, }, }; @@ -76,5 +79,17 @@ export const Disabled: Story = { disabled: true, minValue: 1, maxValue: 10, + showButtons: true, + }, +}; + +export const WithoutButtons: Story = { + args: { + Icon: IconUsers, + title: 'Trash Retention', + description: 'Adjust the number of days before deletion', + value: 14, + minValue: 0, + showButtons: false, }, }; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentInput.stories.tsx b/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentInput.stories.tsx deleted file mode 100644 index 286e9faf3231a..0000000000000 --- a/packages/twenty-front/src/modules/settings/components/SettingsOptions/__stories__/SettingsOptionCardContentInput.stories.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { SettingsOptionCardContentInput } from '@/settings/components/SettingsOptions/SettingsOptionCardContentInput'; -import styled from '@emotion/styled'; -import { type Meta, type StoryObj } from '@storybook/react'; -import { ComponentDecorator } from 'twenty-ui/testing'; -import { IconTrash, IconCalendar, IconDatabase } from 'twenty-ui/display'; - -const StyledContainer = styled.div` - width: 480px; -`; - -const SettingsOptionCardContentInputWrapper = ( - args: React.ComponentProps, -) => { - const handleBlur = (value: string) => { - // eslint-disable-next-line no-console - console.log('Value on blur:', value); - }; - - return ( - - - - ); -}; - -const meta: Meta = { - title: 'Modules/Settings/SettingsOptionCardContentInput', - component: SettingsOptionCardContentInputWrapper, - decorators: [ComponentDecorator], - parameters: { - maxWidth: 800, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - Icon: IconTrash, - title: 'Erasure of soft-deleted records', - description: 'Permanent deletion. Enter the number of days.', - value: '14', - placeholder: '14', - }, - argTypes: { - Icon: { control: false }, - onBlur: { control: false }, - }, -}; - -export const Disabled: Story = { - args: { - Icon: IconDatabase, - title: 'Database Retention', - description: 'This setting is currently locked', - value: '30', - disabled: true, - }, -}; - -export const WithoutIcon: Story = { - args: { - title: 'Simple Input', - description: 'A basic input without an icon', - value: '7', - placeholder: '0', - }, -}; - -export const WithoutDescription: Story = { - args: { - Icon: IconCalendar, - title: 'Days to Keep', - value: '90', - }, -}; diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx index 7573145da458d..b3069435b9c53 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx @@ -1,9 +1,10 @@ import styled from '@emotion/styled'; import { Trans, useLingui } from '@lingui/react/macro'; +import { useDebouncedCallback } from 'use-debounce'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; -import { SettingsOptionCardContentInput } from '@/settings/components/SettingsOptions/SettingsOptionCardContentInput'; +import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard'; import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList'; @@ -44,19 +45,7 @@ export const SettingsSecurity = () => { ); const [updateWorkspace] = useUpdateWorkspaceMutation(); - const handleTrashRetentionDaysBlur = async (value: string) => { - const numValue = parseInt(value, 10); - - // Validate input - if (isNaN(numValue) || numValue < 0) { - return; - } - - // Don't make API call if value hasn't changed - if (numValue === currentWorkspace?.trashRetentionDays) { - return; - } - + const saveWorkspace = useDebouncedCallback(async (value: number) => { try { if (!currentWorkspace?.id) { throw new Error('User is not logged in'); @@ -65,20 +54,32 @@ export const SettingsSecurity = () => { await updateWorkspace({ variables: { input: { - trashRetentionDays: numValue, + trashRetentionDays: value, }, }, }); - - setCurrentWorkspace({ - ...currentWorkspace, - trashRetentionDays: numValue, - }); } catch (err) { enqueueErrorSnackBar({ apolloError: err instanceof ApolloError ? err : undefined, }); } + }, 500); + + const handleTrashRetentionDaysChange = (value: number) => { + if (!currentWorkspace) { + return; + } + + if (value === currentWorkspace.trashRetentionDays) { + return; + } + + setCurrentWorkspace({ + ...currentWorkspace, + trashRetentionDays: value, + }); + + saveWorkspace(value); }; return ( @@ -134,13 +135,14 @@ export const SettingsSecurity = () => { description={t`Other security settings`} /> - From 2eb344adfd7dc1cb3e25fe74a35620ffa1b27737 Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Mon, 6 Oct 2025 02:42:47 +0500 Subject: [PATCH 12/18] fix: resolve issues in workspace-trash-cleanup.cron.job.ts file and ensure it follows patterns of other cron.job.ts files --- .../crons/workspace-trash-cleanup.cron.job.ts | 58 ++++++++++--------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts index b4224b7d5a009..e53b4664232be 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts @@ -5,18 +5,19 @@ import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { Repository } from 'typeorm'; import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator'; +import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; import { WORKSPACE_TRASH_CLEANUP_CRON_PATTERN } from 'src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants'; import { WorkspaceTrashCleanupJob, type WorkspaceTrashCleanupJobData, } from 'src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job'; +import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util'; @Injectable() @Processor(MessageQueue.cronQueue) @@ -28,6 +29,7 @@ export class WorkspaceTrashCleanupCronJob { private readonly workspaceRepository: Repository, @InjectMessageQueue(MessageQueue.workspaceQueue) private readonly messageQueueService: MessageQueueService, + private readonly exceptionHandlerService: ExceptionHandlerService, ) {} @Process(WorkspaceTrashCleanupCronJob.name) @@ -48,21 +50,24 @@ export class WorkspaceTrashCleanupCronJob { `Enqueuing trash cleanup jobs for ${workspaces.length} workspace(s)`, ); - await Promise.all( - workspaces.map((workspace) => - this.messageQueueService.add( + for (const workspace of workspaces) { + try { + await this.messageQueueService.add( WorkspaceTrashCleanupJob.name, { workspaceId: workspace.id, schemaName: workspace.schema, trashRetentionDays: workspace.trashRetentionDays, }, - { - priority: 10, + ); + } catch (error) { + this.exceptionHandlerService.captureExceptions([error], { + workspace: { + id: workspace.id, }, - ), - ), - ); + }); + } + } this.logger.log( `Successfully enqueued ${workspaces.length} trash cleanup job(s)`, @@ -72,25 +77,22 @@ export class WorkspaceTrashCleanupCronJob { private async getActiveWorkspaces(): Promise< Array<{ id: string; trashRetentionDays: number; schema: string }> > { - const rawResults = await this.workspaceRepository - .createQueryBuilder('workspace') - .innerJoin( - DataSourceEntity, - 'dataSource', - 'dataSource.workspaceId = workspace.id', - ) - .where('workspace.activationStatus = :status', { - status: WorkspaceActivationStatus.ACTIVE, - }) - .andWhere('workspace.trashRetentionDays >= :minDays', { minDays: 0 }) - .select([ - 'workspace.id AS id', - 'workspace.trashRetentionDays AS "trashRetentionDays"', - 'dataSource.schema AS schema', - ]) - .orderBy('workspace.id', 'ASC') - .getRawMany(); + const workspaces = await this.workspaceRepository.find({ + where: { + activationStatus: WorkspaceActivationStatus.ACTIVE, + }, + select: ['id', 'trashRetentionDays'], + order: { id: 'ASC' }, + }); + + if (workspaces.length === 0) { + return []; + } - return rawResults; + return workspaces.map((workspace) => ({ + id: workspace.id, + trashRetentionDays: workspace.trashRetentionDays, + schema: getWorkspaceSchemaName(workspace.id), + })); } } From 9b93ee03d82a56e62d3d6109f77ff52d82fecac5 Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Mon, 6 Oct 2025 03:23:37 +0500 Subject: [PATCH 13/18] fix: ensure workspace-trash-cleanup.job.ts follows the established codebase patterns --- .../jobs/workspace-trash-cleanup.job.ts | 10 ++-- .../workspace-trash-cleanup.service.spec.ts | 59 +++++++------------ .../workspace-trash-cleanup.service.ts | 50 +++++----------- 3 files changed, 42 insertions(+), 77 deletions(-) diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job.ts index dd53d60c0c192..5add1287a9b3a 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job.ts @@ -24,18 +24,18 @@ export class WorkspaceTrashCleanupJob { async handle(data: WorkspaceTrashCleanupJobData): Promise { const { workspaceId, schemaName, trashRetentionDays } = data; - const result = + try { await this.workspaceTrashCleanupService.cleanupWorkspaceTrash({ workspaceId, schemaName, trashRetentionDays, }); - - if (!result.success) { + } catch (error) { this.logger.error( - `Trash cleanup failed for workspace ${workspaceId}: ${result.error}`, + `Trash cleanup failed for workspace ${workspaceId}`, + error instanceof Error ? error.stack : String(error), ); - throw new Error(result.error); + throw error; } } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/__tests__/workspace-trash-cleanup.service.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/__tests__/workspace-trash-cleanup.service.spec.ts index 91d560bf6eaca..3086286d2a21d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/__tests__/workspace-trash-cleanup.service.spec.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/__tests__/workspace-trash-cleanup.service.spec.ts @@ -57,7 +57,7 @@ describe('WorkspaceTrashCleanupService', () => { }); describe('cleanupWorkspaceTrash', () => { - it('should return success with deleted count when cleanup succeeds', async () => { + it('should return deleted count when cleanup succeeds', async () => { const mockQueryBuilder = { innerJoin: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), @@ -83,13 +83,10 @@ describe('WorkspaceTrashCleanupService', () => { trashRetentionDays: 14, }); - expect(result).toEqual({ - success: true, - deletedCount: 50000, - }); + expect(result).toEqual(50000); }); - it('should return success with zero count when no tables found', async () => { + it('should return zero when no tables found', async () => { const mockQueryBuilder = { innerJoin: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), @@ -109,13 +106,10 @@ describe('WorkspaceTrashCleanupService', () => { trashRetentionDays: 14, }); - expect(result).toEqual({ - success: true, - deletedCount: 0, - }); + expect(result).toEqual(0); }); - it('should return error result when discovery fails', async () => { + it('should throw when discovery fails', async () => { const mockQueryBuilder = { innerJoin: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), @@ -129,17 +123,13 @@ describe('WorkspaceTrashCleanupService', () => { mockQueryBuilder, ); - const result = await service.cleanupWorkspaceTrash({ - workspaceId: 'workspace-id', - schemaName: 'workspace_test', - trashRetentionDays: 14, - }); - - expect(result).toEqual({ - success: false, - deletedCount: 0, - error: 'Database error', - }); + await expect( + service.cleanupWorkspaceTrash({ + workspaceId: 'workspace-id', + schemaName: 'workspace_test', + trashRetentionDays: 14, + }), + ).rejects.toThrow('Database error'); }); it('should respect max records limit across multiple tables', async () => { @@ -171,15 +161,12 @@ describe('WorkspaceTrashCleanupService', () => { trashRetentionDays: 14, }); - expect(result).toEqual({ - success: true, - deletedCount: 100000, - }); + expect(result).toEqual(100000); // Should only process 2 tables (company, person) and stop before opportunity expect(mockDataSource.query).toHaveBeenCalledTimes(2); }); - it('should return error result when deletion fails', async () => { + it('should throw when deletion fails', async () => { const mockQueryBuilder = { innerJoin: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), @@ -197,17 +184,13 @@ describe('WorkspaceTrashCleanupService', () => { mockDataSource.query.mockRejectedValue(new Error('Deletion failed')); - const result = await service.cleanupWorkspaceTrash({ - workspaceId: 'workspace-id', - schemaName: 'workspace_test', - trashRetentionDays: 14, - }); - - expect(result).toEqual({ - success: false, - deletedCount: 0, - error: 'Deletion failed', - }); + await expect( + service.cleanupWorkspaceTrash({ + workspaceId: 'workspace-id', + schemaName: 'workspace_test', + trashRetentionDays: 14, + }), + ).rejects.toThrow('Deletion failed'); }); }); }); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts index 5a208ff42900e..e75ba0038cbd9 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts @@ -14,12 +14,6 @@ export type WorkspaceTrashCleanupInput = { trashRetentionDays: number; }; -export type WorkspaceTrashCleanupResult = { - success: boolean; - deletedCount: number; - error?: string; -}; - @Injectable() export class WorkspaceTrashCleanupService { private readonly logger = new Logger(WorkspaceTrashCleanupService.name); @@ -39,41 +33,29 @@ export class WorkspaceTrashCleanupService { async cleanupWorkspaceTrash( input: WorkspaceTrashCleanupInput, - ): Promise { + ): Promise { const { workspaceId, schemaName, trashRetentionDays } = input; - try { - const tableNames = await this.discoverTablesWithSoftDelete(schemaName); - - if (tableNames.length === 0) { - this.logger.log( - `No tables with deletedAt found in workspace ${workspaceId}`, - ); - - return { success: true, deletedCount: 0 }; - } + const tableNames = await this.discoverTablesWithSoftDelete(schemaName); - const deletedCount = await this.deleteSoftDeletedRecords( - workspaceId, - schemaName, - tableNames, - trashRetentionDays, - ); + if (tableNames.length === 0) { + this.logger.log(`No tables with deletedAt found in workspace ${workspaceId}`); - this.logger.log( - `Deleted ${deletedCount} record(s) from workspace ${workspaceId}`, - ); + return 0; + } - return { success: true, deletedCount }; - } catch (error) { - const errorMessage = error?.message || String(error); + const deletedCount = await this.deleteSoftDeletedRecords( + workspaceId, + schemaName, + tableNames, + trashRetentionDays, + ); - this.logger.error( - `Failed to cleanup workspace ${workspaceId}: ${errorMessage}`, - ); + this.logger.log( + `Deleted ${deletedCount} record(s) from workspace ${workspaceId}`, + ); - return { success: false, deletedCount: 0, error: errorMessage }; - } + return deletedCount; } private async discoverTablesWithSoftDelete( From 3eecdabeb177ffee3fc43ecbeda368a43e3f8ec8 Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Thu, 9 Oct 2025 23:10:14 +0500 Subject: [PATCH 14/18] feat: revamp the trash deletion logic to respect twenty orm --- .../graphql/fragments/userQueryFragment.ts | 3 - packages/twenty-server/.env.example | 2 + .../twenty-config/config-variables.ts | 9 + .../crons/workspace-trash-cleanup.cron.job.ts | 5 +- .../jobs/workspace-trash-cleanup.job.ts | 4 +- .../workspace-trash-cleanup.service.spec.ts | 256 ++++++++++-------- .../workspace-trash-cleanup.service.ts | 190 +++++++------ .../workspace-trash-cleanup.module.ts | 7 +- 8 files changed, 249 insertions(+), 227 deletions(-) diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index 19871708687b1..df6099ce62cfd 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -80,9 +80,6 @@ export const USER_QUERY_FRAGMENT = gql` } isTwoFactorAuthenticationEnforced trashRetentionDays - views { - ...ViewFragment - } } availableWorkspaces { ...AvailableWorkspacesFragment diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index ff27505ef6d6d..b5a279329f9f2 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -79,3 +79,5 @@ FRONTEND_URL=http://localhost:3001 # IS_CONFIG_VARIABLES_IN_DB_ENABLED=false # ANALYTICS_ENABLED= # CLICKHOUSE_URL=http://default:clickhousePassword@localhost:8123/twenty +# TRASH_CLEANUP_MAX_RECORDS_PER_WORKSPACE=100000 +# TRASH_CLEANUP_BATCH_SIZE=1000 diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 914a6f11b811f..7c14398542cad 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -1139,6 +1139,15 @@ export class ConfigVariables { @IsOptional() TRASH_CLEANUP_MAX_RECORDS_PER_WORKSPACE = 100000; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.Other, + description: 'Number of records deleted per batch during trash cleanup', + type: ConfigVariableType.NUMBER, + }) + @CastToPositiveNumber() + @IsOptional() + TRASH_CLEANUP_BATCH_SIZE = 1000; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.RateLimiting, description: 'Throttle limit for workflow execution', diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts index e53b4664232be..0f3751472560c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts @@ -17,7 +17,6 @@ import { WorkspaceTrashCleanupJob, type WorkspaceTrashCleanupJobData, } from 'src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job'; -import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util'; @Injectable() @Processor(MessageQueue.cronQueue) @@ -56,7 +55,6 @@ export class WorkspaceTrashCleanupCronJob { WorkspaceTrashCleanupJob.name, { workspaceId: workspace.id, - schemaName: workspace.schema, trashRetentionDays: workspace.trashRetentionDays, }, ); @@ -75,7 +73,7 @@ export class WorkspaceTrashCleanupCronJob { } private async getActiveWorkspaces(): Promise< - Array<{ id: string; trashRetentionDays: number; schema: string }> + Array<{ id: string; trashRetentionDays: number }> > { const workspaces = await this.workspaceRepository.find({ where: { @@ -92,7 +90,6 @@ export class WorkspaceTrashCleanupCronJob { return workspaces.map((workspace) => ({ id: workspace.id, trashRetentionDays: workspace.trashRetentionDays, - schema: getWorkspaceSchemaName(workspace.id), })); } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job.ts index 5add1287a9b3a..a80927b965acf 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job.ts @@ -7,7 +7,6 @@ import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/works export type WorkspaceTrashCleanupJobData = { workspaceId: string; - schemaName: string; trashRetentionDays: number; }; @@ -22,12 +21,11 @@ export class WorkspaceTrashCleanupJob { @Process(WorkspaceTrashCleanupJob.name) async handle(data: WorkspaceTrashCleanupJobData): Promise { - const { workspaceId, schemaName, trashRetentionDays } = data; + const { workspaceId, trashRetentionDays } = data; try { await this.workspaceTrashCleanupService.cleanupWorkspaceTrash({ workspaceId, - schemaName, trashRetentionDays, }); } catch (error) { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/__tests__/workspace-trash-cleanup.service.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/__tests__/workspace-trash-cleanup.service.spec.ts index 3086286d2a21d..bbba28b096c80 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/__tests__/workspace-trash-cleanup.service.spec.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/__tests__/workspace-trash-cleanup.service.spec.ts @@ -1,42 +1,43 @@ import { Test, type TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { DataSource } from 'typeorm'; - -import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service'; -import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service'; describe('WorkspaceTrashCleanupService', () => { let service: WorkspaceTrashCleanupService; - let mockDataSource: any; - let mockObjectMetadataRepository: any; + let mockFlatEntityMapsCacheService: any; + let mockTwentyORMGlobalManager: any; + let mockConfigService: { get: jest.Mock }; beforeEach(async () => { - mockDataSource = { - query: jest.fn(), + mockFlatEntityMapsCacheService = { + getOrRecomputeManyOrAllFlatEntityMaps: jest.fn(), }; - mockObjectMetadataRepository = { - createQueryBuilder: jest.fn(), + mockTwentyORMGlobalManager = { + getRepositoryForWorkspace: jest.fn(), + }; + + mockConfigService = { + get: jest.fn().mockReturnValue(100000), }; const module: TestingModule = await Test.createTestingModule({ providers: [ WorkspaceTrashCleanupService, { - provide: DataSource, - useValue: mockDataSource, + provide: TwentyConfigService, + useValue: mockConfigService, }, { - provide: getRepositoryToken(ObjectMetadataEntity), - useValue: mockObjectMetadataRepository, + provide: WorkspaceManyOrAllFlatEntityMapsCacheService, + useValue: mockFlatEntityMapsCacheService, }, { - provide: TwentyConfigService, - useValue: { - get: jest.fn().mockReturnValue(100000), - }, + provide: TwentyORMGlobalManager, + useValue: mockTwentyORMGlobalManager, }, ], }).compile(); @@ -57,140 +58,167 @@ describe('WorkspaceTrashCleanupService', () => { }); describe('cleanupWorkspaceTrash', () => { - it('should return deleted count when cleanup succeeds', async () => { - const mockQueryBuilder = { - innerJoin: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - addSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getRawMany: jest.fn().mockResolvedValue([ - { nameSingular: 'company', isCustom: false }, - { nameSingular: 'person', isCustom: false }, - ]), + const createRepositoryMock = (name: string, initialCount: number) => { + let remaining = initialCount; + let counter = 0; + + return { + find: jest.fn().mockImplementation(({ take }) => { + const amount = Math.min(take ?? remaining, remaining); + const records = Array.from({ length: amount }, () => ({ + id: `${name}-${counter++}`, + })); + + remaining -= amount; + + return Promise.resolve(records); + }), + delete: jest.fn().mockResolvedValue(undefined), }; + }; - mockObjectMetadataRepository.createQueryBuilder.mockReturnValue( - mockQueryBuilder, + const setObjectMetadataCache = ( + entries: Array<{ id: string; nameSingular: string }>, + ) => { + const byId = entries.reduce>( + (acc, { id, nameSingular }) => { + acc[id] = { + id, + nameSingular, + }; + + return acc; + }, + {}, ); - mockDataSource.query.mockResolvedValueOnce([{ count: '30000' }]); - mockDataSource.query.mockResolvedValueOnce([{ count: '20000' }]); + mockFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue( + { + flatObjectMetadataMaps: { + byId, + idByUniversalIdentifier: {}, + }, + }, + ); + }; + + it('should return deleted count when cleanup succeeds', async () => { + setObjectMetadataCache([ + { id: 'obj-company', nameSingular: 'company' }, + { id: 'obj-person', nameSingular: 'person' }, + ]); + + const companyRepository = createRepositoryMock('company', 2); + const personRepository = createRepositoryMock('person', 1); + + mockTwentyORMGlobalManager.getRepositoryForWorkspace + .mockResolvedValueOnce(companyRepository) + .mockResolvedValueOnce(personRepository); const result = await service.cleanupWorkspaceTrash({ workspaceId: 'workspace-id', - schemaName: 'workspace_test', trashRetentionDays: 14, }); - expect(result).toEqual(50000); - }); + expect(result).toEqual(3); + expect(companyRepository.find).toHaveBeenCalled(); + expect(personRepository.find).toHaveBeenCalled(); + expect(companyRepository.delete).toHaveBeenCalledTimes(1); + expect(personRepository.delete).toHaveBeenCalledTimes(1); - it('should return zero when no tables found', async () => { - const mockQueryBuilder = { - innerJoin: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - addSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getRawMany: jest.fn().mockResolvedValue([]), - }; + const findArgs = companyRepository.find.mock.calls[0][0]; - mockObjectMetadataRepository.createQueryBuilder.mockReturnValue( - mockQueryBuilder, - ); + expect(findArgs.withDeleted).toBe(true); + expect(findArgs.order).toEqual({ deletedAt: 'ASC' }); + }); + + it('should return zero when no objects are found', async () => { + setObjectMetadataCache([]); const result = await service.cleanupWorkspaceTrash({ workspaceId: 'workspace-id', - schemaName: 'workspace_test', trashRetentionDays: 14, }); expect(result).toEqual(0); + expect( + mockTwentyORMGlobalManager.getRepositoryForWorkspace, + ).not.toHaveBeenCalled(); }); - it('should throw when discovery fails', async () => { - const mockQueryBuilder = { - innerJoin: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - addSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getRawMany: jest.fn().mockRejectedValue(new Error('Database error')), - }; + it('should respect max records limit across objects', async () => { + mockConfigService.get.mockReturnValue(3); + (service as any).maxRecordsPerWorkspace = 3; + (service as any).batchSize = 3; + setObjectMetadataCache([ + { id: 'obj-company', nameSingular: 'company' }, + { id: 'obj-person', nameSingular: 'person' }, + ]); - mockObjectMetadataRepository.createQueryBuilder.mockReturnValue( - mockQueryBuilder, - ); + const companyRepository = createRepositoryMock('company', 2); + const personRepository = createRepositoryMock('person', 5); - await expect( - service.cleanupWorkspaceTrash({ - workspaceId: 'workspace-id', - schemaName: 'workspace_test', - trashRetentionDays: 14, - }), - ).rejects.toThrow('Database error'); + mockTwentyORMGlobalManager.getRepositoryForWorkspace + .mockResolvedValueOnce(companyRepository) + .mockResolvedValueOnce(personRepository); + + const result = await service.cleanupWorkspaceTrash({ + workspaceId: 'workspace-id', + trashRetentionDays: 14, + }); + + expect(result).toEqual(3); + expect(companyRepository.delete).toHaveBeenCalledTimes(1); + expect(personRepository.delete).toHaveBeenCalledTimes(1); + const personDeleteArgs = personRepository.delete.mock.calls[0][0]; + const deletedIds = + personDeleteArgs.id._value ?? personDeleteArgs.id.value; + + expect(deletedIds).toHaveLength(1); + expect(personRepository.find).toHaveBeenCalledTimes(1); }); - it('should respect max records limit across multiple tables', async () => { - const mockQueryBuilder = { - innerJoin: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - addSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getRawMany: jest.fn().mockResolvedValue([ - { nameSingular: 'company', isCustom: false }, - { nameSingular: 'person', isCustom: false }, - { nameSingular: 'opportunity', isCustom: false }, - ]), - }; + it('should ignore objects without soft deleted records', async () => { + setObjectMetadataCache([{ id: 'obj-company', nameSingular: 'company' }]); - mockObjectMetadataRepository.createQueryBuilder.mockReturnValue( - mockQueryBuilder, - ); + const companyRepository = createRepositoryMock('company', 0); - // Mock deletion results: 60k + 40k = 100k (hits limit, third table not processed) - mockDataSource.query - .mockResolvedValueOnce([{ count: '60000' }]) - .mockResolvedValueOnce([{ count: '40000' }]); + mockTwentyORMGlobalManager.getRepositoryForWorkspace.mockResolvedValueOnce( + companyRepository, + ); const result = await service.cleanupWorkspaceTrash({ workspaceId: 'workspace-id', - schemaName: 'workspace_test', trashRetentionDays: 14, }); - expect(result).toEqual(100000); - // Should only process 2 tables (company, person) and stop before opportunity - expect(mockDataSource.query).toHaveBeenCalledTimes(2); + expect(result).toEqual(0); + expect(companyRepository.delete).not.toHaveBeenCalled(); }); - it('should throw when deletion fails', async () => { - const mockQueryBuilder = { - innerJoin: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - addSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getRawMany: jest - .fn() - .mockResolvedValue([{ nameSingular: 'company', isCustom: false }]), - }; + it('should delete records across multiple batches', async () => { + setObjectMetadataCache([ + { id: 'obj-company', nameSingular: 'company' }, + ]); + + const companyRepository = createRepositoryMock('company', 5); - mockObjectMetadataRepository.createQueryBuilder.mockReturnValue( - mockQueryBuilder, + mockTwentyORMGlobalManager.getRepositoryForWorkspace.mockResolvedValueOnce( + companyRepository, ); - mockDataSource.query.mockRejectedValue(new Error('Deletion failed')); + (service as any).batchSize = 2; + (service as any).maxRecordsPerWorkspace = 10; - await expect( - service.cleanupWorkspaceTrash({ - workspaceId: 'workspace-id', - schemaName: 'workspace_test', - trashRetentionDays: 14, - }), - ).rejects.toThrow('Deletion failed'); + const result = await service.cleanupWorkspaceTrash({ + workspaceId: 'workspace-id', + trashRetentionDays: 14, + }); + + expect(result).toEqual(5); + expect(companyRepository.find).toHaveBeenCalledTimes(4); + expect(companyRepository.delete).toHaveBeenCalledTimes(3); }); + }); }); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts index e75ba0038cbd9..be43b6cc073c1 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts @@ -1,16 +1,14 @@ import { Injectable, Logger } from '@nestjs/common'; -import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository } from 'typeorm'; +import { isDefined } from 'twenty-shared/utils'; +import { In, LessThan } from 'typeorm'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; export type WorkspaceTrashCleanupInput = { workspaceId: string; - schemaName: string; trashRetentionDays: number; }; @@ -18,38 +16,69 @@ export type WorkspaceTrashCleanupInput = { export class WorkspaceTrashCleanupService { private readonly logger = new Logger(WorkspaceTrashCleanupService.name); private readonly maxRecordsPerWorkspace: number; + private readonly batchSize: number; constructor( - @InjectDataSource() - private readonly dataSource: DataSource, - @InjectRepository(ObjectMetadataEntity) - private readonly objectMetadataRepository: Repository, private readonly twentyConfigService: TwentyConfigService, + private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, ) { this.maxRecordsPerWorkspace = this.twentyConfigService.get( 'TRASH_CLEANUP_MAX_RECORDS_PER_WORKSPACE', ); + this.batchSize = this.twentyConfigService.get('TRASH_CLEANUP_BATCH_SIZE'); } async cleanupWorkspaceTrash( input: WorkspaceTrashCleanupInput, ): Promise { - const { workspaceId, schemaName, trashRetentionDays } = input; + const { workspaceId, trashRetentionDays } = input; + + const { flatObjectMetadataMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps'], + }, + ); - const tableNames = await this.discoverTablesWithSoftDelete(schemaName); + const objectNames = Object.values(flatObjectMetadataMaps.byId ?? {}) + .map((metadata) => metadata?.nameSingular) + .filter(isDefined); - if (tableNames.length === 0) { - this.logger.log(`No tables with deletedAt found in workspace ${workspaceId}`); + if (objectNames.length === 0) { + this.logger.log(`No objects found in workspace ${workspaceId}`); return 0; } - const deletedCount = await this.deleteSoftDeletedRecords( - workspaceId, - schemaName, - tableNames, - trashRetentionDays, - ); + const cutoffDate = this.calculateCutoffDate(trashRetentionDays); + let deletedCount = 0; + + for (const objectName of objectNames) { + if (deletedCount >= this.maxRecordsPerWorkspace) { + this.logger.log( + `Reached deletion limit (${this.maxRecordsPerWorkspace}) for workspace ${workspaceId}`, + ); + break; + } + + const remainingQuota = this.maxRecordsPerWorkspace - deletedCount; + const deletedForObject = await this.deleteSoftDeletedRecords({ + workspaceId, + objectName, + cutoffDate, + remainingQuota, + }); + + if (deletedForObject > 0) { + this.logger.log( + `Deleted ${deletedForObject} record(s) from ${objectName} in workspace ${workspaceId}`, + ); + } + + deletedCount += deletedForObject; + } this.logger.log( `Deleted ${deletedCount} record(s) from workspace ${workspaceId}`, @@ -58,67 +87,56 @@ export class WorkspaceTrashCleanupService { return deletedCount; } - private async discoverTablesWithSoftDelete( - schemaName: string, - ): Promise { - const rawResults = await this.objectMetadataRepository - .createQueryBuilder('object') - .innerJoin('object.dataSource', 'dataSource') - .select('object.nameSingular', 'nameSingular') - .addSelect('object.isCustom', 'isCustom') - .where('dataSource.schema = :schemaName', { schemaName }) - .andWhere('object.isActive = :isActive', { isActive: true }) - .andWhere((qb) => { - const subQuery = qb - .subQuery() - .select('1') - .from(FieldMetadataEntity, 'field') - .where('field.objectMetadataId = object.id') - .andWhere('field.name = :fieldName', { fieldName: 'deletedAt' }) - .andWhere('field.isActive = :isActive', { isActive: true }) - .getQuery(); - - return `EXISTS ${subQuery}`; - }) - .getRawMany(); - - return rawResults.map((row) => - computeObjectTargetTable({ - nameSingular: row.nameSingular, - isCustom: row.isCustom, - }), - ); - } + private async deleteSoftDeletedRecords({ + workspaceId, + objectName, + cutoffDate, + remainingQuota, + }: { + workspaceId: string; + objectName: string; + cutoffDate: Date; + remainingQuota: number; + }): Promise { + if (remainingQuota <= 0) { + return 0; + } - private async deleteSoftDeletedRecords( - workspaceId: string, - schemaName: string, - tableNames: string[], - trashRetentionDays: number, - ): Promise { - let totalDeleted = 0; - const cutoffDate = this.calculateCutoffDate(trashRetentionDays); + const repository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + objectName, + { shouldBypassPermissionChecks: true }, + ); - for (const tableName of tableNames) { - if (totalDeleted >= this.maxRecordsPerWorkspace) { - this.logger.log( - `Reached deletion limit (${this.maxRecordsPerWorkspace}) for workspace ${workspaceId}`, - ); + let deleted = 0; + + while (deleted < remainingQuota) { + const take = Math.min(this.batchSize, remainingQuota - deleted); + + const recordsToDelete = await repository.find({ + withDeleted: true, + select: ['id'], + where: { + deletedAt: LessThan(cutoffDate), + }, + order: { deletedAt: 'ASC' }, + take, + loadEagerRelations: false, + }); + + if (recordsToDelete.length === 0) { break; } - const remainingQuota = this.maxRecordsPerWorkspace - totalDeleted; - const deleted = await this.deleteFromTable( - schemaName, - tableName, - cutoffDate, - remainingQuota, - ); + await repository.delete({ + id: In(recordsToDelete.map((record) => record.id)), + }); - totalDeleted += deleted; + deleted += recordsToDelete.length; } - return totalDeleted; + return deleted; } private calculateCutoffDate(trashRetentionDays: number): Date { @@ -129,30 +147,4 @@ export class WorkspaceTrashCleanupService { return cutoffDate; } - - private async deleteFromTable( - schemaName: string, - tableName: string, - cutoffDate: Date, - limit: number, - ): Promise { - const result = await this.dataSource.query( - ` - WITH deleted AS ( - DELETE FROM "${schemaName}"."${tableName}" - WHERE ctid IN ( - SELECT ctid FROM "${schemaName}"."${tableName}" - WHERE "deletedAt" IS NOT NULL - AND "deletedAt" < $1 - LIMIT $2 - ) - RETURNING 1 - ) - SELECT COUNT(*) as count FROM deleted - `, - [cutoffDate, limit], - ); - - return parseInt(result[0]?.count || '0', 10); - } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts index 80a38151b5abb..88bf18b748ce5 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts @@ -2,8 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; -import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { WorkspaceTrashCleanupCronCommand } from 'src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command'; import { WorkspaceTrashCleanupCronJob } from 'src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job'; import { WorkspaceTrashCleanupJob } from 'src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job'; @@ -11,8 +10,8 @@ import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/works @Module({ imports: [ - TypeOrmModule.forFeature([Workspace, ObjectMetadataEntity]), - DataSourceModule, + TypeOrmModule.forFeature([Workspace]), + WorkspaceManyOrAllFlatEntityMapsCacheModule, ], providers: [ WorkspaceTrashCleanupService, From e7e4eb463e5e055149c74e520b259ffd37e46f4e Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Fri, 10 Oct 2025 01:19:34 +0500 Subject: [PATCH 15/18] chore: move the trash-cleanup one folder up --- .../commands/cron-register-all.command.ts | 8 +++---- .../commands/database-command.module.ts | 4 ++-- .../engine/core-modules/core-engine.module.ts | 2 ++ .../commands/trash-cleanup.cron.command.ts} | 12 +++++----- .../constants/trash-cleanup.constants.ts | 2 ++ .../crons/trash-cleanup.cron.job.ts} | 23 ++++++++---------- .../jobs/trash-cleanup.job.ts} | 18 +++++++------- .../__tests__/trash-cleanup.service.spec.ts} | 17 +++++-------- .../services/trash-cleanup.service.ts} | 10 ++++---- .../trash-cleanup/trash-cleanup.module.ts | 24 +++++++++++++++++++ .../workspace-manager.module.ts | 3 --- .../workspace-trash-cleanup.constants.ts | 2 -- .../workspace-trash-cleanup.module.ts | 24 ------------------- 13 files changed, 68 insertions(+), 81 deletions(-) rename packages/twenty-server/src/engine/{workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command.ts => trash-cleanup/commands/trash-cleanup.cron.command.ts} (60%) create mode 100644 packages/twenty-server/src/engine/trash-cleanup/constants/trash-cleanup.constants.ts rename packages/twenty-server/src/engine/{workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts => trash-cleanup/crons/trash-cleanup.cron.job.ts} (79%) rename packages/twenty-server/src/engine/{workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job.ts => trash-cleanup/jobs/trash-cleanup.job.ts} (57%) rename packages/twenty-server/src/engine/{workspace-manager/workspace-trash-cleanup/services/__tests__/workspace-trash-cleanup.service.spec.ts => trash-cleanup/services/__tests__/trash-cleanup.service.spec.ts} (93%) rename packages/twenty-server/src/engine/{workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts => trash-cleanup/services/trash-cleanup.service.ts} (93%) create mode 100644 packages/twenty-server/src/engine/trash-cleanup/trash-cleanup.module.ts delete mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants.ts delete mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts diff --git a/packages/twenty-server/src/database/commands/cron-register-all.command.ts b/packages/twenty-server/src/database/commands/cron-register-all.command.ts index eb79091b96eec..17e9e547648c2 100644 --- a/packages/twenty-server/src/database/commands/cron-register-all.command.ts +++ b/packages/twenty-server/src/database/commands/cron-register-all.command.ts @@ -7,7 +7,7 @@ import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-module import { CronTriggerCronCommand } from 'src/engine/metadata-modules/cron-trigger/crons/commands/cron-trigger.cron.command'; import { CleanOnboardingWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command'; import { CleanSuspendedWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-suspended-workspaces.cron.command'; -import { WorkspaceTrashCleanupCronCommand } from 'src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command'; +import { TrashCleanupCronCommand } from 'src/engine/trash-cleanup/commands/trash-cleanup.cron.command'; import { CalendarEventListFetchCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-event-list-fetch.cron.command'; import { CalendarEventsImportCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-import.cron.command'; import { CalendarOngoingStaleCronCommand } from 'src/modules/calendar/calendar-event-import-manager/crons/commands/calendar-ongoing-stale.cron.command'; @@ -43,7 +43,7 @@ export class CronRegisterAllCommand extends CommandRunner { private readonly cronTriggerCronCommand: CronTriggerCronCommand, private readonly cleanSuspendedWorkspacesCronCommand: CleanSuspendedWorkspacesCronCommand, private readonly cleanOnboardingWorkspacesCronCommand: CleanOnboardingWorkspacesCronCommand, - private readonly workspaceTrashCleanupCronCommand: WorkspaceTrashCleanupCronCommand, + private readonly trashCleanupCronCommand: TrashCleanupCronCommand, ) { super(); } @@ -113,8 +113,8 @@ export class CronRegisterAllCommand extends CommandRunner { command: this.cleanOnboardingWorkspacesCronCommand, }, { - name: 'WorkspaceTrashCleanup', - command: this.workspaceTrashCleanupCronCommand, + name: 'TrashCleanup', + command: this.trashCleanupCronCommand, }, ]; diff --git a/packages/twenty-server/src/database/commands/database-command.module.ts b/packages/twenty-server/src/database/commands/database-command.module.ts index 53b6af7eafd9f..9207f9fab88ed 100644 --- a/packages/twenty-server/src/database/commands/database-command.module.ts +++ b/packages/twenty-server/src/database/commands/database-command.module.ts @@ -20,8 +20,8 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { DevSeederModule } from 'src/engine/workspace-manager/dev-seeder/dev-seeder.module'; import { WorkspaceCleanerModule } from 'src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module'; +import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; -import { WorkspaceTrashCleanupModule } from 'src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module'; import { CalendarEventImportManagerModule } from 'src/modules/calendar/calendar-event-import-manager/calendar-event-import-manager.module'; import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module'; import { WorkflowRunQueueModule } from 'src/modules/workflow/workflow-runner/workflow-run-queue/workflow-run-queue.module'; @@ -51,7 +51,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au CronTriggerModule, DatabaseEventTriggerModule, WorkspaceCleanerModule, - WorkspaceTrashCleanupModule, + TrashCleanupModule, PublicDomainModule, ], providers: [ diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index a926644360180..861ec658ebf86 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -57,6 +57,7 @@ import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.mod import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module'; import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module'; +import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module'; import { AuditModule } from './audit/audit.module'; import { ClientConfigModule } from './client-config/client-config.module'; @@ -132,6 +133,7 @@ import { FileModule } from './file/file.module'; WebhookModule, PageLayoutModule, ImpersonationModule, + TrashCleanupModule, ], exports: [ AuditModule, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command.ts b/packages/twenty-server/src/engine/trash-cleanup/commands/trash-cleanup.cron.command.ts similarity index 60% rename from packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command.ts rename to packages/twenty-server/src/engine/trash-cleanup/commands/trash-cleanup.cron.command.ts index 26b6e0f0d7f6d..ced36075ef443 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command.ts +++ b/packages/twenty-server/src/engine/trash-cleanup/commands/trash-cleanup.cron.command.ts @@ -3,14 +3,14 @@ import { Command, CommandRunner } from 'nest-commander'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; -import { WORKSPACE_TRASH_CLEANUP_CRON_PATTERN } from 'src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants'; -import { WorkspaceTrashCleanupCronJob } from 'src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job'; +import { TRASH_CLEANUP_CRON_PATTERN } from 'src/engine/trash-cleanup/constants/trash-cleanup.constants'; +import { TrashCleanupCronJob } from 'src/engine/trash-cleanup/crons/trash-cleanup.cron.job'; @Command({ - name: 'cron:workspace:cleanup-trash', + name: 'cron:trash-cleanup', description: 'Starts a cron job to clean up soft-deleted records', }) -export class WorkspaceTrashCleanupCronCommand extends CommandRunner { +export class TrashCleanupCronCommand extends CommandRunner { constructor( @InjectMessageQueue(MessageQueue.cronQueue) private readonly messageQueueService: MessageQueueService, @@ -20,11 +20,11 @@ export class WorkspaceTrashCleanupCronCommand extends CommandRunner { async run(): Promise { await this.messageQueueService.addCron({ - jobName: WorkspaceTrashCleanupCronJob.name, + jobName: TrashCleanupCronJob.name, data: undefined, options: { repeat: { - pattern: WORKSPACE_TRASH_CLEANUP_CRON_PATTERN, + pattern: TRASH_CLEANUP_CRON_PATTERN, }, }, }); diff --git a/packages/twenty-server/src/engine/trash-cleanup/constants/trash-cleanup.constants.ts b/packages/twenty-server/src/engine/trash-cleanup/constants/trash-cleanup.constants.ts new file mode 100644 index 0000000000000..a77b8cace3e23 --- /dev/null +++ b/packages/twenty-server/src/engine/trash-cleanup/constants/trash-cleanup.constants.ts @@ -0,0 +1,2 @@ +// Daily at 00:10 UTC +export const TRASH_CLEANUP_CRON_PATTERN = '10 0 * * *'; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts b/packages/twenty-server/src/engine/trash-cleanup/crons/trash-cleanup.cron.job.ts similarity index 79% rename from packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts rename to packages/twenty-server/src/engine/trash-cleanup/crons/trash-cleanup.cron.job.ts index 0f3751472560c..41c67bd7f2488 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job.ts +++ b/packages/twenty-server/src/engine/trash-cleanup/crons/trash-cleanup.cron.job.ts @@ -12,16 +12,16 @@ import { Processor } from 'src/engine/core-modules/message-queue/decorators/proc import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { WORKSPACE_TRASH_CLEANUP_CRON_PATTERN } from 'src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants'; +import { TRASH_CLEANUP_CRON_PATTERN } from 'src/engine/trash-cleanup/constants/trash-cleanup.constants'; import { - WorkspaceTrashCleanupJob, - type WorkspaceTrashCleanupJobData, -} from 'src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job'; + TrashCleanupJob, + type TrashCleanupJobData, +} from 'src/engine/trash-cleanup/jobs/trash-cleanup.job'; @Injectable() @Processor(MessageQueue.cronQueue) -export class WorkspaceTrashCleanupCronJob { - private readonly logger = new Logger(WorkspaceTrashCleanupCronJob.name); +export class TrashCleanupCronJob { + private readonly logger = new Logger(TrashCleanupCronJob.name); constructor( @InjectRepository(Workspace) @@ -31,11 +31,8 @@ export class WorkspaceTrashCleanupCronJob { private readonly exceptionHandlerService: ExceptionHandlerService, ) {} - @Process(WorkspaceTrashCleanupCronJob.name) - @SentryCronMonitor( - WorkspaceTrashCleanupCronJob.name, - WORKSPACE_TRASH_CLEANUP_CRON_PATTERN, - ) + @Process(TrashCleanupCronJob.name) + @SentryCronMonitor(TrashCleanupCronJob.name, TRASH_CLEANUP_CRON_PATTERN) async handle(): Promise { const workspaces = await this.getActiveWorkspaces(); @@ -51,8 +48,8 @@ export class WorkspaceTrashCleanupCronJob { for (const workspace of workspaces) { try { - await this.messageQueueService.add( - WorkspaceTrashCleanupJob.name, + await this.messageQueueService.add( + TrashCleanupJob.name, { workspaceId: workspace.id, trashRetentionDays: workspace.trashRetentionDays, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job.ts b/packages/twenty-server/src/engine/trash-cleanup/jobs/trash-cleanup.job.ts similarity index 57% rename from packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job.ts rename to packages/twenty-server/src/engine/trash-cleanup/jobs/trash-cleanup.job.ts index a80927b965acf..b5b7200632c5e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job.ts +++ b/packages/twenty-server/src/engine/trash-cleanup/jobs/trash-cleanup.job.ts @@ -3,28 +3,26 @@ import { Injectable, Logger } from '@nestjs/common'; import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; -import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service'; +import { TrashCleanupService } from 'src/engine/trash-cleanup/services/trash-cleanup.service'; -export type WorkspaceTrashCleanupJobData = { +export type TrashCleanupJobData = { workspaceId: string; trashRetentionDays: number; }; @Injectable() @Processor(MessageQueue.workspaceQueue) -export class WorkspaceTrashCleanupJob { - private readonly logger = new Logger(WorkspaceTrashCleanupJob.name); +export class TrashCleanupJob { + private readonly logger = new Logger(TrashCleanupJob.name); - constructor( - private readonly workspaceTrashCleanupService: WorkspaceTrashCleanupService, - ) {} + constructor(private readonly trashCleanupService: TrashCleanupService) {} - @Process(WorkspaceTrashCleanupJob.name) - async handle(data: WorkspaceTrashCleanupJobData): Promise { + @Process(TrashCleanupJob.name) + async handle(data: TrashCleanupJobData): Promise { const { workspaceId, trashRetentionDays } = data; try { - await this.workspaceTrashCleanupService.cleanupWorkspaceTrash({ + await this.trashCleanupService.cleanupWorkspaceTrash({ workspaceId, trashRetentionDays, }); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/__tests__/workspace-trash-cleanup.service.spec.ts b/packages/twenty-server/src/engine/trash-cleanup/services/__tests__/trash-cleanup.service.spec.ts similarity index 93% rename from packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/__tests__/workspace-trash-cleanup.service.spec.ts rename to packages/twenty-server/src/engine/trash-cleanup/services/__tests__/trash-cleanup.service.spec.ts index bbba28b096c80..c2041c059cf3c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/__tests__/workspace-trash-cleanup.service.spec.ts +++ b/packages/twenty-server/src/engine/trash-cleanup/services/__tests__/trash-cleanup.service.spec.ts @@ -3,10 +3,10 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service'; +import { TrashCleanupService } from 'src/engine/trash-cleanup/services/trash-cleanup.service'; -describe('WorkspaceTrashCleanupService', () => { - let service: WorkspaceTrashCleanupService; +describe('TrashCleanupService', () => { + let service: TrashCleanupService; let mockFlatEntityMapsCacheService: any; let mockTwentyORMGlobalManager: any; let mockConfigService: { get: jest.Mock }; @@ -26,7 +26,7 @@ describe('WorkspaceTrashCleanupService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - WorkspaceTrashCleanupService, + TrashCleanupService, { provide: TwentyConfigService, useValue: mockConfigService, @@ -42,9 +42,7 @@ describe('WorkspaceTrashCleanupService', () => { ], }).compile(); - service = module.get( - WorkspaceTrashCleanupService, - ); + service = module.get(TrashCleanupService); // Suppress logger output in tests jest.spyOn(service['logger'], 'log').mockImplementation(); @@ -197,9 +195,7 @@ describe('WorkspaceTrashCleanupService', () => { }); it('should delete records across multiple batches', async () => { - setObjectMetadataCache([ - { id: 'obj-company', nameSingular: 'company' }, - ]); + setObjectMetadataCache([{ id: 'obj-company', nameSingular: 'company' }]); const companyRepository = createRepositoryMock('company', 5); @@ -219,6 +215,5 @@ describe('WorkspaceTrashCleanupService', () => { expect(companyRepository.find).toHaveBeenCalledTimes(4); expect(companyRepository.delete).toHaveBeenCalledTimes(3); }); - }); }); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts b/packages/twenty-server/src/engine/trash-cleanup/services/trash-cleanup.service.ts similarity index 93% rename from packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts rename to packages/twenty-server/src/engine/trash-cleanup/services/trash-cleanup.service.ts index be43b6cc073c1..333e4a4b1d621 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service.ts +++ b/packages/twenty-server/src/engine/trash-cleanup/services/trash-cleanup.service.ts @@ -7,14 +7,14 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/core-mo import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -export type WorkspaceTrashCleanupInput = { +export type TrashCleanupInput = { workspaceId: string; trashRetentionDays: number; }; @Injectable() -export class WorkspaceTrashCleanupService { - private readonly logger = new Logger(WorkspaceTrashCleanupService.name); +export class TrashCleanupService { + private readonly logger = new Logger(TrashCleanupService.name); private readonly maxRecordsPerWorkspace: number; private readonly batchSize: number; @@ -29,9 +29,7 @@ export class WorkspaceTrashCleanupService { this.batchSize = this.twentyConfigService.get('TRASH_CLEANUP_BATCH_SIZE'); } - async cleanupWorkspaceTrash( - input: WorkspaceTrashCleanupInput, - ): Promise { + async cleanupWorkspaceTrash(input: TrashCleanupInput): Promise { const { workspaceId, trashRetentionDays } = input; const { flatObjectMetadataMaps } = diff --git a/packages/twenty-server/src/engine/trash-cleanup/trash-cleanup.module.ts b/packages/twenty-server/src/engine/trash-cleanup/trash-cleanup.module.ts new file mode 100644 index 0000000000000..81887424525ab --- /dev/null +++ b/packages/twenty-server/src/engine/trash-cleanup/trash-cleanup.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { TrashCleanupCronCommand } from 'src/engine/trash-cleanup/commands/trash-cleanup.cron.command'; +import { TrashCleanupCronJob } from 'src/engine/trash-cleanup/crons/trash-cleanup.cron.job'; +import { TrashCleanupJob } from 'src/engine/trash-cleanup/jobs/trash-cleanup.job'; +import { TrashCleanupService } from 'src/engine/trash-cleanup/services/trash-cleanup.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Workspace]), + WorkspaceManyOrAllFlatEntityMapsCacheModule, + ], + providers: [ + TrashCleanupService, + TrashCleanupJob, + TrashCleanupCronJob, + TrashCleanupCronCommand, + ], + exports: [TrashCleanupCronCommand], +}) +export class TrashCleanupModule {} 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 aaf911959aee8..95764eb8a9844 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 @@ -22,8 +22,6 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp import { WorkspaceManagerService } from './workspace-manager.service'; -import { WorkspaceTrashCleanupModule } from './workspace-trash-cleanup/workspace-trash-cleanup.module'; - @Module({ imports: [ WorkspaceDataSourceModule, @@ -34,7 +32,6 @@ import { WorkspaceTrashCleanupModule } from './workspace-trash-cleanup/workspace DataSourceModule, WorkspaceSyncMetadataModule, WorkspaceHealthModule, - WorkspaceTrashCleanupModule, FeatureFlagModule, PermissionsModule, AgentModule, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants.ts deleted file mode 100644 index bdfa481032398..0000000000000 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/constants/workspace-trash-cleanup.constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Daily at 00:10 UTC -export const WORKSPACE_TRASH_CLEANUP_CRON_PATTERN = '10 0 * * *'; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts deleted file mode 100644 index 88bf18b748ce5..0000000000000 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-trash-cleanup/workspace-trash-cleanup.module.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.module'; -import { WorkspaceTrashCleanupCronCommand } from 'src/engine/workspace-manager/workspace-trash-cleanup/commands/workspace-trash-cleanup.cron.command'; -import { WorkspaceTrashCleanupCronJob } from 'src/engine/workspace-manager/workspace-trash-cleanup/crons/workspace-trash-cleanup.cron.job'; -import { WorkspaceTrashCleanupJob } from 'src/engine/workspace-manager/workspace-trash-cleanup/jobs/workspace-trash-cleanup.job'; -import { WorkspaceTrashCleanupService } from 'src/engine/workspace-manager/workspace-trash-cleanup/services/workspace-trash-cleanup.service'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([Workspace]), - WorkspaceManyOrAllFlatEntityMapsCacheModule, - ], - providers: [ - WorkspaceTrashCleanupService, - WorkspaceTrashCleanupJob, - WorkspaceTrashCleanupCronJob, - WorkspaceTrashCleanupCronCommand, - ], - exports: [WorkspaceTrashCleanupCronCommand], -}) -export class WorkspaceTrashCleanupModule {} From 2df8dbd895370cfe4094eb2ba1db0bed3960d36d Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Fri, 10 Oct 2025 01:27:10 +0500 Subject: [PATCH 16/18] fix: regenerate graphql metadata --- .../src/generated-metadata/graphql.ts | 188 +++++++++--------- 1 file changed, 92 insertions(+), 96 deletions(-) diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 3824062eeb06c..3515199239f85 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -5566,7 +5566,7 @@ export type BillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscri export type CurrentBillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null, views?: Array<{ __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, fieldMetadataId: string, isVisible: boolean, fieldValue: string, position: number, viewId: string }> }> | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; export type WorkspaceUrlsFragmentFragment = { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }; @@ -5585,7 +5585,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null, views?: Array<{ __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, fieldMetadataId: string, isVisible: boolean, fieldValue: string, position: number, viewId: string }> }> | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: string } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; export type ViewFieldFragmentFragment = { __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }; @@ -6307,96 +6307,6 @@ export const RoleFragmentFragmentDoc = gql` canBeAssignedToApiKeys } `; -export const ViewFieldFragmentFragmentDoc = gql` - fragment ViewFieldFragment on CoreViewField { - id - fieldMetadataId - viewId - isVisible - position - size - aggregateOperation - createdAt - updatedAt - deletedAt -} - `; -export const ViewFilterFragmentFragmentDoc = gql` - fragment ViewFilterFragment on CoreViewFilter { - id - fieldMetadataId - operand - value - viewFilterGroupId - positionInViewFilterGroup - subFieldName - viewId -} - `; -export const ViewFilterGroupFragmentFragmentDoc = gql` - fragment ViewFilterGroupFragment on CoreViewFilterGroup { - id - parentViewFilterGroupId - logicalOperator - positionInViewFilterGroup - viewId -} - `; -export const ViewSortFragmentFragmentDoc = gql` - fragment ViewSortFragment on CoreViewSort { - id - fieldMetadataId - direction - viewId -} - `; -export const ViewGroupFragmentFragmentDoc = gql` - fragment ViewGroupFragment on CoreViewGroup { - id - fieldMetadataId - isVisible - fieldValue - position - viewId -} - `; -export const ViewFragmentFragmentDoc = gql` - fragment ViewFragment on CoreView { - id - name - objectMetadataId - type - key - icon - position - isCompact - openRecordIn - kanbanAggregateOperation - kanbanAggregateOperationFieldMetadataId - anyFieldFilterValue - calendarFieldMetadataId - calendarLayout - viewFields { - ...ViewFieldFragment - } - viewFilters { - ...ViewFilterFragment - } - viewFilterGroups { - ...ViewFilterGroupFragment - } - viewSorts { - ...ViewSortFragment - } - viewGroups { - ...ViewGroupFragment - } -} - ${ViewFieldFragmentFragmentDoc} -${ViewFilterFragmentFragmentDoc} -${ViewFilterGroupFragmentFragmentDoc} -${ViewSortFragmentFragmentDoc} -${ViewGroupFragmentFragmentDoc}`; export const AvailableWorkspaceFragmentFragmentDoc = gql` fragment AvailableWorkspaceFragment on AvailableWorkspace { id @@ -6496,9 +6406,6 @@ export const UserQueryFragmentFragmentDoc = gql` } isTwoFactorAuthenticationEnforced trashRetentionDays - views { - ...ViewFragment - } } availableWorkspaces { ...AvailableWorkspacesFragment @@ -6513,8 +6420,97 @@ ${WorkspaceUrlsFragmentFragmentDoc} ${CurrentBillingSubscriptionFragmentFragmentDoc} ${BillingSubscriptionFragmentFragmentDoc} ${RoleFragmentFragmentDoc} -${ViewFragmentFragmentDoc} ${AvailableWorkspacesFragmentFragmentDoc}`; +export const ViewFieldFragmentFragmentDoc = gql` + fragment ViewFieldFragment on CoreViewField { + id + fieldMetadataId + viewId + isVisible + position + size + aggregateOperation + createdAt + updatedAt + deletedAt +} + `; +export const ViewFilterFragmentFragmentDoc = gql` + fragment ViewFilterFragment on CoreViewFilter { + id + fieldMetadataId + operand + value + viewFilterGroupId + positionInViewFilterGroup + subFieldName + viewId +} + `; +export const ViewFilterGroupFragmentFragmentDoc = gql` + fragment ViewFilterGroupFragment on CoreViewFilterGroup { + id + parentViewFilterGroupId + logicalOperator + positionInViewFilterGroup + viewId +} + `; +export const ViewSortFragmentFragmentDoc = gql` + fragment ViewSortFragment on CoreViewSort { + id + fieldMetadataId + direction + viewId +} + `; +export const ViewGroupFragmentFragmentDoc = gql` + fragment ViewGroupFragment on CoreViewGroup { + id + fieldMetadataId + isVisible + fieldValue + position + viewId +} + `; +export const ViewFragmentFragmentDoc = gql` + fragment ViewFragment on CoreView { + id + name + objectMetadataId + type + key + icon + position + isCompact + openRecordIn + kanbanAggregateOperation + kanbanAggregateOperationFieldMetadataId + anyFieldFilterValue + calendarFieldMetadataId + calendarLayout + viewFields { + ...ViewFieldFragment + } + viewFilters { + ...ViewFilterFragment + } + viewFilterGroups { + ...ViewFilterGroupFragment + } + viewSorts { + ...ViewSortFragment + } + viewGroups { + ...ViewGroupFragment + } +} + ${ViewFieldFragmentFragmentDoc} +${ViewFilterFragmentFragmentDoc} +${ViewFilterGroupFragmentFragmentDoc} +${ViewSortFragmentFragmentDoc} +${ViewGroupFragmentFragmentDoc}`; export const WorkflowDiffFragmentFragmentDoc = gql` fragment WorkflowDiffFragment on WorkflowVersionStepChanges { triggerDiff From 491ba1c187f25b697688b416ca617f894f44450c Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Mon, 13 Oct 2025 16:31:14 +0500 Subject: [PATCH 17/18] fix: move batch size and total deletion to constants --- packages/twenty-server/.env.example | 4 +--- .../twenty-config/config-variables.ts | 19 ------------------- .../constants/trash-cleanup.constants.ts | 3 +++ .../__tests__/trash-cleanup.service.spec.ts | 11 ----------- .../services/trash-cleanup.service.ts | 18 ++++++++---------- 5 files changed, 12 insertions(+), 43 deletions(-) diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index b5a279329f9f2..59f019fdf3892 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -78,6 +78,4 @@ FRONTEND_URL=http://localhost:3001 # CLOUDFLARE_WEBHOOK_SECRET= # IS_CONFIG_VARIABLES_IN_DB_ENABLED=false # ANALYTICS_ENABLED= -# CLICKHOUSE_URL=http://default:clickhousePassword@localhost:8123/twenty -# TRASH_CLEANUP_MAX_RECORDS_PER_WORKSPACE=100000 -# TRASH_CLEANUP_BATCH_SIZE=1000 +# CLICKHOUSE_URL=http://default:clickhousePassword@localhost:8123/twenty \ No newline at end of file diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 7c14398542cad..d67240d023e4b 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -1129,25 +1129,6 @@ export class ConfigVariables { @ValidateIf((env) => env.MAX_NUMBER_OF_WORKSPACES_DELETED_PER_EXECUTION > 0) MAX_NUMBER_OF_WORKSPACES_DELETED_PER_EXECUTION = 5; - @ConfigVariablesMetadata({ - group: ConfigVariablesGroup.Other, - description: - 'Maximum number of records to delete per workspace during trash cleanup', - type: ConfigVariableType.NUMBER, - }) - @CastToPositiveNumber() - @IsOptional() - TRASH_CLEANUP_MAX_RECORDS_PER_WORKSPACE = 100000; - - @ConfigVariablesMetadata({ - group: ConfigVariablesGroup.Other, - description: 'Number of records deleted per batch during trash cleanup', - type: ConfigVariableType.NUMBER, - }) - @CastToPositiveNumber() - @IsOptional() - TRASH_CLEANUP_BATCH_SIZE = 1000; - @ConfigVariablesMetadata({ group: ConfigVariablesGroup.RateLimiting, description: 'Throttle limit for workflow execution', diff --git a/packages/twenty-server/src/engine/trash-cleanup/constants/trash-cleanup.constants.ts b/packages/twenty-server/src/engine/trash-cleanup/constants/trash-cleanup.constants.ts index a77b8cace3e23..ba74a0839dbb7 100644 --- a/packages/twenty-server/src/engine/trash-cleanup/constants/trash-cleanup.constants.ts +++ b/packages/twenty-server/src/engine/trash-cleanup/constants/trash-cleanup.constants.ts @@ -1,2 +1,5 @@ // Daily at 00:10 UTC export const TRASH_CLEANUP_CRON_PATTERN = '10 0 * * *'; + +export const TRASH_CLEANUP_MAX_RECORDS_PER_WORKSPACE = 1_000_000; +export const TRASH_CLEANUP_BATCH_SIZE = 1_000; diff --git a/packages/twenty-server/src/engine/trash-cleanup/services/__tests__/trash-cleanup.service.spec.ts b/packages/twenty-server/src/engine/trash-cleanup/services/__tests__/trash-cleanup.service.spec.ts index c2041c059cf3c..fc5324ccc0359 100644 --- a/packages/twenty-server/src/engine/trash-cleanup/services/__tests__/trash-cleanup.service.spec.ts +++ b/packages/twenty-server/src/engine/trash-cleanup/services/__tests__/trash-cleanup.service.spec.ts @@ -1,6 +1,5 @@ import { Test, type TestingModule } from '@nestjs/testing'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { TrashCleanupService } from 'src/engine/trash-cleanup/services/trash-cleanup.service'; @@ -9,7 +8,6 @@ describe('TrashCleanupService', () => { let service: TrashCleanupService; let mockFlatEntityMapsCacheService: any; let mockTwentyORMGlobalManager: any; - let mockConfigService: { get: jest.Mock }; beforeEach(async () => { mockFlatEntityMapsCacheService = { @@ -20,17 +18,9 @@ describe('TrashCleanupService', () => { getRepositoryForWorkspace: jest.fn(), }; - mockConfigService = { - get: jest.fn().mockReturnValue(100000), - }; - const module: TestingModule = await Test.createTestingModule({ providers: [ TrashCleanupService, - { - provide: TwentyConfigService, - useValue: mockConfigService, - }, { provide: WorkspaceManyOrAllFlatEntityMapsCacheService, useValue: mockFlatEntityMapsCacheService, @@ -145,7 +135,6 @@ describe('TrashCleanupService', () => { }); it('should respect max records limit across objects', async () => { - mockConfigService.get.mockReturnValue(3); (service as any).maxRecordsPerWorkspace = 3; (service as any).batchSize = 3; setObjectMetadataCache([ diff --git a/packages/twenty-server/src/engine/trash-cleanup/services/trash-cleanup.service.ts b/packages/twenty-server/src/engine/trash-cleanup/services/trash-cleanup.service.ts index 333e4a4b1d621..7b2135ebdfb59 100644 --- a/packages/twenty-server/src/engine/trash-cleanup/services/trash-cleanup.service.ts +++ b/packages/twenty-server/src/engine/trash-cleanup/services/trash-cleanup.service.ts @@ -4,8 +4,11 @@ import { isDefined } from 'twenty-shared/utils'; import { In, LessThan } from 'typeorm'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.service'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { + TRASH_CLEANUP_BATCH_SIZE, + TRASH_CLEANUP_MAX_RECORDS_PER_WORKSPACE, +} from 'src/engine/trash-cleanup/constants/trash-cleanup.constants'; export type TrashCleanupInput = { workspaceId: string; @@ -15,19 +18,14 @@ export type TrashCleanupInput = { @Injectable() export class TrashCleanupService { private readonly logger = new Logger(TrashCleanupService.name); - private readonly maxRecordsPerWorkspace: number; - private readonly batchSize: number; + private readonly maxRecordsPerWorkspace = + TRASH_CLEANUP_MAX_RECORDS_PER_WORKSPACE; + private readonly batchSize = TRASH_CLEANUP_BATCH_SIZE; constructor( - private readonly twentyConfigService: TwentyConfigService, private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly twentyORMGlobalManager: TwentyORMGlobalManager, - ) { - this.maxRecordsPerWorkspace = this.twentyConfigService.get( - 'TRASH_CLEANUP_MAX_RECORDS_PER_WORKSPACE', - ); - this.batchSize = this.twentyConfigService.get('TRASH_CLEANUP_BATCH_SIZE'); - } + ) {} async cleanupWorkspaceTrash(input: TrashCleanupInput): Promise { const { workspaceId, trashRetentionDays } = input; From 8abe94034f14107e08655d2984add19b451ea1f8 Mon Sep 17 00:00:00 2001 From: Abdullah <125115953+mabdullahabaid@users.noreply.github.com> Date: Mon, 13 Oct 2025 17:31:08 +0500 Subject: [PATCH 18/18] fix: remove database constraint and resolve imports per the updated path --- ... => 1760356369619-add-workspace-trash-retention.ts} | 10 ++-------- .../engine/core-modules/workspace/workspace.entity.ts | 1 - .../services/__tests__/trash-cleanup.service.spec.ts | 4 ++-- .../trash-cleanup/services/trash-cleanup.service.ts | 4 ++-- .../src/engine/trash-cleanup/trash-cleanup.module.ts | 2 +- 5 files changed, 7 insertions(+), 14 deletions(-) rename packages/twenty-server/src/database/typeorm/core/migrations/common/{1759182953990-add-workspace-trash-retention.ts => 1760356369619-add-workspace-trash-retention.ts} (56%) diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1759182953990-add-workspace-trash-retention.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1760356369619-add-workspace-trash-retention.ts similarity index 56% rename from packages/twenty-server/src/database/typeorm/core/migrations/common/1759182953990-add-workspace-trash-retention.ts rename to packages/twenty-server/src/database/typeorm/core/migrations/common/1760356369619-add-workspace-trash-retention.ts index b6ad7594bba33..2e4704e19ae5f 100644 --- a/packages/twenty-server/src/database/typeorm/core/migrations/common/1759182953990-add-workspace-trash-retention.ts +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1760356369619-add-workspace-trash-retention.ts @@ -1,23 +1,17 @@ import { type MigrationInterface, type QueryRunner } from 'typeorm'; -export class AddWorkspaceTrashRetention1759182953990 +export class AddWorkspaceTrashRetention1760356369619 implements MigrationInterface { - name = 'AddWorkspaceTrashRetention1759182953990'; + name = 'AddWorkspaceTrashRetention1760356369619'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "core"."workspace" ADD "trashRetentionDays" integer NOT NULL DEFAULT '14'`, ); - await queryRunner.query( - `ALTER TABLE "core"."workspace" ADD CONSTRAINT "trash_retention_positive" CHECK ("trashRetentionDays" >= 0)`, - ); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `ALTER TABLE "core"."workspace" DROP CONSTRAINT "trash_retention_positive"`, - ); await queryRunner.query( `ALTER TABLE "core"."workspace" DROP COLUMN "trashRetentionDays"`, ); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index 705aa340d8fa8..a94f002121180 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -52,7 +52,6 @@ registerEnumType(WorkspaceActivationStatus, { 'onboarded_workspace_requires_default_role', `"activationStatus" IN ('PENDING_CREATION', 'ONGOING_CREATION') OR "defaultRoleId" IS NOT NULL`, ) -@Check('trash_retention_positive', '"trashRetentionDays" >= 0') @Entity({ name: 'workspace', schema: 'core' }) @ObjectType() export class Workspace { diff --git a/packages/twenty-server/src/engine/trash-cleanup/services/__tests__/trash-cleanup.service.spec.ts b/packages/twenty-server/src/engine/trash-cleanup/services/__tests__/trash-cleanup.service.spec.ts index fc5324ccc0359..b597dc8d78f79 100644 --- a/packages/twenty-server/src/engine/trash-cleanup/services/__tests__/trash-cleanup.service.spec.ts +++ b/packages/twenty-server/src/engine/trash-cleanup/services/__tests__/trash-cleanup.service.spec.ts @@ -1,8 +1,8 @@ import { Test, type TestingModule } from '@nestjs/testing'; -import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.service'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { TrashCleanupService } from 'src/engine/trash-cleanup/services/trash-cleanup.service'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; describe('TrashCleanupService', () => { let service: TrashCleanupService; diff --git a/packages/twenty-server/src/engine/trash-cleanup/services/trash-cleanup.service.ts b/packages/twenty-server/src/engine/trash-cleanup/services/trash-cleanup.service.ts index 7b2135ebdfb59..1d044f75f7bb1 100644 --- a/packages/twenty-server/src/engine/trash-cleanup/services/trash-cleanup.service.ts +++ b/packages/twenty-server/src/engine/trash-cleanup/services/trash-cleanup.service.ts @@ -3,12 +3,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; import { In, LessThan } from 'typeorm'; -import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.service'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { TRASH_CLEANUP_BATCH_SIZE, TRASH_CLEANUP_MAX_RECORDS_PER_WORKSPACE, } from 'src/engine/trash-cleanup/constants/trash-cleanup.constants'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; export type TrashCleanupInput = { workspaceId: string; diff --git a/packages/twenty-server/src/engine/trash-cleanup/trash-cleanup.module.ts b/packages/twenty-server/src/engine/trash-cleanup/trash-cleanup.module.ts index 81887424525ab..477cdd228b0dd 100644 --- a/packages/twenty-server/src/engine/trash-cleanup/trash-cleanup.module.ts +++ b/packages/twenty-server/src/engine/trash-cleanup/trash-cleanup.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { TrashCleanupCronCommand } from 'src/engine/trash-cleanup/commands/trash-cleanup.cron.command'; import { TrashCleanupCronJob } from 'src/engine/trash-cleanup/crons/trash-cleanup.cron.job'; import { TrashCleanupJob } from 'src/engine/trash-cleanup/jobs/trash-cleanup.job';