add command to run cleaning suspended workspaces job (#9895)

Co-authored-by: etiennejouan <jouan.etienne@gmail.com>
This commit is contained in:
Etienne
2025-01-29 19:30:46 +01:00
committed by GitHub
parent 4edeb7f991
commit 1b3181b14e
6 changed files with 344 additions and 269 deletions

View File

@ -22,6 +22,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
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';
import { CalendarEventParticipantManagerModule } from 'src/modules/calendar/calendar-event-participant-manager/calendar-event-participant-manager.module';
import { CalendarModule } from 'src/modules/calendar/calendar.module';
import { AutoCompaniesAndContactsCreationJobModule } from 'src/modules/contact-creation-manager/jobs/auto-companies-and-contacts-creation-job.module';
@ -56,6 +57,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
WebhookJobModule,
WorkflowModule,
FavoriteModule,
WorkspaceCleanerModule,
],
providers: [
CleanSuspendedWorkspacesJob,

View File

@ -0,0 +1,33 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command';
import { BaseCommandOptions } from 'src/database/commands/base.command';
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',
description: 'Clean suspended workspace',
})
export class CleanSuspendedWorkspacesCommand extends ActiveWorkspacesCommandRunner {
constructor(
private readonly cleanerWorkspaceService: CleanerWorkspaceService,
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
_options: BaseCommandOptions,
workspaceIds: string[],
): Promise<void> {
await this.cleanerWorkspaceService.batchWarnOrCleanSuspendedWorkspaces(
workspaceIds,
);
}
}

View File

@ -1,289 +1,33 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { render } from '@react-email/components';
import chunk from 'lodash.chunk';
import {
CleanSuspendedWorkspaceEmail,
WarnSuspendedWorkspaceEmail,
} from 'twenty-emails';
import { WorkspaceActivationStatus } from 'twenty-shared';
import { 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 { 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 { 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';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { USER_WORKSPACE_DELETION_WARNING_SENT_KEY } from 'src/engine/workspace-manager/workspace-cleaner/constants/user-workspace-deletion-warning-sent-key.constant';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
const MILLISECONDS_IN_ONE_DAY = 1000 * 3600 * 24;
import { CleanerWorkspaceService } from 'src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service';
@Processor(MessageQueue.cronQueue)
export class CleanSuspendedWorkspacesJob {
private readonly logger = new Logger(CleanSuspendedWorkspacesJob.name);
private readonly inactiveDaysBeforeDelete: number;
private readonly inactiveDaysBeforeWarn: number;
private readonly maxNumberOfWorkspacesDeletedPerExecution: number;
constructor(
private readonly workspaceService: WorkspaceService,
private readonly environmentService: EnvironmentService,
private readonly userService: UserService,
private readonly userVarsService: UserVarsService,
private readonly emailService: EmailService,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
private readonly cleanerWorkspaceService: CleanerWorkspaceService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {
this.inactiveDaysBeforeDelete = this.environmentService.get(
'WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION',
);
this.inactiveDaysBeforeWarn = this.environmentService.get(
'WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION',
);
this.maxNumberOfWorkspacesDeletedPerExecution = this.environmentService.get(
'MAX_NUMBER_OF_WORKSPACES_DELETED_PER_EXECUTION',
);
}
async computeWorkspaceBillingInactivity(
workspace: Workspace,
): Promise<number | null> {
try {
const lastSubscription =
await this.billingSubscriptionRepository.findOneOrFail({
where: { workspaceId: workspace.id },
order: { updatedAt: 'DESC' },
});
const daysSinceBillingInactivity = Math.floor(
(new Date().getTime() - lastSubscription.updatedAt.getTime()) /
MILLISECONDS_IN_ONE_DAY,
);
return daysSinceBillingInactivity;
} catch {
this.logger.error(
`No billing subscription found for workspace ${workspace.id} ${workspace.displayName}`,
);
return null;
}
}
async checkIfWorkspaceMembersWarned(
workspaceMembers: WorkspaceMemberWorkspaceEntity[],
workspaceId: string,
) {
for (const workspaceMember of workspaceMembers) {
const workspaceMemberWarned =
(await this.userVarsService.get({
userId: workspaceMember.userId,
workspaceId: workspaceId,
key: USER_WORKSPACE_DELETION_WARNING_SENT_KEY,
})) === true;
if (workspaceMemberWarned) {
return true;
}
}
return false;
}
async sendWarningEmail(
workspaceMember: WorkspaceMemberWorkspaceEntity,
workspaceDisplayName: string | undefined,
daysSinceInactive: number,
) {
const emailData = {
daysSinceInactive,
inactiveDaysBeforeDelete: this.inactiveDaysBeforeDelete,
userName: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
workspaceDisplayName: `${workspaceDisplayName}`,
};
const emailTemplate = WarnSuspendedWorkspaceEmail(emailData);
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
this.emailService.send({
to: workspaceMember.userEmail,
bcc: this.environmentService.get('EMAIL_SYSTEM_ADDRESS'),
from: `${this.environmentService.get(
'EMAIL_FROM_NAME',
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
subject: 'Action needed to prevent workspace deletion',
html,
text,
});
}
async warnWorkspaceMembers(workspace: Workspace, daysSinceInactive: number) {
const workspaceMembers =
await this.userService.loadWorkspaceMembers(workspace);
const workspaceMembersWarned = await this.checkIfWorkspaceMembersWarned(
workspaceMembers,
workspace.id,
);
if (workspaceMembersWarned) {
this.logger.log(
`Workspace ${workspace.id} ${workspace.displayName} already warned`,
);
return;
} else {
this.logger.log(
`Sending ${workspace.id} ${
workspace.displayName
} suspended since ${daysSinceInactive} days emails to users ['${workspaceMembers
.map((workspaceUser) => workspaceUser.userEmail)
.join(', ')}']`,
);
const workspaceMembersChunks = chunk(workspaceMembers, 5);
for (const workspaceMembersChunk of workspaceMembersChunks) {
await Promise.all(
workspaceMembersChunk.map(async (workspaceMember) => {
await this.userVarsService.set({
userId: workspaceMember.userId,
workspaceId: workspace.id,
key: USER_WORKSPACE_DELETION_WARNING_SENT_KEY,
value: true,
});
await this.sendWarningEmail(
workspaceMember,
workspace.displayName,
daysSinceInactive,
);
}),
);
}
return;
}
}
async sendCleaningEmail(
workspaceMember: WorkspaceMemberWorkspaceEntity,
workspaceDisplayName: string | undefined,
) {
const emailData = {
inactiveDaysBeforeDelete: this.inactiveDaysBeforeDelete,
userName: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
workspaceDisplayName: `${workspaceDisplayName}`,
};
const emailTemplate = CleanSuspendedWorkspaceEmail(emailData);
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
this.emailService.send({
to: workspaceMember.userEmail,
bcc: this.environmentService.get('EMAIL_SYSTEM_ADDRESS'),
from: `${this.environmentService.get(
'EMAIL_FROM_NAME',
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
subject: 'Your workspace has been deleted',
html,
text,
});
}
async informWorkspaceMembersAndDeleteWorkspace(workspace: Workspace) {
const workspaceMembers =
await this.userService.loadWorkspaceMembers(workspace);
this.logger.log(
`Sending workspace ${workspace.id} ${
workspace.displayName
} deletion emails to users ['${workspaceMembers
.map((workspaceUser) => workspaceUser.userEmail)
.join(', ')}']`,
);
const workspaceMembersChunks = chunk(workspaceMembers, 5);
for (const workspaceMembersChunk of workspaceMembersChunks) {
await Promise.all(
workspaceMembersChunk.map(async (workspaceMember) => {
await this.userVarsService.delete({
userId: workspaceMember.userId,
workspaceId: workspace.id,
key: USER_WORKSPACE_DELETION_WARNING_SENT_KEY,
});
await this.sendCleaningEmail(workspaceMember, workspace.displayName);
}),
);
}
await this.workspaceService.deleteWorkspace(workspace.id);
this.logger.log(
`Cleaning Workspace ${workspace.id} ${workspace.displayName}`,
);
}
) {}
@Process(CleanSuspendedWorkspacesJob.name)
async handle(): Promise<void> {
this.logger.log(`Job running...`);
const suspendedWorkspaces = await this.workspaceRepository.find({
where: { activationStatus: WorkspaceActivationStatus.SUSPENDED },
const suspendedWorkspaceIds = await this.workspaceRepository.find({
select: ['id'],
where: {
activationStatus: WorkspaceActivationStatus.SUSPENDED,
},
});
const suspendedWorkspacesChunks = chunk(suspendedWorkspaces, 5);
let deletedWorkspacesCount = 0;
for (const suspendedWorkspacesChunk of suspendedWorkspacesChunks) {
await Promise.all(
suspendedWorkspacesChunk.map(async (workspace) => {
const workspaceInactivity =
await this.computeWorkspaceBillingInactivity(workspace);
if (
workspaceInactivity &&
workspaceInactivity > this.inactiveDaysBeforeDelete &&
deletedWorkspacesCount <=
this.maxNumberOfWorkspacesDeletedPerExecution
) {
await this.informWorkspaceMembersAndDeleteWorkspace(workspace);
deletedWorkspacesCount++;
return;
}
if (
workspaceInactivity &&
workspaceInactivity > this.inactiveDaysBeforeWarn &&
workspaceInactivity <= this.inactiveDaysBeforeDelete
) {
await this.warnWorkspaceMembers(workspace, workspaceInactivity);
return;
}
}),
);
}
this.logger.log(`Job done!`);
await this.cleanerWorkspaceService.batchWarnOrCleanSuspendedWorkspaces(
suspendedWorkspaceIds.map((workspace) => workspace.id),
);
}
}

View File

@ -0,0 +1,12 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkspaceCleanerException extends CustomException {
code: WorkspaceCleanerExceptionCode;
constructor(message: string, code: WorkspaceCleanerExceptionCode) {
super(message, code);
}
}
export enum WorkspaceCleanerExceptionCode {
BILLING_SUBSCRIPTION_NOT_FOUND = 'BILLING_SUBSCRIPTION_NOT_FOUND',
}

View File

@ -0,0 +1,267 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { render } from '@react-email/components';
import {
CleanSuspendedWorkspaceEmail,
WarnSuspendedWorkspaceEmail,
} from 'twenty-emails';
import { WorkspaceActivationStatus } from 'twenty-shared';
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 { 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';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { USER_WORKSPACE_DELETION_WARNING_SENT_KEY } from 'src/engine/workspace-manager/workspace-cleaner/constants/user-workspace-deletion-warning-sent-key.constant';
import {
WorkspaceCleanerException,
WorkspaceCleanerExceptionCode,
} from 'src/engine/workspace-manager/workspace-cleaner/exceptions/workspace-cleaner.exception';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
const MILLISECONDS_IN_ONE_DAY = 1000 * 3600 * 24;
@Injectable()
export class CleanerWorkspaceService {
private readonly logger = new Logger(CleanerWorkspaceService.name);
private readonly inactiveDaysBeforeDelete: number;
private readonly inactiveDaysBeforeWarn: number;
private readonly maxNumberOfWorkspacesDeletedPerExecution: number;
constructor(
private readonly workspaceService: WorkspaceService,
private readonly environmentService: EnvironmentService,
private readonly userVarsService: UserVarsService,
private readonly userService: UserService,
private readonly emailService: EmailService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
) {
this.inactiveDaysBeforeDelete = this.environmentService.get(
'WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION',
);
this.inactiveDaysBeforeWarn = this.environmentService.get(
'WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION',
);
this.maxNumberOfWorkspacesDeletedPerExecution = this.environmentService.get(
'MAX_NUMBER_OF_WORKSPACES_DELETED_PER_EXECUTION',
);
}
async computeWorkspaceBillingInactivity(
workspace: Workspace,
): Promise<number> {
try {
const lastSubscription =
await this.billingSubscriptionRepository.findOneOrFail({
where: { workspaceId: workspace.id },
order: { updatedAt: 'DESC' },
});
const daysSinceBillingInactivity = Math.floor(
(new Date().getTime() - lastSubscription.updatedAt.getTime()) /
MILLISECONDS_IN_ONE_DAY,
);
return daysSinceBillingInactivity;
} catch {
throw new WorkspaceCleanerException(
`No billing subscription found for workspace ${workspace.id} ${workspace.displayName}`,
WorkspaceCleanerExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND,
);
}
}
async checkIfAtLeastOneWorkspaceMemberWarned(
workspaceMembers: WorkspaceMemberWorkspaceEntity[],
workspaceId: string,
) {
for (const workspaceMember of workspaceMembers) {
const workspaceMemberWarned = await this.userVarsService.get({
userId: workspaceMember.userId,
workspaceId: workspaceId,
key: USER_WORKSPACE_DELETION_WARNING_SENT_KEY,
});
if (workspaceMemberWarned) {
return true;
}
}
return false;
}
async sendWarningEmail(
workspaceMember: WorkspaceMemberWorkspaceEntity,
workspaceDisplayName: string | undefined,
daysSinceInactive: number,
) {
const emailData = {
daysSinceInactive,
inactiveDaysBeforeDelete: this.inactiveDaysBeforeDelete,
userName: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
workspaceDisplayName: `${workspaceDisplayName}`,
};
const emailTemplate = WarnSuspendedWorkspaceEmail(emailData);
const html = render(emailTemplate, { pretty: true });
const text = render(emailTemplate, { plainText: true });
this.emailService.send({
to: workspaceMember.userEmail,
bcc: this.environmentService.get('EMAIL_SYSTEM_ADDRESS'),
from: `${this.environmentService.get(
'EMAIL_FROM_NAME',
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
subject: 'Action needed to prevent workspace deletion',
html,
text,
});
}
async warnWorkspaceMembers(workspace: Workspace, daysSinceInactive: number) {
const workspaceMembers =
await this.userService.loadWorkspaceMembers(workspace);
const workspaceMembersWarned =
await this.checkIfAtLeastOneWorkspaceMemberWarned(
workspaceMembers,
workspace.id,
);
if (workspaceMembersWarned) {
this.logger.log(
`Workspace ${workspace.id} ${workspace.displayName} already warned`,
);
return;
}
this.logger.log(
`Sending ${workspace.id} ${
workspace.displayName
} suspended since ${daysSinceInactive} days emails to users ['${workspaceMembers
.map((workspaceUser) => workspaceUser.id)
.join(', ')}']`,
);
for (const workspaceMember of workspaceMembers) {
await this.userVarsService.set({
userId: workspaceMember.userId,
workspaceId: workspace.id,
key: USER_WORKSPACE_DELETION_WARNING_SENT_KEY,
value: true,
});
await this.sendWarningEmail(
workspaceMember,
workspace.displayName,
daysSinceInactive,
);
}
}
async sendCleaningEmail(
workspaceMember: WorkspaceMemberWorkspaceEntity,
workspaceDisplayName: string,
) {
const emailData = {
inactiveDaysBeforeDelete: this.inactiveDaysBeforeDelete,
userName: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
workspaceDisplayName,
};
const emailTemplate = CleanSuspendedWorkspaceEmail(emailData);
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
this.emailService.send({
to: workspaceMember.userEmail,
bcc: this.environmentService.get('EMAIL_SYSTEM_ADDRESS'),
from: `${this.environmentService.get(
'EMAIL_FROM_NAME',
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
subject: 'Your workspace has been deleted',
html,
text,
});
}
async informWorkspaceMembersAndDeleteWorkspace(workspace: Workspace) {
const workspaceMembers =
await this.userService.loadWorkspaceMembers(workspace);
this.logger.log(
`Sending workspace ${workspace.id} ${
workspace.displayName
} deletion emails to users ['${workspaceMembers
.map((workspaceUser) => workspaceUser.id)
.join(', ')}']`,
);
for (const workspaceMember of workspaceMembers) {
await this.userVarsService.delete({
userId: workspaceMember.userId,
workspaceId: workspace.id,
key: USER_WORKSPACE_DELETION_WARNING_SENT_KEY,
});
await this.sendCleaningEmail(
workspaceMember,
workspace.displayName || '',
);
}
await this.workspaceService.deleteWorkspace(workspace.id);
this.logger.log(
`Cleaning Workspace ${workspace.id} ${workspace.displayName}`,
);
}
async batchWarnOrCleanSuspendedWorkspaces(
workspaceIds: string[],
): Promise<void> {
this.logger.log(`batchWarnOrCleanSuspendedWorkspaces running...`);
const workspaces = await this.workspaceRepository.find({
where: {
id: In(workspaceIds),
activationStatus: WorkspaceActivationStatus.SUSPENDED,
},
});
let deletedWorkspacesCount = 0;
for (const workspace of workspaces) {
const workspaceInactivity =
await this.computeWorkspaceBillingInactivity(workspace);
if (
workspaceInactivity &&
workspaceInactivity > this.inactiveDaysBeforeDelete &&
deletedWorkspacesCount <= this.maxNumberOfWorkspacesDeletedPerExecution
) {
await this.informWorkspaceMembersAndDeleteWorkspace(workspace);
deletedWorkspacesCount++;
continue;
}
if (
workspaceInactivity &&
workspaceInactivity > this.inactiveDaysBeforeWarn &&
workspaceInactivity <= this.inactiveDaysBeforeDelete
) {
await this.warnWorkspaceMembers(workspace, workspaceInactivity);
}
}
this.logger.log(`batchWarnOrCleanSuspendedWorkspaces done!`);
}
}

View File

@ -1,18 +1,35 @@
import { Module } from '@nestjs/common';
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 { 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 { 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';
import { CleanerWorkspaceService } from 'src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace], 'core'),
TypeOrmModule.forFeature([Workspace, BillingSubscription], 'core'),
WorkspaceModule,
DataSourceModule,
UserVarsModule,
UserModule,
EmailModule,
BillingModule,
],
providers: [DeleteWorkspacesCommand, CleanSuspendedWorkspacesCronCommand],
providers: [
DeleteWorkspacesCommand,
CleanSuspendedWorkspacesCommand,
CleanSuspendedWorkspacesCronCommand,
CleanerWorkspaceService,
],
exports: [CleanerWorkspaceService],
})
export class WorkspaceCleanerModule {}