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)
This commit is contained in:
Weiko
2025-03-26 14:35:11 +01:00
committed by GitHub
parent 72b4b26e2c
commit 16cb768c5c
9 changed files with 225 additions and 4 deletions

View File

@ -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, {

View File

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

View File

@ -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<Workspace>,
) {
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<string[]> {
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<void> {
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,
);
}
}

View File

@ -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<void> {
await this.messageQueueService.addCron<undefined>({
jobName: CleanOnboardingWorkspacesJob.name,
data: undefined,
options: {
repeat: { pattern: cleanOnboardingWorkspacesCronPattern },
},
});
}
}

View File

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

View File

@ -0,0 +1 @@
export const cleanOnboardingWorkspacesCronPattern = '0 * * * *'; // Every hour at minute 0

View File

@ -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<Workspace>,
) {}
@Process(CleanOnboardingWorkspacesJob.name)
@SentryCronMonitor(
CleanOnboardingWorkspacesJob.name,
cleanOnboardingWorkspacesCronPattern,
)
async handle(): Promise<void> {
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),
);
}
}

View File

@ -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<BillingSubscription>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
) {
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<void> {
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,

View File

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