add command to run cleaning suspended workspaces job (#9895)
Co-authored-by: etiennejouan <jouan.etienne@gmail.com>
This commit is contained in:
@ -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 { 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 { 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 { 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 { CalendarEventParticipantManagerModule } from 'src/modules/calendar/calendar-event-participant-manager/calendar-event-participant-manager.module';
|
||||||
import { CalendarModule } from 'src/modules/calendar/calendar.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';
|
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,
|
WebhookJobModule,
|
||||||
WorkflowModule,
|
WorkflowModule,
|
||||||
FavoriteModule,
|
FavoriteModule,
|
||||||
|
WorkspaceCleanerModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
CleanSuspendedWorkspacesJob,
|
CleanSuspendedWorkspacesJob,
|
||||||
|
|||||||
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,289 +1,33 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
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 { WorkspaceActivationStatus } from 'twenty-shared';
|
||||||
import { Repository } from 'typeorm';
|
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 { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.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 { 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 { 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 { CleanerWorkspaceService } from 'src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service';
|
||||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
|
||||||
|
|
||||||
const MILLISECONDS_IN_ONE_DAY = 1000 * 3600 * 24;
|
|
||||||
|
|
||||||
@Processor(MessageQueue.cronQueue)
|
@Processor(MessageQueue.cronQueue)
|
||||||
export class CleanSuspendedWorkspacesJob {
|
export class CleanSuspendedWorkspacesJob {
|
||||||
private readonly logger = new Logger(CleanSuspendedWorkspacesJob.name);
|
|
||||||
private readonly inactiveDaysBeforeDelete: number;
|
|
||||||
private readonly inactiveDaysBeforeWarn: number;
|
|
||||||
private readonly maxNumberOfWorkspacesDeletedPerExecution: number;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly workspaceService: WorkspaceService,
|
private readonly cleanerWorkspaceService: CleanerWorkspaceService,
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
private readonly userService: UserService,
|
|
||||||
private readonly userVarsService: UserVarsService,
|
|
||||||
private readonly emailService: EmailService,
|
|
||||||
@InjectRepository(BillingSubscription, 'core')
|
|
||||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
|
||||||
@InjectRepository(Workspace, 'core')
|
@InjectRepository(Workspace, 'core')
|
||||||
private readonly workspaceRepository: Repository<Workspace>,
|
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)
|
@Process(CleanSuspendedWorkspacesJob.name)
|
||||||
async handle(): Promise<void> {
|
async handle(): Promise<void> {
|
||||||
this.logger.log(`Job running...`);
|
const suspendedWorkspaceIds = await this.workspaceRepository.find({
|
||||||
|
select: ['id'],
|
||||||
const suspendedWorkspaces = await this.workspaceRepository.find({
|
where: {
|
||||||
where: { activationStatus: WorkspaceActivationStatus.SUSPENDED },
|
activationStatus: WorkspaceActivationStatus.SUSPENDED,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const suspendedWorkspacesChunks = chunk(suspendedWorkspaces, 5);
|
await this.cleanerWorkspaceService.batchWarnOrCleanSuspendedWorkspaces(
|
||||||
|
suspendedWorkspaceIds.map((workspace) => workspace.id),
|
||||||
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!`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
}
|
||||||
@ -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!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,18 +1,35 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.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 { 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 { 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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Workspace], 'core'),
|
TypeOrmModule.forFeature([Workspace, BillingSubscription], 'core'),
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
DataSourceModule,
|
DataSourceModule,
|
||||||
|
UserVarsModule,
|
||||||
|
UserModule,
|
||||||
|
EmailModule,
|
||||||
|
BillingModule,
|
||||||
],
|
],
|
||||||
providers: [DeleteWorkspacesCommand, CleanSuspendedWorkspacesCronCommand],
|
providers: [
|
||||||
|
DeleteWorkspacesCommand,
|
||||||
|
CleanSuspendedWorkspacesCommand,
|
||||||
|
CleanSuspendedWorkspacesCronCommand,
|
||||||
|
CleanerWorkspaceService,
|
||||||
|
],
|
||||||
|
exports: [CleanerWorkspaceService],
|
||||||
})
|
})
|
||||||
export class WorkspaceCleanerModule {}
|
export class WorkspaceCleanerModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user