add new emails in suspended workspace cleaning job (#9834)

From: Felix from Twenty <noreply@yourdomain.com>
Subject: Action needed to prevent workspace deletion
<img width="400" alt="Screenshot 2025-01-24 at 16 31 35"
src="https://github.com/user-attachments/assets/8350a499-6815-4b00-a4fb-615b2a3b60e0"
/>


From: Felix from Twenty <noreply@yourdomain.com>
Subject: Your workspace has been deleted
<img width="456" alt="Screenshot 2025-01-24 at 16 33 15"
src="https://github.com/user-attachments/assets/7e392e7c-519c-4b38-ae8c-ab3c9dd72c24"
/>



closes [284](https://github.com/twentyhq/core-team-issues/issues/284) &
[285](https://github.com/twentyhq/core-team-issues/issues/285) - [parent
issue](https://github.com/twentyhq/core-team-issues/issues/179)

---------

Co-authored-by: etiennejouan <jouan.etienne@gmail.com>
This commit is contained in:
Etienne
2025-01-24 18:37:06 +01:00
committed by GitHub
parent 07dec36976
commit aacbf11435
13 changed files with 186 additions and 494 deletions

View File

@ -1,48 +0,0 @@
import { BaseEmail } from 'src/components/BaseEmail';
import { CallToAction } from 'src/components/CallToAction';
import { HighlightedText } from 'src/components/HighlightedText';
import { MainText } from 'src/components/MainText';
import { Title } from 'src/components/Title';
type CleanInactiveWorkspaceEmailProps = {
daysLeft: number;
userName: string;
workspaceDisplayName: string;
};
export const CleanInactiveWorkspaceEmail = ({
daysLeft,
userName,
workspaceDisplayName,
}: CleanInactiveWorkspaceEmailProps) => {
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,41 @@
import { BaseEmail } from 'src/components/BaseEmail';
import { CallToAction } from 'src/components/CallToAction';
import { MainText } from 'src/components/MainText';
import { Title } from 'src/components/Title';
type CleanSuspendedWorkspaceEmailProps = {
inactiveDaysBeforeDelete: number;
userName: string;
workspaceDisplayName: string | undefined;
};
export const CleanSuspendedWorkspaceEmail = ({
inactiveDaysBeforeDelete,
userName,
workspaceDisplayName,
}: CleanSuspendedWorkspaceEmailProps) => {
const helloString = userName?.length > 1 ? `Hello ${userName}` : 'Hello';
return (
<BaseEmail width={333}>
<Title value="Deleted Workspace 🥺" />
<MainText>
{helloString},
<br />
<br />
Your workspace <b>{workspaceDisplayName}</b> has been deleted as your
subscription expired {inactiveDaysBeforeDelete} days ago.
<br />
<br />
All data in this workspace has been permanently deleted.
<br />
<br />
If you wish to use Twenty again, you can create a new workspace.
</MainText>
<CallToAction
href="https://app.twenty.com/"
value="Create a new workspace"
/>
</BaseEmail>
);
};

View File

@ -1,40 +0,0 @@
import { Column, Row, Section } from '@react-email/components';
import { BaseEmail } from 'src/components/BaseEmail';
import { MainText } from 'src/components/MainText';
import { Title } from 'src/components/Title';
type DeleteInactiveWorkspaceEmailData = {
daysSinceInactive: number;
workspaceId: string;
};
export const DeleteInactiveWorkspaceEmail = (
workspacesToDelete: DeleteInactiveWorkspaceEmailData[],
) => {
const minDaysSinceInactive = Math.min(
...workspacesToDelete.map(
(workspaceToDelete) => workspaceToDelete.daysSinceInactive,
),
);
return (
<BaseEmail width={350}>
<Title value="Dead Workspaces 😵 that should be deleted" />
<MainText>
List of <b>workspaceIds</b> inactive since at least{' '}
<b>{minDaysSinceInactive} days</b>:
<Section>
{workspacesToDelete.map((workspaceToDelete) => {
return (
<Row key={workspaceToDelete.workspaceId}>
<Column>{workspaceToDelete.workspaceId}</Column>
</Row>
);
})}
</Section>
</MainText>
</BaseEmail>
);
};
export default DeleteInactiveWorkspaceEmail;

View File

@ -0,0 +1,49 @@
import { BaseEmail } from 'src/components/BaseEmail';
import { CallToAction } from 'src/components/CallToAction';
import { MainText } from 'src/components/MainText';
import { Title } from 'src/components/Title';
type WarnSuspendedWorkspaceEmailProps = {
daysSinceInactive: number;
inactiveDaysBeforeDelete: number;
userName: string;
workspaceDisplayName: string | undefined;
};
export const WarnSuspendedWorkspaceEmail = ({
daysSinceInactive,
inactiveDaysBeforeDelete,
userName,
workspaceDisplayName,
}: WarnSuspendedWorkspaceEmailProps) => {
const daysLeft = inactiveDaysBeforeDelete - daysSinceInactive;
const dayOrDays = daysLeft > 1 ? 'days' : 'day';
const remainingDays = daysLeft > 0 ? `${daysLeft} ` : '';
const helloString = userName?.length > 1 ? `Hello ${userName}` : 'Hello';
return (
<BaseEmail width={333}>
<Title value="Suspended Workspace 😴" />
<MainText>
{helloString},
<br />
<br />
It appears that your workspace <b>{workspaceDisplayName}</b> has been
suspended for {daysSinceInactive} days.
<br />
<br />
The workspace will be deactivated in {remainingDays} {dayOrDays}, and
all its data will be deleted.
<br />
<br />
If you wish to continue using Twenty, please update your subscription
within the next {remainingDays} {dayOrDays}.
</MainText>
<CallToAction
href="https://app.twenty.com/settings/billing"
value="Update your subscription"
/>
</BaseEmail>
);
};

View File

@ -1,6 +1,6 @@
export * from './emails/clean-inactive-workspaces.email';
export * from './emails/delete-inactive-workspaces.email';
export * from './emails/clean-suspended-workspace.email';
export * from './emails/password-reset-link.email';
export * from './emails/password-update-notify.email';
export * from './emails/send-email-verification-link.email';
export * from './emails/send-invite-link.email';
export * from './emails/warn-suspended-workspace.email';

View File

@ -20,7 +20,6 @@ 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 { 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 { 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';
@ -59,7 +58,6 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
FavoriteModule,
],
providers: [
CleanInactiveWorkspaceJob,
CleanSuspendedWorkspacesJob,
EmailSenderJob,
DataSeedDemoWorkspaceJob,

View File

@ -1,43 +0,0 @@
import { Command, CommandRunner, Option } 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 { CleanInactiveWorkspaceJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.job';
export type CleanInactiveWorkspacesCommandOptions = {
dryRun: boolean;
};
@Command({
name: 'clean-inactive-workspaces',
description: 'Clean inactive workspaces',
})
export class CleanInactiveWorkspacesCommand extends CommandRunner {
constructor(
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {
super();
}
@Option({
flags: '-d, --dry-run [dry run]',
description: 'Dry run: Log cleaning actions without executing them.',
required: false,
})
dryRun(value: string): boolean {
return Boolean(value);
}
async run(
_passedParam: string[],
options: CleanInactiveWorkspacesCommandOptions,
): Promise<void> {
await this.messageQueueService.add<CleanInactiveWorkspacesCommandOptions>(
CleanInactiveWorkspaceJob.name,
options,
{ retryLimit: 3 },
);
}
}

View File

@ -1,28 +0,0 @@
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 { cleanInactiveWorkspaceCronPattern } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.cron.pattern';
import { CleanInactiveWorkspaceJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.job';
@Command({
name: 'clean-inactive-workspaces:cron:start',
description: 'Starts a cron job to clean inactive workspaces',
})
export class StartCleanInactiveWorkspacesCronCommand extends CommandRunner {
constructor(
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {
super();
}
async run(): Promise<void> {
await this.messageQueueService.addCron<undefined>(
CleanInactiveWorkspaceJob.name,
undefined,
{ retryLimit: 3, repeat: { pattern: cleanInactiveWorkspaceCronPattern } },
);
}
}

View File

@ -1,27 +0,0 @@
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 { cleanInactiveWorkspaceCronPattern } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.cron.pattern';
import { CleanInactiveWorkspaceJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.job';
@Command({
name: 'clean-inactive-workspaces:cron:stop',
description: 'Stops the clean inactive workspaces cron job',
})
export class StopCleanInactiveWorkspacesCronCommand extends CommandRunner {
constructor(
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {
super();
}
async run(): Promise<void> {
await this.messageQueueService.removeCron(
CleanInactiveWorkspaceJob.name,
cleanInactiveWorkspaceCronPattern,
);
}
}

View File

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

View File

@ -1,279 +0,0 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { render } from '@react-email/render';
import {
CleanInactiveWorkspaceEmail,
DeleteInactiveWorkspaceEmail,
} from 'twenty-emails';
import { In, Repository } from 'typeorm';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { 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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { CleanInactiveWorkspacesCommandOptions } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-inactive-workspaces.command';
import { getDryRunLogHeader } from 'src/utils/get-dry-run-log-header';
const MILLISECONDS_IN_ONE_DAY = 1000 * 3600 * 24;
type WorkspaceToDeleteData = {
workspaceId: string;
daysSinceInactive: number;
};
@Processor(MessageQueue.cronQueue)
export class CleanInactiveWorkspaceJob {
private readonly logger = new Logger(CleanInactiveWorkspaceJob.name);
private readonly inactiveDaysBeforeDelete;
private readonly inactiveDaysBeforeEmail;
constructor(
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly dataSourceService: DataSourceService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly typeORMService: TypeORMService,
private readonly userService: UserService,
private readonly emailService: EmailService,
private readonly environmentService: EnvironmentService,
) {
this.inactiveDaysBeforeDelete = this.environmentService.get(
'WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION',
);
this.inactiveDaysBeforeEmail = this.environmentService.get(
'WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION',
);
}
async getMostRecentUpdatedAt(
dataSource: DataSourceEntity,
objectsMetadata: ObjectMetadataEntity[],
): Promise<Date> {
const tableNames = objectsMetadata
.filter(
(objectMetadata) =>
objectMetadata.workspaceId === dataSource.workspaceId,
)
.map((objectMetadata) => computeObjectTargetTable(objectMetadata));
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,
isDryRun: boolean,
) {
const workspace = await this.workspaceRepository.findOne({
where: { id: dataSource.workspaceId },
});
if (!workspace) {
this.logger.error(
`Workspace with id ${dataSource.workspaceId} not found in database`,
);
return;
}
const workspaceMembers =
await this.userService.loadWorkspaceMembers(workspace);
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(
`${getDryRunLogHeader(isDryRun)}Sending workspace ${
dataSource.workspaceId
} inactive since ${daysSinceInactive} days emails to users ['${workspaceMembers
.map((workspaceUser) => workspaceUser.userEmail)
.join(', ')}']`,
);
if (isDryRun) {
return;
}
workspaceMembers.forEach((workspaceMember) => {
const emailData = {
daysLeft: this.inactiveDaysBeforeDelete - daysSinceInactive,
userName: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
workspaceDisplayName: `${displayName}`,
};
const emailTemplate = CleanInactiveWorkspaceEmail(emailData);
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
this.emailService.send({
to: workspaceMember.userEmail,
bcc: this.environmentService.get('EMAIL_SYSTEM_ADDRESS'),
from: `${this.environmentService.get(
'EMAIL_FROM_NAME',
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
subject: 'Action Needed to Prevent Workspace Deletion',
html,
text,
});
});
}
chunkArray(array: any[], chunkSize = 6): any[][] {
const chunkedArray: any[][] = [];
let index = 0;
while (index < array.length) {
chunkedArray.push(array.slice(index, index + chunkSize));
index += chunkSize;
}
return chunkedArray;
}
async sendDeleteWorkspaceEmail(
workspacesToDelete: WorkspaceToDeleteData[],
isDryRun: boolean,
): Promise<void> {
this.logger.log(
`${getDryRunLogHeader(
isDryRun,
)}Sending email to delete workspaces "${workspacesToDelete
.map((workspaceToDelete) => workspaceToDelete.workspaceId)
.join('", "')}"`,
);
if (isDryRun || workspacesToDelete.length === 0) {
return;
}
const emailTemplate = DeleteInactiveWorkspaceEmail(workspacesToDelete);
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
await this.emailService.send({
to: this.environmentService.get('EMAIL_SYSTEM_ADDRESS'),
from: `${this.environmentService.get(
'EMAIL_FROM_NAME',
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
subject: 'Action Needed to Delete Workspaces',
html,
text,
});
}
@Process(CleanInactiveWorkspaceJob.name)
async handle(data: CleanInactiveWorkspacesCommandOptions): Promise<void> {
const isDryRun = data.dryRun || false;
const workspacesToDelete: WorkspaceToDeleteData[] = [];
this.logger.log(`${getDryRunLogHeader(isDryRun)}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 dataSourcesChunks = this.chunkArray(dataSources);
this.logger.log(
`${getDryRunLogHeader(isDryRun)}On ${
dataSources.length
} workspaces divided in ${dataSourcesChunks.length} chunks...`,
);
for (const dataSourcesChunk of dataSourcesChunks) {
const objectsMetadata = await this.objectMetadataService.findMany({
where: {
dataSourceId: In(dataSourcesChunk.map((dataSource) => dataSource.id)),
},
});
for (const dataSource of dataSourcesChunk) {
this.logger.log(
`${getDryRunLogHeader(isDryRun)}Cleaning Workspace ${
dataSource.workspaceId
}`,
);
const mostRecentUpdatedAt = await this.getMostRecentUpdatedAt(
dataSource,
objectsMetadata,
);
const daysSinceInactive = Math.floor(
(new Date().getTime() - mostRecentUpdatedAt.getTime()) /
MILLISECONDS_IN_ONE_DAY,
);
if (daysSinceInactive > this.inactiveDaysBeforeDelete) {
workspacesToDelete.push({
daysSinceInactive: daysSinceInactive,
workspaceId: `${dataSource.workspaceId}`,
});
} else if (daysSinceInactive > this.inactiveDaysBeforeEmail) {
await this.warnWorkspaceUsers(
dataSource,
daysSinceInactive,
isDryRun,
);
}
}
}
await this.sendDeleteWorkspaceEmail(workspacesToDelete, isDryRun);
this.logger.log(`${getDryRunLogHeader(isDryRun)}job done!`);
}
}

View File

@ -1,11 +1,17 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { render } from '@react-email/components';
import chunk from 'lodash.chunk';
import {
CleanSuspendedWorkspaceEmail,
WarnSuspendedWorkspaceEmail,
} from 'twenty-emails';
import { WorkspaceActivationStatus } from 'twenty-shared';
import { Repository } from 'typeorm';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
@ -31,6 +37,7 @@ export class CleanSuspendedWorkspacesJob {
private readonly environmentService: EnvironmentService,
private readonly userService: UserService,
private readonly userVarsService: UserVarsService,
private readonly emailService: EmailService,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(Workspace, 'core')
@ -92,7 +99,38 @@ export class CleanSuspendedWorkspacesJob {
return false;
}
async warnWorkspaceMembers(workspace: Workspace) {
async sendWarningEmail(
workspaceMember: WorkspaceMemberWorkspaceEntity,
workspaceDisplayName: string | undefined,
daysSinceInactive: number,
) {
const emailData = {
daysSinceInactive,
inactiveDaysBeforeDelete: this.inactiveDaysBeforeDelete,
userName: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
workspaceDisplayName: `${workspaceDisplayName}`,
};
const emailTemplate = WarnSuspendedWorkspaceEmail(emailData);
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
this.emailService.send({
to: workspaceMember.userEmail,
bcc: this.environmentService.get('EMAIL_SYSTEM_ADDRESS'),
from: `${this.environmentService.get(
'EMAIL_FROM_NAME',
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
subject: 'Action needed to prevent workspace deletion',
html,
text,
});
}
async warnWorkspaceMembers(workspace: Workspace, daysSinceInactive: number) {
const workspaceMembers =
await this.userService.loadWorkspaceMembers(workspace);
@ -108,6 +146,14 @@ export class CleanSuspendedWorkspacesJob {
return;
} else {
this.logger.log(
`Sending ${workspace.id} ${
workspace.displayName
} suspended since ${daysSinceInactive} days emails to users ['${workspaceMembers
.map((workspaceUser) => workspaceUser.userEmail)
.join(', ')}']`,
);
const workspaceMembersChunks = chunk(workspaceMembers, 5);
for (const workspaceMembersChunk of workspaceMembersChunks) {
@ -119,25 +165,61 @@ export class CleanSuspendedWorkspacesJob {
key: USER_WORKSPACE_DELETION_WARNING_SENT_KEY,
value: true,
});
await this.sendWarningEmail(
workspaceMember,
workspace.displayName,
daysSinceInactive,
);
}),
);
}
// 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 sendCleaningEmail(
workspaceMember: WorkspaceMemberWorkspaceEntity,
workspaceDisplayName: string | undefined,
) {
const emailData = {
inactiveDaysBeforeDelete: this.inactiveDaysBeforeDelete,
userName: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
workspaceDisplayName: `${workspaceDisplayName}`,
};
const emailTemplate = CleanSuspendedWorkspaceEmail(emailData);
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
this.emailService.send({
to: workspaceMember.userEmail,
bcc: this.environmentService.get('EMAIL_SYSTEM_ADDRESS'),
from: `${this.environmentService.get(
'EMAIL_FROM_NAME',
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
subject: 'Your workspace has been deleted',
html,
text,
});
}
async informWorkspaceMembersAndDeleteWorkspace(workspace: Workspace) {
const workspaceMembers =
await this.userService.loadWorkspaceMembers(workspace);
this.logger.log(
`Sending workspace ${workspace.id} ${
workspace.displayName
} deletion emails to users ['${workspaceMembers
.map((workspaceUser) => workspaceUser.userEmail)
.join(', ')}']`,
);
const workspaceMembersChunks = chunk(workspaceMembers, 5);
for (const workspaceMembersChunk of workspaceMembersChunks) {
@ -148,12 +230,9 @@ export class CleanSuspendedWorkspacesJob {
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.sendCleaningEmail(workspaceMember, workspace.displayName);
}),
);
}
@ -197,7 +276,7 @@ export class CleanSuspendedWorkspacesJob {
workspaceInactivity > this.inactiveDaysBeforeWarn &&
workspaceInactivity <= this.inactiveDaysBeforeDelete
) {
await this.warnWorkspaceMembers(workspace);
await this.warnWorkspaceMembers(workspace, workspaceInactivity);
return;
}

View File

@ -4,11 +4,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
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 { 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 { StopCleanInactiveWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/stop-clean-inactive-workspaces.cron.command';
@Module({
imports: [
@ -16,12 +13,6 @@ import { StopCleanInactiveWorkspacesCronCommand } from 'src/engine/workspace-man
WorkspaceModule,
DataSourceModule,
],
providers: [
DeleteWorkspacesCommand,
CleanInactiveWorkspacesCommand,
StartCleanInactiveWorkspacesCronCommand,
StopCleanInactiveWorkspacesCronCommand,
CleanSuspendedWorkspacesCronCommand,
],
providers: [DeleteWorkspacesCommand, CleanSuspendedWorkspacesCronCommand],
})
export class WorkspaceCleanerModule {}