feat: add clean suspended workspaces command (#9808)
closes [283 sub-issue](https://github.com/twentyhq/core-team-issues/issues/283) - [parent issue ](https://github.com/orgs/twentyhq/projects/1/views/3?filterQuery=sprint%3A%40current+assignee%3Aetiennejouan&pane=issue&itemId=93520456&issue=twentyhq%7Ccore-team-issues%7C179) --------- Co-authored-by: etiennejouan <jouan.etienne@gmail.com>
This commit is contained in:
@ -26,6 +26,7 @@ import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billi
|
|||||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||||
|
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
|
||||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
|
|
||||||
@ -34,6 +35,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
|||||||
FeatureFlagModule,
|
FeatureFlagModule,
|
||||||
StripeModule,
|
StripeModule,
|
||||||
DomainManagerModule,
|
DomainManagerModule,
|
||||||
|
MessageQueueModule,
|
||||||
TypeOrmModule.forFeature(
|
TypeOrmModule.forFeature(
|
||||||
[
|
[
|
||||||
BillingSubscription,
|
BillingSubscription,
|
||||||
|
|||||||
@ -13,7 +13,28 @@ import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/se
|
|||||||
import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-customer.util';
|
import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-customer.util';
|
||||||
import { transformStripeSubscriptionEventToDatabaseSubscriptionItem } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription-item.util';
|
import { transformStripeSubscriptionEventToDatabaseSubscriptionItem } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription-item.util';
|
||||||
import { transformStripeSubscriptionEventToDatabaseSubscription } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util';
|
import { transformStripeSubscriptionEventToDatabaseSubscription } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util';
|
||||||
|
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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
|
import {
|
||||||
|
CleanWorkspaceDeletionWarningUserVarsJob,
|
||||||
|
CleanWorkspaceDeletionWarningUserVarsJobData,
|
||||||
|
} from 'src/engine/workspace-manager/workspace-cleaner/jobs/clean-workspace-deletion-warning-user-vars.job';
|
||||||
|
|
||||||
|
const BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS = {
|
||||||
|
[WorkspaceActivationStatus.ACTIVE]: [
|
||||||
|
SubscriptionStatus.Active,
|
||||||
|
SubscriptionStatus.Trialing,
|
||||||
|
SubscriptionStatus.PastDue,
|
||||||
|
],
|
||||||
|
[WorkspaceActivationStatus.SUSPENDED]: [
|
||||||
|
SubscriptionStatus.Canceled,
|
||||||
|
SubscriptionStatus.Unpaid,
|
||||||
|
SubscriptionStatus.Paused,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingWebhookSubscriptionService {
|
export class BillingWebhookSubscriptionService {
|
||||||
protected readonly logger = new Logger(
|
protected readonly logger = new Logger(
|
||||||
@ -21,6 +42,8 @@ export class BillingWebhookSubscriptionService {
|
|||||||
);
|
);
|
||||||
constructor(
|
constructor(
|
||||||
private readonly stripeCustomerService: StripeCustomerService,
|
private readonly stripeCustomerService: StripeCustomerService,
|
||||||
|
@InjectMessageQueue(MessageQueue.workspaceQueue)
|
||||||
|
private readonly messageQueueService: MessageQueueService,
|
||||||
@InjectRepository(BillingSubscription, 'core')
|
@InjectRepository(BillingSubscription, 'core')
|
||||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||||
@InjectRepository(BillingSubscriptionItem, 'core')
|
@InjectRepository(BillingSubscriptionItem, 'core')
|
||||||
@ -62,14 +85,28 @@ export class BillingWebhookSubscriptionService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const billingSubscription =
|
const billingSubscriptions = await this.billingSubscriptionRepository.find({
|
||||||
await this.billingSubscriptionRepository.findOneOrFail({
|
where: { workspaceId },
|
||||||
where: { stripeSubscriptionId: data.object.id },
|
});
|
||||||
});
|
|
||||||
|
const updatedBillingSubscription = billingSubscriptions.find(
|
||||||
|
(subscription) => subscription.stripeSubscriptionId === data.object.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updatedBillingSubscription) {
|
||||||
|
throw new Error('Billing subscription not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveWorkspaceCompatibleSubscription = billingSubscriptions.some(
|
||||||
|
(subscription) =>
|
||||||
|
BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS[
|
||||||
|
WorkspaceActivationStatus.ACTIVE
|
||||||
|
].includes(subscription.status),
|
||||||
|
);
|
||||||
|
|
||||||
await this.billingSubscriptionItemRepository.upsert(
|
await this.billingSubscriptionItemRepository.upsert(
|
||||||
transformStripeSubscriptionEventToDatabaseSubscriptionItem(
|
transformStripeSubscriptionEventToDatabaseSubscriptionItem(
|
||||||
billingSubscription.id,
|
updatedBillingSubscription.id,
|
||||||
data,
|
data,
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
@ -79,9 +116,10 @@ export class BillingWebhookSubscriptionService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
data.object.status === SubscriptionStatus.Canceled ||
|
BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS[
|
||||||
data.object.status === SubscriptionStatus.Unpaid ||
|
WorkspaceActivationStatus.SUSPENDED
|
||||||
data.object.status === SubscriptionStatus.Paused
|
].includes(data.object.status as SubscriptionStatus) &&
|
||||||
|
!hasActiveWorkspaceCompatibleSubscription
|
||||||
) {
|
) {
|
||||||
await this.workspaceRepository.update(workspaceId, {
|
await this.workspaceRepository.update(workspaceId, {
|
||||||
activationStatus: WorkspaceActivationStatus.SUSPENDED,
|
activationStatus: WorkspaceActivationStatus.SUSPENDED,
|
||||||
@ -89,14 +127,19 @@ export class BillingWebhookSubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(data.object.status === SubscriptionStatus.Active ||
|
BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS[
|
||||||
data.object.status === SubscriptionStatus.Trialing ||
|
WorkspaceActivationStatus.ACTIVE
|
||||||
data.object.status === SubscriptionStatus.PastDue) &&
|
].includes(data.object.status as SubscriptionStatus) &&
|
||||||
workspace.activationStatus == WorkspaceActivationStatus.SUSPENDED
|
workspace.activationStatus == WorkspaceActivationStatus.SUSPENDED
|
||||||
) {
|
) {
|
||||||
await this.workspaceRepository.update(workspaceId, {
|
await this.workspaceRepository.update(workspaceId, {
|
||||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.messageQueueService.add<CleanWorkspaceDeletionWarningUserVarsJobData>(
|
||||||
|
CleanWorkspaceDeletionWarningUserVarsJob.name,
|
||||||
|
{ workspaceId },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.stripeCustomerService.updateCustomerMetadataWorkspaceId(
|
await this.stripeCustomerService.updateCustomerMetadataWorkspaceId(
|
||||||
|
|||||||
@ -371,12 +371,17 @@ export class EnvironmentVariables {
|
|||||||
'"WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION" should be strictly lower that "WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION"',
|
'"WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION" should be strictly lower that "WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION"',
|
||||||
})
|
})
|
||||||
@ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION > 0)
|
@ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION > 0)
|
||||||
WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION = 30;
|
WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION = 7;
|
||||||
|
|
||||||
@CastToPositiveNumber()
|
@CastToPositiveNumber()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION > 0)
|
@ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION > 0)
|
||||||
WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION = 60;
|
WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION = 14;
|
||||||
|
|
||||||
|
@CastToPositiveNumber()
|
||||||
|
@IsNumber()
|
||||||
|
@ValidateIf((env) => env.MAX_NUMBER_OF_WORKSPACES_DELETED_PER_EXECUTION > 0)
|
||||||
|
MAX_NUMBER_OF_WORKSPACES_DELETED_PER_EXECUTION = 5;
|
||||||
|
|
||||||
@IsEnum(CaptchaDriverType)
|
@IsEnum(CaptchaDriverType)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -7,11 +7,13 @@ import { DataSeedDemoWorkspaceJob } from 'src/database/commands/data-seed-demo-w
|
|||||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||||
|
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||||
import { UpdateSubscriptionQuantityJob } from 'src/engine/core-modules/billing/jobs/update-subscription-quantity.job';
|
import { UpdateSubscriptionQuantityJob } from 'src/engine/core-modules/billing/jobs/update-subscription-quantity.job';
|
||||||
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
|
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
|
||||||
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
|
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
|
||||||
import { EmailModule } from 'src/engine/core-modules/email/email.module';
|
import { EmailModule } from 'src/engine/core-modules/email/email.module';
|
||||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.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 { UserModule } from 'src/engine/core-modules/user/user.module';
|
||||||
import { HandleWorkspaceMemberDeletedJob } from 'src/engine/core-modules/workspace/handle-workspace-member-deleted.job';
|
import { HandleWorkspaceMemberDeletedJob } from 'src/engine/core-modules/workspace/handle-workspace-member-deleted.job';
|
||||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
@ -19,6 +21,8 @@ import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.mod
|
|||||||
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 { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||||
import { CleanInactiveWorkspaceJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.job';
|
import { CleanInactiveWorkspaceJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.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 { 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';
|
||||||
@ -31,11 +35,12 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Workspace], 'core'),
|
TypeOrmModule.forFeature([Workspace, BillingSubscription], 'core'),
|
||||||
DataSourceModule,
|
DataSourceModule,
|
||||||
ObjectMetadataModule,
|
ObjectMetadataModule,
|
||||||
TypeORMModule,
|
TypeORMModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
|
UserVarsModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
DataSeedDemoWorkspaceModule,
|
DataSeedDemoWorkspaceModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
@ -55,10 +60,12 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
|||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
CleanInactiveWorkspaceJob,
|
CleanInactiveWorkspaceJob,
|
||||||
|
CleanSuspendedWorkspacesJob,
|
||||||
EmailSenderJob,
|
EmailSenderJob,
|
||||||
DataSeedDemoWorkspaceJob,
|
DataSeedDemoWorkspaceJob,
|
||||||
UpdateSubscriptionQuantityJob,
|
UpdateSubscriptionQuantityJob,
|
||||||
HandleWorkspaceMemberDeletedJob,
|
HandleWorkspaceMemberDeletedJob,
|
||||||
|
CleanWorkspaceDeletionWarningUserVarsJob,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class JobsModule {
|
export class JobsModule {
|
||||||
|
|||||||
@ -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 { cleanSuspendedWorkspaceCronPattern } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-suspended-workspaces.cron.pattern';
|
||||||
|
import { CleanSuspendedWorkspacesJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-suspended-workspaces.job';
|
||||||
|
|
||||||
|
@Command({
|
||||||
|
name: 'cron:clean-suspended-workspaces',
|
||||||
|
description: 'Starts a cron job to clean suspended workspaces',
|
||||||
|
})
|
||||||
|
export class CleanSuspendedWorkspacesCronCommand extends CommandRunner {
|
||||||
|
constructor(
|
||||||
|
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||||
|
private readonly messageQueueService: MessageQueueService,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(): Promise<void> {
|
||||||
|
await this.messageQueueService.addCron<undefined>(
|
||||||
|
CleanSuspendedWorkspacesJob.name,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
repeat: { pattern: cleanSuspendedWorkspaceCronPattern },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export const USER_WORKSPACE_DELETION_WARNING_SENT_KEY =
|
||||||
|
'USER_WORKSPACE_DELETION_WARNING_SENT';
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export const cleanSuspendedWorkspaceCronPattern = '0 22 * * *'; // Every day at 10pm
|
||||||
@ -0,0 +1,210 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import chunk from 'lodash.chunk';
|
||||||
|
import { WorkspaceActivationStatus } from 'twenty-shared';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||||
|
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;
|
||||||
|
|
||||||
|
@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,
|
||||||
|
@InjectRepository(BillingSubscription, 'core')
|
||||||
|
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||||
|
@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 warnWorkspaceMembers(workspace: Workspace) {
|
||||||
|
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 {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: issue #284
|
||||||
|
// send email warning for deletion in (this.inactiveDaysBeforeDelete - this.inactiveDaysBeforeWarn) days (cci @twenty.com)
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Warning Workspace ${workspace.id} ${workspace.displayName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async informWorkspaceMembersAndDeleteWorkspace(workspace: Workspace) {
|
||||||
|
const workspaceMembers =
|
||||||
|
await this.userService.loadWorkspaceMembers(workspace);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
// TODO: issue #285
|
||||||
|
// send email informing about deletion (cci @twenty.com)
|
||||||
|
// remove clean-inactive-workspace.job.ts and .. files
|
||||||
|
// add new env var in infra
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Job done!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
import { Logger, Scope } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import chunk from 'lodash.chunk';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
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 { 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';
|
||||||
|
|
||||||
|
export type CleanWorkspaceDeletionWarningUserVarsJobData = {
|
||||||
|
workspaceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Processor({
|
||||||
|
queueName: MessageQueue.workspaceQueue,
|
||||||
|
scope: Scope.REQUEST,
|
||||||
|
})
|
||||||
|
export class CleanWorkspaceDeletionWarningUserVarsJob {
|
||||||
|
protected readonly logger = new Logger(
|
||||||
|
CleanWorkspaceDeletionWarningUserVarsJob.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly userService: UserService,
|
||||||
|
private readonly userVarsService: UserVarsService,
|
||||||
|
@InjectRepository(Workspace, 'core')
|
||||||
|
private readonly workspaceRepository: Repository<Workspace>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Process(CleanWorkspaceDeletionWarningUserVarsJob.name)
|
||||||
|
async handle(
|
||||||
|
data: CleanWorkspaceDeletionWarningUserVarsJobData,
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.log(`Job running...`);
|
||||||
|
|
||||||
|
const { workspaceId } = data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workspace = await this.workspaceRepository.findOneOrFail({
|
||||||
|
where: { id: workspaceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaceMembers =
|
||||||
|
await this.userService.loadWorkspaceMembers(workspace);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Successfully cleaned user vars for ${workspaceMember.userId} user in ${workspace.id} workspace`,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.logger.log(`Job done!`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to clean ${workspaceId} workspace users deletion warning user vars: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.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 { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||||
import { CleanInactiveWorkspacesCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-inactive-workspaces.command';
|
import { CleanInactiveWorkspacesCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-inactive-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 { StartCleanInactiveWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/start-clean-inactive-workspaces.cron.command';
|
import { StartCleanInactiveWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/start-clean-inactive-workspaces.cron.command';
|
||||||
import { StopCleanInactiveWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/stop-clean-inactive-workspaces.cron.command';
|
import { StopCleanInactiveWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/stop-clean-inactive-workspaces.cron.command';
|
||||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
|
||||||
import { DeleteWorkspacesCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/delete-workspaces.command';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -20,6 +21,7 @@ import { DeleteWorkspacesCommand } from 'src/engine/workspace-manager/workspace-
|
|||||||
CleanInactiveWorkspacesCommand,
|
CleanInactiveWorkspacesCommand,
|
||||||
StartCleanInactiveWorkspacesCronCommand,
|
StartCleanInactiveWorkspacesCronCommand,
|
||||||
StopCleanInactiveWorkspacesCronCommand,
|
StopCleanInactiveWorkspacesCronCommand,
|
||||||
|
CleanSuspendedWorkspacesCronCommand,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class WorkspaceCleanerModule {}
|
export class WorkspaceCleanerModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user