2252 build a script to cleanup inactive workspaces (#3307)
* Add cron to message queue interfaces * Add command to launch cron job * Add command to stop cron job * Update clean inactive workspaces job * Add react-email * WIP * Fix import error * Rename services * Update logging * Update email template * Update email template * Add Base Email template * Move to proper place * Remove test files * Update logo * Add email theme * Revert "Remove test files" This reverts commit fe062dd05166b95125cf99f2165cc20efb6c275a. * Add email theme 2 * Revert "Revert "Remove test files"" This reverts commit 6c6471273ad765788f2eaf5a5614209edfb965ce. * Revert "Revert "Revert "Remove test files""" This reverts commit f851333c24e9cfe3f425c9cbbd1e079efce5c3dd. * Revert "Revert "Revert "Revert "Remove test files"""" This reverts commit 7838e19e88e269026e24803f26cd52b467b4ef36. * Fix theme * Reorganize files * Update clean inactive workspaces job * Use env variable to define inactive days * Remove FROM variable * Use feature flag * Fix cron command * Remove useless variable * Reorganize files * Refactor some code * Update email template * Update email object * Remove verbose log * Code review returns * Code review returns * Simplify handle * Code review returns * Review --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -0,0 +1 @@
|
||||
export const cleanInactiveWorkspaceCronPattern = '0 22 * * *'; // Every day at 10pm
|
||||
@ -0,0 +1,221 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { render } from '@react-email/render';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { MessageQueueJob } from 'src/integrations/message-queue/interfaces/message-queue-job.interface';
|
||||
|
||||
import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service';
|
||||
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import { DataSourceEntity } from 'src/metadata/data-source/data-source.entity';
|
||||
import { UserService } from 'src/core/user/services/user.service';
|
||||
import { EmailService } from 'src/integrations/email/email.service';
|
||||
import CleanInactiveWorkspaceEmail from 'src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspaces.email';
|
||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||
import {
|
||||
FeatureFlagEntity,
|
||||
FeatureFlagKeys,
|
||||
} from 'src/core/feature-flag/feature-flag.entity';
|
||||
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
|
||||
import DeleteInactiveWorkspaceEmail from 'src/workspace/cron/clean-inactive-workspaces/delete-inactive-workspaces.email';
|
||||
|
||||
const MILLISECONDS_IN_ONE_DAY = 1000 * 3600 * 24;
|
||||
|
||||
@Injectable()
|
||||
export class CleanInactiveWorkspaceJob implements MessageQueueJob<undefined> {
|
||||
private readonly logger = new Logger(CleanInactiveWorkspaceJob.name);
|
||||
private readonly inactiveDaysBeforeDelete;
|
||||
private readonly inactiveDaysBeforeEmail;
|
||||
|
||||
constructor(
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
private readonly typeORMService: TypeORMService,
|
||||
private readonly userService: UserService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@InjectRepository(FeatureFlagEntity, 'core')
|
||||
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
|
||||
) {
|
||||
this.inactiveDaysBeforeDelete =
|
||||
this.environmentService.getInactiveDaysBeforeDelete();
|
||||
this.inactiveDaysBeforeEmail =
|
||||
this.environmentService.getInactiveDaysBeforeEmail();
|
||||
}
|
||||
|
||||
async getmostRecentUpdatedAt(
|
||||
dataSource: DataSourceEntity,
|
||||
objectsMetadata: ObjectMetadataEntity[],
|
||||
): Promise<Date> {
|
||||
const tableNames = objectsMetadata
|
||||
.filter(
|
||||
(objectMetadata) =>
|
||||
objectMetadata.workspaceId === dataSource.workspaceId,
|
||||
)
|
||||
.map((objectMetadata) => objectMetadata.targetTableName);
|
||||
|
||||
const workspaceDataSource =
|
||||
await this.typeORMService.connectToDataSource(dataSource);
|
||||
|
||||
let mostRecentUpdatedAtDate = new Date(0);
|
||||
|
||||
for (const tableName of tableNames) {
|
||||
const mostRecentTableUpdatedAt = (
|
||||
await workspaceDataSource?.query(
|
||||
`SELECT MAX("updatedAt") FROM ${dataSource.schema}."${tableName}"`,
|
||||
)
|
||||
)[0].max;
|
||||
|
||||
if (mostRecentTableUpdatedAt) {
|
||||
const mostRecentTableUpdatedAtDate = new Date(mostRecentTableUpdatedAt);
|
||||
|
||||
if (
|
||||
!mostRecentUpdatedAtDate ||
|
||||
mostRecentTableUpdatedAtDate > mostRecentUpdatedAtDate
|
||||
) {
|
||||
mostRecentUpdatedAtDate = mostRecentTableUpdatedAtDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mostRecentUpdatedAtDate;
|
||||
}
|
||||
|
||||
async warnWorkspaceUsers(
|
||||
dataSource: DataSourceEntity,
|
||||
daysSinceInactive: number,
|
||||
) {
|
||||
const workspaceMembers =
|
||||
await this.userService.loadWorkspaceMembers(dataSource);
|
||||
|
||||
const workspaceDataSource =
|
||||
await this.typeORMService.connectToDataSource(dataSource);
|
||||
|
||||
const displayName = (
|
||||
await workspaceDataSource?.query(
|
||||
`SELECT "displayName" FROM core.workspace WHERE id='${dataSource.workspaceId}'`,
|
||||
)
|
||||
)?.[0].displayName;
|
||||
|
||||
this.logger.log(
|
||||
`Sending workspace ${
|
||||
dataSource.workspaceId
|
||||
} inactive since ${daysSinceInactive} days emails to users ['${workspaceMembers
|
||||
.map((workspaceUser) => workspaceUser.email)
|
||||
.join(', ')}']`,
|
||||
);
|
||||
|
||||
workspaceMembers.forEach((workspaceMember) => {
|
||||
const emailData = {
|
||||
daysLeft: this.inactiveDaysBeforeDelete - daysSinceInactive,
|
||||
userName: `${workspaceMember.nameFirstName} ${workspaceMember.nameLastName}`,
|
||||
workspaceDisplayName: `${displayName}`,
|
||||
};
|
||||
const emailTemplate = CleanInactiveWorkspaceEmail(emailData);
|
||||
const html = render(emailTemplate, {
|
||||
pretty: true,
|
||||
});
|
||||
const text = render(emailTemplate, {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
this.emailService.send({
|
||||
to: workspaceMember.email,
|
||||
from: `${this.environmentService.getEmailFromName()} <${this.environmentService.getEmailFromAddress()}>`,
|
||||
subject: 'Action Needed to Prevent Workspace Deletion',
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async deleteWorkspace(
|
||||
dataSource: DataSourceEntity,
|
||||
daysSinceInactive: number,
|
||||
): Promise<void> {
|
||||
this.logger.log(
|
||||
`Sending email to delete workspace ${dataSource.workspaceId} inactive since ${daysSinceInactive} days`,
|
||||
);
|
||||
|
||||
const emailData = {
|
||||
daysSinceDead: daysSinceInactive - this.inactiveDaysBeforeDelete,
|
||||
workspaceId: `${dataSource.workspaceId}`,
|
||||
};
|
||||
const emailTemplate = DeleteInactiveWorkspaceEmail(emailData);
|
||||
const html = render(emailTemplate, {
|
||||
pretty: true,
|
||||
});
|
||||
|
||||
const text = `Workspace '${dataSource.workspaceId}' should be deleted as inactive since ${daysSinceInactive} days`;
|
||||
|
||||
await this.emailService.send({
|
||||
to: this.environmentService.getEmailSystemAddress(),
|
||||
from: `${this.environmentService.getEmailFromName()} <${this.environmentService.getEmailFromAddress()}>`,
|
||||
subject: 'Action Needed to Delete Workspace',
|
||||
html,
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
async processWorkspace(
|
||||
dataSource: DataSourceEntity,
|
||||
objectsMetadata: ObjectMetadataEntity[],
|
||||
): Promise<void> {
|
||||
const mostRecentUpdatedAt = await this.getmostRecentUpdatedAt(
|
||||
dataSource,
|
||||
objectsMetadata,
|
||||
);
|
||||
const daysSinceInactive = Math.floor(
|
||||
(new Date().getTime() - mostRecentUpdatedAt.getTime()) /
|
||||
MILLISECONDS_IN_ONE_DAY,
|
||||
);
|
||||
|
||||
if (daysSinceInactive > this.inactiveDaysBeforeDelete) {
|
||||
await this.deleteWorkspace(dataSource, daysSinceInactive);
|
||||
} else if (daysSinceInactive > this.inactiveDaysBeforeEmail) {
|
||||
await this.warnWorkspaceUsers(dataSource, daysSinceInactive);
|
||||
}
|
||||
}
|
||||
|
||||
async isWorkspaceCleanable(dataSource: DataSourceEntity): Promise<boolean> {
|
||||
const workspaceFeatureFlags = await this.featureFlagRepository.find({
|
||||
where: { workspaceId: dataSource.workspaceId },
|
||||
});
|
||||
|
||||
return (
|
||||
workspaceFeatureFlags.filter(
|
||||
(workspaceFeatureFlag) =>
|
||||
workspaceFeatureFlag.key === FeatureFlagKeys.IsWorkspaceCleanable &&
|
||||
workspaceFeatureFlag.value,
|
||||
).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
async handle(): Promise<void> {
|
||||
this.logger.log('Job running...');
|
||||
if (!this.inactiveDaysBeforeDelete && !this.inactiveDaysBeforeEmail) {
|
||||
this.logger.log(
|
||||
`'WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION' and 'WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION' environment variables not set, please check this doc for more info: https://docs.twenty.com/start/self-hosting/environment-variables`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
const dataSources =
|
||||
await this.dataSourceService.getManyDataSourceMetadata();
|
||||
|
||||
const objectsMetadata = await this.objectMetadataService.findMany();
|
||||
|
||||
for (const dataSource of dataSources) {
|
||||
if (!(await this.isWorkspaceCleanable(dataSource))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger.log(`Cleaning Workspace ${dataSource.workspaceId}`);
|
||||
await this.processWorkspace(dataSource, objectsMetadata);
|
||||
}
|
||||
|
||||
this.logger.log('job done!');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { HighlightedText } from 'src/emails/components/HighlightedText';
|
||||
import { MainText } from 'src/emails/components/MainText';
|
||||
import { Title } from 'src/emails/components/Title';
|
||||
import { BaseEmail } from 'src/emails/components/BaseEmail';
|
||||
import { CallToAction } from 'src/emails/components/CallToAction';
|
||||
|
||||
type CleanInactiveWorkspaceEmailData = {
|
||||
daysLeft: number;
|
||||
userName: string;
|
||||
workspaceDisplayName: string;
|
||||
};
|
||||
|
||||
export const CleanInactiveWorkspaceEmail = ({
|
||||
daysLeft,
|
||||
userName,
|
||||
workspaceDisplayName,
|
||||
}: CleanInactiveWorkspaceEmailData) => {
|
||||
const dayOrDays = daysLeft > 1 ? 'days' : 'day';
|
||||
const remainingDays = daysLeft > 1 ? `${daysLeft} ` : '';
|
||||
|
||||
const helloString = userName?.length > 1 ? `Hello ${userName}` : 'Hello';
|
||||
|
||||
return (
|
||||
<BaseEmail>
|
||||
<Title value="Inactive Workspace 😴" />
|
||||
<HighlightedText value={`${daysLeft} ${dayOrDays} left`} />
|
||||
<MainText>
|
||||
{helloString},
|
||||
<br />
|
||||
<br />
|
||||
It appears that there has been a period of inactivity on your{' '}
|
||||
<b>{workspaceDisplayName}</b> workspace.
|
||||
<br />
|
||||
<br />
|
||||
Please note that the account is due for deactivation soon, and all
|
||||
associated data within this workspace will be deleted.
|
||||
<br />
|
||||
<br />
|
||||
No need for concern, though! Simply create or edit a record within the
|
||||
next {remainingDays}
|
||||
{dayOrDays} to retain access.
|
||||
</MainText>
|
||||
<CallToAction href="https://app.twenty.com" value="Connect to Twenty" />
|
||||
</BaseEmail>
|
||||
);
|
||||
};
|
||||
|
||||
export default CleanInactiveWorkspaceEmail;
|
||||
@ -0,0 +1,28 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service';
|
||||
import { CleanInactiveWorkspaceJob } from 'src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.job';
|
||||
|
||||
@Command({
|
||||
name: 'clean-inactive-workspaces',
|
||||
description: 'Clean inactive workspaces',
|
||||
})
|
||||
export class CleanInactiveWorkspacesCommand extends CommandRunner {
|
||||
constructor(
|
||||
@Inject(MessageQueue.taskAssignedQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.messageQueueService.add<any>(
|
||||
CleanInactiveWorkspaceJob.name,
|
||||
{},
|
||||
{ retryLimit: 3 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service';
|
||||
import { cleanInactiveWorkspaceCronPattern } from 'src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.cron.pattern';
|
||||
import { CleanInactiveWorkspaceJob } from 'src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.job';
|
||||
|
||||
@Command({
|
||||
name: 'clean-inactive-workspace:cron:start',
|
||||
description: 'Starts a cron job to clean inactive workspaces',
|
||||
})
|
||||
export class StartCleanInactiveWorkspacesCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
@Inject(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.messageQueueService.addCron<undefined>(
|
||||
CleanInactiveWorkspaceJob.name,
|
||||
undefined,
|
||||
cleanInactiveWorkspaceCronPattern,
|
||||
{ retryLimit: 3 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service';
|
||||
import { cleanInactiveWorkspaceCronPattern } from 'src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.cron.pattern';
|
||||
import { CleanInactiveWorkspaceJob } from 'src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.job';
|
||||
|
||||
@Command({
|
||||
name: 'clean-inactive-workspace:cron:stop',
|
||||
description: 'Stops the clean inactive workspaces cron job',
|
||||
})
|
||||
export class StopCleanInactiveWorkspacesCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
@Inject(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.messageQueueService.removeCron(
|
||||
CleanInactiveWorkspaceJob.name,
|
||||
cleanInactiveWorkspaceCronPattern,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { HighlightedText } from 'src/emails/components/HighlightedText';
|
||||
import { MainText } from 'src/emails/components/MainText';
|
||||
import { Title } from 'src/emails/components/Title';
|
||||
import { BaseEmail } from 'src/emails/components/BaseEmail';
|
||||
import { CallToAction } from 'src/emails/components/CallToAction';
|
||||
|
||||
type DeleteInactiveWorkspaceEmailData = {
|
||||
daysSinceDead: number;
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
export const DeleteInactiveWorkspaceEmail = ({
|
||||
daysSinceDead,
|
||||
workspaceId,
|
||||
}: DeleteInactiveWorkspaceEmailData) => {
|
||||
return (
|
||||
<BaseEmail>
|
||||
<Title value="Dead Workspace 😵" />
|
||||
<HighlightedText value={`Inactive since ${daysSinceDead} day(s)`} />
|
||||
<MainText>
|
||||
Workspace <b>{workspaceId}</b> should be deleted.
|
||||
</MainText>
|
||||
<CallToAction href="https://app.twenty.com" value="Connect to Twenty" />
|
||||
</BaseEmail>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteInactiveWorkspaceEmail;
|
||||
@ -3,7 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity';
|
||||
import {
|
||||
FeatureFlagEntity,
|
||||
FeatureFlagKeys,
|
||||
} from 'src/core/feature-flag/feature-flag.entity';
|
||||
import { MessagingProducer } from 'src/workspace/messaging/producers/messaging-producer';
|
||||
import { MessagingUtilsService } from 'src/workspace/messaging/services/messaging-utils.service';
|
||||
|
||||
@ -32,7 +35,7 @@ export class GmailFullSyncCommand extends CommandRunner {
|
||||
): Promise<void> {
|
||||
const isMessagingEnabled = await this.featureFlagRepository.findOneBy({
|
||||
workspaceId: options.workspaceId,
|
||||
key: 'IS_MESSAGING_ENABLED',
|
||||
key: FeatureFlagKeys.IsMessagingEnabled,
|
||||
value: true,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user