Update suspended cleaning command (#10195)

closes https://github.com/twentyhq/core-team-issues/issues/382
This commit is contained in:
Etienne
2025-02-14 16:49:44 +01:00
committed by GitHub
parent 968ad3bd31
commit db526778e3
12 changed files with 256 additions and 106 deletions

View File

@ -5,13 +5,13 @@ import { MainText } from 'src/components/MainText';
import { Title } from 'src/components/Title'; import { Title } from 'src/components/Title';
type CleanSuspendedWorkspaceEmailProps = { type CleanSuspendedWorkspaceEmailProps = {
inactiveDaysBeforeDelete: number; daysSinceInactive: number;
userName: string; userName: string;
workspaceDisplayName: string | undefined; workspaceDisplayName: string | undefined;
}; };
export const CleanSuspendedWorkspaceEmail = ({ export const CleanSuspendedWorkspaceEmail = ({
inactiveDaysBeforeDelete, daysSinceInactive,
userName, userName,
workspaceDisplayName, workspaceDisplayName,
}: CleanSuspendedWorkspaceEmailProps) => { }: CleanSuspendedWorkspaceEmailProps) => {
@ -26,7 +26,7 @@ export const CleanSuspendedWorkspaceEmail = ({
<br /> <br />
<Trans> <Trans>
Your workspace <b>{workspaceDisplayName}</b> has been deleted as your Your workspace <b>{workspaceDisplayName}</b> has been deleted as your
subscription expired {inactiveDaysBeforeDelete} days ago. subscription expired {daysSinceInactive} days ago.
</Trans> </Trans>
<br /> <br />
<br /> <br />

View File

@ -46,8 +46,9 @@ FRONTEND_URL=http://localhost:3001
# SENTRY_FRONT_DSN=https://xxx@xxx.ingest.sentry.io/xxx # SENTRY_FRONT_DSN=https://xxx@xxx.ingest.sentry.io/xxx
# LOG_LEVELS=error,warn # LOG_LEVELS=error,warn
# SERVER_URL=http://localhost:3000 # SERVER_URL=http://localhost:3000
# WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION=30 # WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION=7
# WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION=60 # WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION=14
# WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION=21
# Email Server Settings, see this doc for more info: https://docs.twenty.com/start/self-hosting/#email # Email Server Settings, see this doc for more info: https://docs.twenty.com/start/self-hosting/#email
# IS_EMAIL_VERIFICATION_REQUIRED=false # IS_EMAIL_VERIFICATION_REQUIRED=false
# EMAIL_VERIFICATION_TOKEN_EXPIRES_IN=1h # EMAIL_VERIFICATION_TOKEN_EXPIRES_IN=1h

View File

@ -877,13 +877,23 @@ export class EnvironmentVariables {
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
@IsNumber() @IsNumber()
@ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION > 0) @IsStrictlyLowerThan('WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION', {
message:
'"WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION" should be strictly lower than "WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION"',
})
WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION = 7;
@EnvironmentVariablesMetadata({
group: EnvironmentVariablesGroup.Other,
description: 'Number of inactive days before soft deleting workspaces',
})
@CastToPositiveNumber()
@IsNumber()
@IsStrictlyLowerThan('WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', { @IsStrictlyLowerThan('WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', {
message: message:
'"WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION" should be strictly lower than "WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION"', '"WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION" should be strictly lower than "WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION"',
}) })
@ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION > 0) WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION = 14;
WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION = 7;
@EnvironmentVariablesMetadata({ @EnvironmentVariablesMetadata({
group: EnvironmentVariablesGroup.Other, group: EnvironmentVariablesGroup.Other,
@ -891,8 +901,7 @@ export class EnvironmentVariables {
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
@IsNumber() @IsNumber()
@ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION > 0) WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION = 21;
WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION = 14;
@EnvironmentVariablesMetadata({ @EnvironmentVariablesMetadata({
group: EnvironmentVariablesGroup.Other, group: EnvironmentVariablesGroup.Other,

View File

@ -5,6 +5,7 @@ import { SettingsFeatures } from 'twenty-shared';
import { import {
Column, Column,
CreateDateColumn, CreateDateColumn,
DeleteDateColumn,
Entity, Entity,
JoinColumn, JoinColumn,
ManyToOne, ManyToOne,
@ -63,7 +64,7 @@ export class UserWorkspace {
updatedAt: Date; updatedAt: Date;
@Field({ nullable: true }) @Field({ nullable: true })
@Column({ nullable: true, type: 'timestamptz' }) @DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date; deletedAt: Date;
@OneToMany( @OneToMany(

View File

@ -3,9 +3,11 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@ -15,9 +17,8 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { WorkspaceService } from './workspace.service'; import { WorkspaceService } from './workspace.service';
@ -96,6 +97,10 @@ describe('WorkspaceService', () => {
provide: PermissionsService, provide: PermissionsService,
useValue: {}, useValue: {},
}, },
{
provide: WorkspaceCacheStorageService,
useValue: {},
},
], ],
}).compile(); }).compile();

View File

@ -14,6 +14,7 @@ import { Repository } from 'typeorm';
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
@ -35,9 +36,9 @@ import {
PermissionsExceptionMessage, PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception'; } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags'; import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
@Injectable() @Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository // eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -61,6 +62,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
private readonly exceptionHandlerService: ExceptionHandlerService, private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly permissionsService: PermissionsService, private readonly permissionsService: PermissionsService,
private readonly customDomainService: CustomDomainService, private readonly customDomainService: CustomDomainService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) { ) {
super(workspaceRepository); super(workspaceRepository);
} }
@ -259,43 +261,71 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
}); });
} }
async softDeleteWorkspace(id: string) { async deleteMetadataSchemaCacheAndUserWorkspace(workspace: Workspace) {
const workspace = await this.workspaceRepository.findOneBy({ id }); await this.userWorkspaceRepository.delete({ workspaceId: workspace.id });
assert(workspace, 'Workspace not found');
await this.userWorkspaceRepository.delete({ workspaceId: id });
if (this.billingService.isBillingEnabled()) { if (this.billingService.isBillingEnabled()) {
await this.billingSubscriptionService.deleteSubscriptions(workspace.id); await this.billingSubscriptionService.deleteSubscriptions(workspace.id);
} }
await this.workspaceManagerService.delete(id); await this.workspaceManagerService.delete(workspace.id);
return workspace; return workspace;
} }
async deleteWorkspace(id: string) { async deleteWorkspace(id: string, softDelete = false) {
const userWorkspaces = await this.userWorkspaceRepository.findBy({ const workspace = await this.workspaceRepository.findOne({
workspaceId: id, where: { id },
withDeleted: true,
}); });
const workspace = await this.softDeleteWorkspace(id); assert(workspace, 'Workspace not found');
const userWorkspaces = await this.userWorkspaceRepository.find({
where: {
workspaceId: id,
},
withDeleted: true,
});
for (const userWorkspace of userWorkspaces) { for (const userWorkspace of userWorkspaces) {
await this.handleRemoveWorkspaceMember(id, userWorkspace.userId); await this.handleRemoveWorkspaceMember(
id,
userWorkspace.userId,
softDelete,
);
} }
await this.workspaceRepository.delete(id); await this.workspaceCacheStorageService.flush(
workspace.id,
workspace.metadataVersion,
);
return workspace; if (softDelete) {
return await this.workspaceRepository.softDelete({ id });
}
await this.deleteMetadataSchemaCacheAndUserWorkspace(workspace);
return await this.workspaceRepository.delete(id);
} }
async handleRemoveWorkspaceMember(workspaceId: string, userId: string) { async handleRemoveWorkspaceMember(
await this.userWorkspaceRepository.delete({ workspaceId: string,
userId, userId: string,
workspaceId, softDelete = false,
}); ) {
if (softDelete) {
await this.userWorkspaceRepository.softDelete({
userId,
workspaceId,
});
} else {
await this.userWorkspaceRepository.delete({
userId,
workspaceId,
});
}
const userWorkspaces = await this.userWorkspaceRepository.find({ const userWorkspaces = await this.userWorkspaceRepository.find({
where: { where: {

View File

@ -21,6 +21,7 @@ import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.r
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 { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module'; import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts'; import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts';
@ -51,6 +52,7 @@ import { WorkspaceService } from './services/workspace.service';
OnboardingModule, OnboardingModule,
TypeORMModule, TypeORMModule,
PermissionsModule, PermissionsModule,
WorkspaceCacheStorageModule,
], ],
services: [WorkspaceService], services: [WorkspaceService],
resolvers: workspaceAutoResolverOpts, resolvers: workspaceAutoResolverOpts,

View File

@ -411,9 +411,6 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
public async deleteObjectsMetadata(workspaceId: string) { public async deleteObjectsMetadata(workspaceId: string) {
await this.objectMetadataRepository.delete({ workspaceId }); await this.objectMetadataRepository.delete({ workspaceId });
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
} }
public async getObjectMetadataStandardIdToIdMap(workspaceId: string) { public async getObjectMetadataStandardIdToIdMap(workspaceId: string) {

View File

@ -1,10 +1,13 @@
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander'; import { Command, Option } from 'nest-commander';
import { Repository } from 'typeorm'; import { WorkspaceActivationStatus } from 'twenty-shared';
import { In, Repository } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command'; import {
import { BaseCommandOptions } from 'src/database/commands/base.command'; BaseCommandOptions,
BaseCommandRunner,
} from 'src/database/commands/base.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { CleanerWorkspaceService } from 'src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service'; import { CleanerWorkspaceService } from 'src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service';
@ -12,22 +15,59 @@ import { CleanerWorkspaceService } from 'src/engine/workspace-manager/workspace-
name: 'workspace:clean', name: 'workspace:clean',
description: 'Clean suspended workspace', description: 'Clean suspended workspace',
}) })
export class CleanSuspendedWorkspacesCommand extends ActiveWorkspacesCommandRunner { export class CleanSuspendedWorkspacesCommand extends BaseCommandRunner {
private workspaceIds: string[] = [];
constructor( constructor(
private readonly cleanerWorkspaceService: CleanerWorkspaceService, private readonly cleanerWorkspaceService: CleanerWorkspaceService,
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>, protected readonly workspaceRepository: Repository<Workspace>,
) { ) {
super(workspaceRepository); super();
} }
async executeActiveWorkspacesCommand( @Option({
_passedParam: string[], flags: '-w, --workspace-id [workspace_id]',
_options: BaseCommandOptions, description:
workspaceIds: string[], 'workspace id. Command runs on all suspended workspaces if not provided',
required: false,
})
parseWorkspaceId(val: string): string[] {
this.workspaceIds.push(val);
return this.workspaceIds;
}
async fetchSuspendedWorkspaceIds(): Promise<string[]> {
const suspendedWorkspaces = await this.workspaceRepository.find({
select: ['id'],
where: {
activationStatus: In([WorkspaceActivationStatus.SUSPENDED]),
},
withDeleted: true,
});
return suspendedWorkspaces.map((workspace) => workspace.id);
}
override async executeBaseCommand(
_passedParams: string[],
options: BaseCommandOptions,
): Promise<void> { ): Promise<void> {
const { dryRun } = options;
const suspendedWorkspaceIds =
this.workspaceIds.length > 0
? this.workspaceIds
: await this.fetchSuspendedWorkspaceIds();
this.logger.log(
`${dryRun ? 'DRY RUN - ' : ''}Cleaning ${suspendedWorkspaceIds.length} suspended workspaces`,
);
await this.cleanerWorkspaceService.batchWarnOrCleanSuspendedWorkspaces( await this.cleanerWorkspaceService.batchWarnOrCleanSuspendedWorkspaces(
workspaceIds, suspendedWorkspaceIds,
dryRun,
); );
} }
} }

View File

@ -24,6 +24,7 @@ export class CleanSuspendedWorkspacesJob {
where: { where: {
activationStatus: WorkspaceActivationStatus.SUSPENDED, activationStatus: WorkspaceActivationStatus.SUSPENDED,
}, },
withDeleted: true,
}); });
await this.cleanerWorkspaceService.batchWarnOrCleanSuspendedWorkspaces( await this.cleanerWorkspaceService.batchWarnOrCleanSuspendedWorkspaces(

View File

@ -2,11 +2,12 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { render } from '@react-email/render'; import { render } from '@react-email/render';
import { differenceInDays } from 'date-fns';
import { import {
CleanSuspendedWorkspaceEmail, CleanSuspendedWorkspaceEmail,
WarnSuspendedWorkspaceEmail, WarnSuspendedWorkspaceEmail,
} from 'twenty-emails'; } from 'twenty-emails';
import { WorkspaceActivationStatus } from 'twenty-shared'; import { isDefined, WorkspaceActivationStatus } from 'twenty-shared';
import { In, Repository } from 'typeorm'; import { In, Repository } from 'typeorm';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
@ -23,11 +24,10 @@ import {
} from 'src/engine/workspace-manager/workspace-cleaner/exceptions/workspace-cleaner.exception'; } from 'src/engine/workspace-manager/workspace-cleaner/exceptions/workspace-cleaner.exception';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
const MILLISECONDS_IN_ONE_DAY = 1000 * 3600 * 24;
@Injectable() @Injectable()
export class CleanerWorkspaceService { export class CleanerWorkspaceService {
private readonly logger = new Logger(CleanerWorkspaceService.name); private readonly logger = new Logger(CleanerWorkspaceService.name);
private readonly inactiveDaysBeforeSoftDelete: number;
private readonly inactiveDaysBeforeDelete: number; private readonly inactiveDaysBeforeDelete: number;
private readonly inactiveDaysBeforeWarn: number; private readonly inactiveDaysBeforeWarn: number;
private readonly maxNumberOfWorkspacesDeletedPerExecution: number; private readonly maxNumberOfWorkspacesDeletedPerExecution: number;
@ -43,6 +43,9 @@ export class CleanerWorkspaceService {
@InjectRepository(BillingSubscription, 'core') @InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>, private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
) { ) {
this.inactiveDaysBeforeSoftDelete = this.environmentService.get(
'WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION',
);
this.inactiveDaysBeforeDelete = this.environmentService.get( this.inactiveDaysBeforeDelete = this.environmentService.get(
'WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', 'WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION',
); );
@ -64,9 +67,9 @@ export class CleanerWorkspaceService {
order: { updatedAt: 'DESC' }, order: { updatedAt: 'DESC' },
}); });
const daysSinceBillingInactivity = Math.floor( const daysSinceBillingInactivity = differenceInDays(
(new Date().getTime() - lastSubscription.updatedAt.getTime()) / new Date(),
MILLISECONDS_IN_ONE_DAY, lastSubscription.updatedAt,
); );
return daysSinceBillingInactivity; return daysSinceBillingInactivity;
@ -104,7 +107,7 @@ export class CleanerWorkspaceService {
) { ) {
const emailData = { const emailData = {
daysSinceInactive, daysSinceInactive,
inactiveDaysBeforeDelete: this.inactiveDaysBeforeDelete, inactiveDaysBeforeDelete: this.inactiveDaysBeforeSoftDelete,
userName: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`, userName: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
workspaceDisplayName: `${workspaceDisplayName}`, workspaceDisplayName: `${workspaceDisplayName}`,
}; };
@ -124,7 +127,11 @@ export class CleanerWorkspaceService {
}); });
} }
async warnWorkspaceMembers(workspace: Workspace, daysSinceInactive: number) { async warnWorkspaceMembers(
workspace: Workspace,
daysSinceInactive: number,
dryRun: boolean,
) {
const workspaceMembers = const workspaceMembers =
await this.userService.loadWorkspaceMembers(workspace); await this.userService.loadWorkspaceMembers(workspace);
@ -136,42 +143,45 @@ export class CleanerWorkspaceService {
if (workspaceMembersWarned) { if (workspaceMembersWarned) {
this.logger.log( this.logger.log(
`Workspace ${workspace.id} ${workspace.displayName} already warned`, `${dryRun ? 'DRY RUN - ' : ''}Workspace ${workspace.id} ${workspace.displayName} already warned`,
); );
return; return;
} }
this.logger.log( this.logger.log(
`Sending ${workspace.id} ${ `${dryRun ? 'DRY RUN - ' : ''}Sending ${workspace.id} ${
workspace.displayName workspace.displayName
} suspended since ${daysSinceInactive} days emails to users ['${workspaceMembers } suspended since ${daysSinceInactive} days emails to users ['${workspaceMembers
.map((workspaceUser) => workspaceUser.userId) .map((workspaceUser) => workspaceUser.userId)
.join(', ')}']`, .join(', ')}']`,
); );
for (const workspaceMember of workspaceMembers) { if (!dryRun) {
await this.userVarsService.set({ for (const workspaceMember of workspaceMembers) {
userId: workspaceMember.userId, await this.userVarsService.set({
workspaceId: workspace.id, userId: workspaceMember.userId,
key: USER_WORKSPACE_DELETION_WARNING_SENT_KEY, workspaceId: workspace.id,
value: true, key: USER_WORKSPACE_DELETION_WARNING_SENT_KEY,
}); value: true,
});
await this.sendWarningEmail( await this.sendWarningEmail(
workspaceMember, workspaceMember,
workspace.displayName, workspace.displayName,
daysSinceInactive, daysSinceInactive,
); );
}
} }
} }
async sendCleaningEmail( async sendCleaningEmail(
workspaceMember: WorkspaceMemberWorkspaceEntity, workspaceMember: WorkspaceMemberWorkspaceEntity,
workspaceDisplayName: string, workspaceDisplayName: string,
daysSinceInactive: number,
) { ) {
const emailData = { const emailData = {
inactiveDaysBeforeDelete: this.inactiveDaysBeforeDelete, daysSinceInactive: daysSinceInactive,
userName: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`, userName: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
workspaceDisplayName, workspaceDisplayName,
}; };
@ -191,73 +201,126 @@ export class CleanerWorkspaceService {
}); });
} }
async informWorkspaceMembersAndDeleteWorkspace(workspace: Workspace) { async informWorkspaceMembersAndSoftDeleteWorkspace(
workspace: Workspace,
daysSinceInactive: number,
dryRun: boolean,
) {
if (isDefined(workspace.deletedAt)) {
this.logger.log(
`${dryRun ? 'DRY RUN - ' : ''}Workspace ${workspace.id} ${
workspace.displayName
} already soft deleted`,
);
return;
}
const workspaceMembers = const workspaceMembers =
await this.userService.loadWorkspaceMembers(workspace); await this.userService.loadWorkspaceMembers(workspace);
this.logger.log( this.logger.log(
`Sending workspace ${workspace.id} ${ `${dryRun ? 'DRY RUN - ' : ''}Sending workspace ${workspace.id} ${
workspace.displayName workspace.displayName
} deletion emails to users ['${workspaceMembers } deletion emails to users ['${workspaceMembers
.map((workspaceUser) => workspaceUser.userId) .map((workspaceUser) => workspaceUser.userId)
.join(', ')}']`, .join(', ')}']`,
); );
for (const workspaceMember of workspaceMembers) { if (!dryRun) {
await this.userVarsService.delete({ for (const workspaceMember of workspaceMembers) {
userId: workspaceMember.userId, await this.userVarsService.delete({
workspaceId: workspace.id, userId: workspaceMember.userId,
key: USER_WORKSPACE_DELETION_WARNING_SENT_KEY, workspaceId: workspace.id,
}); key: USER_WORKSPACE_DELETION_WARNING_SENT_KEY,
});
await this.sendCleaningEmail( await this.sendCleaningEmail(
workspaceMember, workspaceMember,
workspace.displayName || '', workspace.displayName || '',
); daysSinceInactive,
);
}
await this.workspaceService.deleteWorkspace(workspace.id, true);
} }
await this.workspaceService.deleteWorkspace(workspace.id);
this.logger.log( this.logger.log(
`Cleaning Workspace ${workspace.id} ${workspace.displayName}`, `${dryRun ? 'DRY RUN - ' : ''}Soft deleting Workspace ${workspace.id} ${workspace.displayName}`,
); );
} }
async batchWarnOrCleanSuspendedWorkspaces( async batchWarnOrCleanSuspendedWorkspaces(
workspaceIds: string[], workspaceIds: string[],
dryRun = false,
): Promise<void> { ): Promise<void> {
this.logger.log(`batchWarnOrCleanSuspendedWorkspaces running...`); this.logger.log(
`${dryRun ? 'DRY RUN - ' : ''}batchWarnOrCleanSuspendedWorkspaces running...`,
);
const workspaces = await this.workspaceRepository.find({ const workspaces = await this.workspaceRepository.find({
where: { where: {
id: In(workspaceIds), id: In(workspaceIds),
activationStatus: WorkspaceActivationStatus.SUSPENDED, activationStatus: WorkspaceActivationStatus.SUSPENDED,
}, },
withDeleted: true,
}); });
let deletedWorkspacesCount = 0; let deletedWorkspacesCount = 0;
for (const workspace of workspaces) { for (const workspace of workspaces) {
const workspaceInactivity = try {
await this.computeWorkspaceBillingInactivity(workspace); const workspaceInactivity =
await this.computeWorkspaceBillingInactivity(workspace);
if ( const daysSinceSoftDeleted = workspace.deletedAt
workspaceInactivity && ? differenceInDays(new Date(), workspace.deletedAt)
workspaceInactivity > this.inactiveDaysBeforeDelete && : 0;
deletedWorkspacesCount <= this.maxNumberOfWorkspacesDeletedPerExecution
) {
await this.informWorkspaceMembersAndDeleteWorkspace(workspace);
deletedWorkspacesCount++;
continue; if (
} daysSinceSoftDeleted >
if ( this.inactiveDaysBeforeDelete - this.inactiveDaysBeforeSoftDelete
workspaceInactivity && ) {
workspaceInactivity > this.inactiveDaysBeforeWarn && this.logger.log(
workspaceInactivity <= this.inactiveDaysBeforeDelete `${dryRun ? 'DRY RUN - ' : ''}Destroying workspace ${workspace.id} ${workspace.displayName}`,
) { );
await this.warnWorkspaceMembers(workspace, workspaceInactivity); if (!dryRun) {
await this.workspaceService.deleteWorkspace(workspace.id);
}
continue;
}
if (
workspaceInactivity > this.inactiveDaysBeforeSoftDelete &&
deletedWorkspacesCount <=
this.maxNumberOfWorkspacesDeletedPerExecution
) {
await this.informWorkspaceMembersAndSoftDeleteWorkspace(
workspace,
workspaceInactivity,
dryRun,
);
deletedWorkspacesCount++;
continue;
}
if (
workspaceInactivity > this.inactiveDaysBeforeWarn &&
workspaceInactivity <= this.inactiveDaysBeforeSoftDelete
) {
await this.warnWorkspaceMembers(
workspace,
workspaceInactivity,
dryRun,
);
}
} catch (error) {
this.logger.error(
`Error while processing workspace ${workspace.id} ${workspace.displayName}: ${error}`,
);
} }
} }
this.logger.log(`batchWarnOrCleanSuspendedWorkspaces done!`); this.logger.log(
`${dryRun ? 'DRY RUN - ' : ''}batchWarnOrCleanSuspendedWorkspaces done!`,
);
} }
} }

View File

@ -336,7 +336,8 @@ This feature is WIP and is not yet useful for most users.
<ArticleTable options={[ <ArticleTable options={[
['WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION', '', 'Number of inactive days before sending workspace deleting warning email'], ['WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION', '', 'Number of inactive days before sending workspace deleting warning email'],
['WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', '', 'Number of inactive days before deleting workspace'], ['WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION', '', 'Number of inactive days before soft deleting workspace'],
['WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', '', 'Number of inactive days before destroying workspace'],
]}></ArticleTable> ]}></ArticleTable>
### Captcha ### Captcha