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],