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:
martmull
2024-01-13 12:03:41 +01:00
committed by GitHub
parent 03bf597301
commit 49a9a2c2be
27 changed files with 594 additions and 24 deletions

View File

@ -0,0 +1 @@
export const cleanInactiveWorkspaceCronPattern = '0 22 * * *'; // Every day at 10pm

View File

@ -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!');
}
}

View File

@ -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;

View File

@ -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 },
);
}
}

View File

@ -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 },
);
}
}

View File

@ -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,
);
}
}

View File

@ -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;

View File

@ -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,
});