From 16cb768c5c97a158d9b7c920b1b5f4959b660f49 Mon Sep 17 00:00:00 2001 From: Weiko Date: Wed, 26 Mar 2025 14:35:11 +0100 Subject: [PATCH] Do not suspend onboarding workspaces after stripe hook and add cron to delete them after 7 days (#11189) ## Context When a trial ends, we receive a webhook from stripe to switch a workspace from its previous status to SUSPENDED. We also have some workspaces in ONGOING_CREATION that started the flow, started the trial but did not finish completely the flow which means the workspace has no data/metadata/schema. Because of this, we can have workspaces that switch from ONGOING_CREATION to SUSPENDED directly and ONGOING_CREATION workspaces that didn't start the trial can stay in this state forever since they are not cleaned up by the current cleaner. To solve those 2 issues, I'm adding a new cron that will automatically clean ONGOING_CREATION workspaces that are older than 7 days and I'm updating the stripe webhook to only update the activationStatus to SUSPENDED if it's already ACTIVE (then it will be deleted by the other cron in the other case) --- .../billing-webhook-subscription.service.ts | 3 +- .../core-modules/message-queue/jobs.module.ts | 2 + .../clean-onboarding-workspaces.command.ts | 81 +++++++++++++++++++ ...lean-onboarding-workspaces.cron.command.ts | 30 +++++++ .../clean-suspended-workspaces.command.ts | 2 +- ...lean-onboarding-workspaces.cron.pattern.ts | 1 + .../crons/clean-onboarding-workspaces.job.ts | 48 +++++++++++ .../services/cleaner.workspace-service.ts | 52 +++++++++++- .../workspace-cleaner.module.ts | 10 ++- 9 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.command.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-onboarding-workspaces.cron.pattern.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-onboarding-workspaces.job.ts diff --git a/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service.ts index 9fca680bc..c0b58ff48 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service.ts @@ -4,8 +4,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import Stripe from 'stripe'; -import { Repository } from 'typeorm'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { Repository } from 'typeorm'; import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; @@ -121,6 +121,7 @@ export class BillingWebhookSubscriptionService { BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS[ WorkspaceActivationStatus.SUSPENDED ].includes(data.object.status as SubscriptionStatus) && + workspace.activationStatus == WorkspaceActivationStatus.ACTIVE && !hasActiveWorkspaceCompatibleSubscription ) { await this.workspaceRepository.update(workspaceId, { diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts index 41e569002..d7b8c87ff 100644 --- a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts +++ b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts @@ -19,6 +19,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; +import { CleanOnboardingWorkspacesJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-onboarding-workspaces.job'; import { CleanSuspendedWorkspacesJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-suspended-workspaces.job'; import { CleanWorkspaceDeletionWarningUserVarsJob } from 'src/engine/workspace-manager/workspace-cleaner/jobs/clean-workspace-deletion-warning-user-vars.job'; import { WorkspaceCleanerModule } from 'src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module'; @@ -60,6 +61,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module'; ], providers: [ CleanSuspendedWorkspacesJob, + CleanOnboardingWorkspacesJob, EmailSenderJob, UpdateSubscriptionQuantityJob, HandleWorkspaceMemberDeletedJob, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.command.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.command.ts new file mode 100644 index 000000000..83e3c43cd --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.command.ts @@ -0,0 +1,81 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command, Option } from 'nest-commander'; +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { In, LessThan, Repository } from 'typeorm'; + +import { + MigrationCommandOptions, + MigrationCommandRunner, +} from 'src/database/commands/command-runners/migration.command-runner'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { CleanerWorkspaceService } from 'src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service'; + +@Command({ + name: 'workspace:clean:onboarding', + description: 'Clean onboarding workspaces', +}) +export class CleanOnboardingWorkspacesCommand extends MigrationCommandRunner { + private workspaceIds: string[] = []; + + constructor( + private readonly cleanerWorkspaceService: CleanerWorkspaceService, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, + ) { + super(); + } + + @Option({ + flags: '-w, --workspace-id [workspace_id]', + description: + 'workspace id. Command runs on all onboarding workspaces if not provided', + required: false, + }) + parseWorkspaceId(val: string): string[] { + this.workspaceIds.push(val); + + return this.workspaceIds; + } + + async fetchOnboardingWorkspaceIds(): Promise { + const sevenDaysAgo = new Date(); + + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + + const onboardingWorkspaces = await this.workspaceRepository.find({ + select: ['id'], + where: { + activationStatus: In([ + WorkspaceActivationStatus.PENDING_CREATION, + WorkspaceActivationStatus.ONGOING_CREATION, + ]), + createdAt: LessThan(sevenDaysAgo), + }, + withDeleted: true, + }); + + return onboardingWorkspaces.map((workspace) => workspace.id); + } + + override async runMigrationCommand( + _passedParams: string[], + options: MigrationCommandOptions, + ): Promise { + const { dryRun } = options; + + const onboardingWorkspaceIds = + this.workspaceIds.length > 0 + ? this.workspaceIds + : await this.fetchOnboardingWorkspaceIds(); + + this.logger.log( + `${dryRun ? 'DRY RUN - ' : ''}Cleaning ${onboardingWorkspaceIds.length} onboarding workspaces`, + ); + + await this.cleanerWorkspaceService.batchCleanOnboardingWorkspaces( + onboardingWorkspaceIds, + dryRun, + ); + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command.ts new file mode 100644 index 000000000..a6f0c234b --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command.ts @@ -0,0 +1,30 @@ +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 { cleanOnboardingWorkspacesCronPattern } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-onboarding-workspaces.cron.pattern'; +import { CleanOnboardingWorkspacesJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-onboarding-workspaces.job'; + +@Command({ + name: 'cron:clean-onboarding-workspaces', + description: 'Starts a cron job to clean onboarding workspaces', +}) +export class CleanOnboardingWorkspacesCronCommand extends CommandRunner { + constructor( + @InjectMessageQueue(MessageQueue.cronQueue) + private readonly messageQueueService: MessageQueueService, + ) { + super(); + } + + async run(): Promise { + await this.messageQueueService.addCron({ + jobName: CleanOnboardingWorkspacesJob.name, + data: undefined, + options: { + repeat: { pattern: cleanOnboardingWorkspacesCronPattern }, + }, + }); + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/clean-suspended-workspaces.command.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/clean-suspended-workspaces.command.ts index 0f1b6e83a..e7b2afe0f 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/clean-suspended-workspaces.command.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/clean-suspended-workspaces.command.ts @@ -1,8 +1,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Command, Option } from 'nest-commander'; -import { In, Repository } from 'typeorm'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { In, Repository } from 'typeorm'; import { MigrationCommandOptions, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-onboarding-workspaces.cron.pattern.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-onboarding-workspaces.cron.pattern.ts new file mode 100644 index 000000000..8e7638c91 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-onboarding-workspaces.cron.pattern.ts @@ -0,0 +1 @@ +export const cleanOnboardingWorkspacesCronPattern = '0 * * * *'; // Every hour at minute 0 diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-onboarding-workspaces.job.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-onboarding-workspaces.job.ts new file mode 100644 index 000000000..58f0fd65e --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-onboarding-workspaces.job.ts @@ -0,0 +1,48 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { In, LessThan, Repository } from 'typeorm'; + +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 } from 'src/engine/core-modules/workspace/workspace.entity'; +import { cleanOnboardingWorkspacesCronPattern } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-onboarding-workspaces.cron.pattern'; +import { CleanerWorkspaceService } from 'src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service'; + +@Processor(MessageQueue.cronQueue) +export class CleanOnboardingWorkspacesJob { + constructor( + private readonly cleanerWorkspaceService: CleanerWorkspaceService, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, + ) {} + + @Process(CleanOnboardingWorkspacesJob.name) + @SentryCronMonitor( + CleanOnboardingWorkspacesJob.name, + cleanOnboardingWorkspacesCronPattern, + ) + async handle(): Promise { + const sevenDaysAgo = new Date(); + + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + + const onboardingWorkspaces = await this.workspaceRepository.find({ + select: ['id'], + where: { + activationStatus: In([ + WorkspaceActivationStatus.PENDING_CREATION, + WorkspaceActivationStatus.ONGOING_CREATION, + ]), + createdAt: LessThan(sevenDaysAgo), + }, + withDeleted: true, + }); + + await this.cleanerWorkspaceService.batchCleanOnboardingWorkspaces( + onboardingWorkspaces.map((workspace) => workspace.id), + ); + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts index d8a8058de..765bbae4b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts @@ -9,13 +9,14 @@ import { CleanSuspendedWorkspaceEmail, WarnSuspendedWorkspaceEmail, } from 'twenty-emails'; -import { In, Repository } from 'typeorm'; import { isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { In, Repository } from 'typeorm'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service'; import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; @@ -47,6 +48,8 @@ export class CleanerWorkspaceService { @InjectRepository(BillingSubscription, 'core') private readonly billingSubscriptionRepository: Repository, private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + @InjectRepository(UserWorkspace, 'core') + private readonly userWorkspaceRepository: Repository, ) { this.inactiveDaysBeforeSoftDelete = this.environmentService.get( 'WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION', @@ -256,6 +259,53 @@ export class CleanerWorkspaceService { ); } + async batchCleanOnboardingWorkspaces( + workspaceIds: string[], + dryRun = false, + ): Promise { + this.logger.log( + `${dryRun ? 'DRY RUN - ' : ''}batchCleanOnboardingWorkspaces running...`, + ); + + const workspaces = await this.workspaceRepository.find({ + where: { + id: In(workspaceIds), + activationStatus: In([ + WorkspaceActivationStatus.PENDING_CREATION, + WorkspaceActivationStatus.ONGOING_CREATION, + ]), + }, + withDeleted: true, + }); + + if (workspaces.length !== 0) { + if (!dryRun) { + for (const workspace of workspaces) { + const userWorkspaces = await this.userWorkspaceRepository.find({ + where: { + workspaceId: workspace.id, + }, + withDeleted: true, + }); + + for (const userWorkspace of userWorkspaces) { + await this.workspaceService.handleRemoveWorkspaceMember( + workspace.id, + userWorkspace.userId, + false, + ); + } + + await this.workspaceRepository.delete(workspace.id); + } + } + + this.logger.log( + `${dryRun ? 'DRY RUN - ' : ''}batchCleanOnboardingWorkspaces done with ${workspaces.length} workspaces!`, + ); + } + } + async batchWarnOrCleanSuspendedWorkspaces( workspaceIds: string[], dryRun = false, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module.ts index ee74d5e6b..d51c51256 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module.ts @@ -4,11 +4,14 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { EmailModule } from 'src/engine/core-modules/email/email.module'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module'; import { UserModule } from 'src/engine/core-modules/user/user.module'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; +import { CleanOnboardingWorkspacesCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.command'; +import { CleanOnboardingWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command'; import { CleanSuspendedWorkspacesCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-suspended-workspaces.command'; import { CleanSuspendedWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-suspended-workspaces.cron.command'; import { DeleteWorkspacesCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/delete-workspaces.command'; @@ -16,7 +19,10 @@ import { CleanerWorkspaceService } from 'src/engine/workspace-manager/workspace- @Module({ imports: [ - TypeOrmModule.forFeature([Workspace, BillingSubscription], 'core'), + TypeOrmModule.forFeature( + [Workspace, UserWorkspace, BillingSubscription], + 'core', + ), WorkspaceModule, DataSourceModule, UserVarsModule, @@ -28,6 +34,8 @@ import { CleanerWorkspaceService } from 'src/engine/workspace-manager/workspace- DeleteWorkspacesCommand, CleanSuspendedWorkspacesCommand, CleanSuspendedWorkspacesCronCommand, + CleanOnboardingWorkspacesCommand, + CleanOnboardingWorkspacesCronCommand, CleanerWorkspaceService, ], exports: [CleanerWorkspaceService],