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:
@ -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;
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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;
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,6 +1,6 @@
|
|||||||
export * from './emails/clean-inactive-workspaces.email';
|
export * from './emails/clean-suspended-workspace.email';
|
||||||
export * from './emails/delete-inactive-workspaces.email';
|
|
||||||
export * from './emails/password-reset-link.email';
|
export * from './emails/password-reset-link.email';
|
||||||
export * from './emails/password-update-notify.email';
|
export * from './emails/password-update-notify.email';
|
||||||
export * from './emails/send-email-verification-link.email';
|
export * from './emails/send-email-verification-link.email';
|
||||||
export * from './emails/send-invite-link.email';
|
export * from './emails/send-invite-link.email';
|
||||||
|
export * from './emails/warn-suspended-workspace.email';
|
||||||
|
|||||||
@ -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 { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||||
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 { CleanSuspendedWorkspacesJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-suspended-workspaces.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 { 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';
|
||||||
@ -59,7 +58,6 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
|||||||
FavoriteModule,
|
FavoriteModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
CleanInactiveWorkspaceJob,
|
|
||||||
CleanSuspendedWorkspacesJob,
|
CleanSuspendedWorkspacesJob,
|
||||||
EmailSenderJob,
|
EmailSenderJob,
|
||||||
DataSeedDemoWorkspaceJob,
|
DataSeedDemoWorkspaceJob,
|
||||||
|
|||||||
@ -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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export const cleanInactiveWorkspaceCronPattern = '0 22 * * *'; // Every day at 10pm
|
|
||||||
@ -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!`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +1,17 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { render } from '@react-email/components';
|
||||||
import chunk from 'lodash.chunk';
|
import chunk from 'lodash.chunk';
|
||||||
|
import {
|
||||||
|
CleanSuspendedWorkspaceEmail,
|
||||||
|
WarnSuspendedWorkspaceEmail,
|
||||||
|
} from 'twenty-emails';
|
||||||
import { WorkspaceActivationStatus } from 'twenty-shared';
|
import { WorkspaceActivationStatus } from 'twenty-shared';
|
||||||
import { Repository } from 'typeorm';
|
import { 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';
|
||||||
|
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 { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
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 { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||||
@ -31,6 +37,7 @@ export class CleanSuspendedWorkspacesJob {
|
|||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
private readonly userVarsService: UserVarsService,
|
private readonly userVarsService: UserVarsService,
|
||||||
|
private readonly emailService: EmailService,
|
||||||
@InjectRepository(BillingSubscription, 'core')
|
@InjectRepository(BillingSubscription, 'core')
|
||||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||||
@InjectRepository(Workspace, 'core')
|
@InjectRepository(Workspace, 'core')
|
||||||
@ -92,7 +99,38 @@ export class CleanSuspendedWorkspacesJob {
|
|||||||
return false;
|
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 =
|
const workspaceMembers =
|
||||||
await this.userService.loadWorkspaceMembers(workspace);
|
await this.userService.loadWorkspaceMembers(workspace);
|
||||||
|
|
||||||
@ -108,6 +146,14 @@ export class CleanSuspendedWorkspacesJob {
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
} else {
|
} 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);
|
const workspaceMembersChunks = chunk(workspaceMembers, 5);
|
||||||
|
|
||||||
for (const workspaceMembersChunk of workspaceMembersChunks) {
|
for (const workspaceMembersChunk of workspaceMembersChunks) {
|
||||||
@ -119,25 +165,61 @@ export class CleanSuspendedWorkspacesJob {
|
|||||||
key: USER_WORKSPACE_DELETION_WARNING_SENT_KEY,
|
key: USER_WORKSPACE_DELETION_WARNING_SENT_KEY,
|
||||||
value: true,
|
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;
|
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) {
|
async informWorkspaceMembersAndDeleteWorkspace(workspace: Workspace) {
|
||||||
const workspaceMembers =
|
const workspaceMembers =
|
||||||
await this.userService.loadWorkspaceMembers(workspace);
|
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);
|
const workspaceMembersChunks = chunk(workspaceMembers, 5);
|
||||||
|
|
||||||
for (const workspaceMembersChunk of workspaceMembersChunks) {
|
for (const workspaceMembersChunk of workspaceMembersChunks) {
|
||||||
@ -148,12 +230,9 @@ export class CleanSuspendedWorkspacesJob {
|
|||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
key: USER_WORKSPACE_DELETION_WARNING_SENT_KEY,
|
key: USER_WORKSPACE_DELETION_WARNING_SENT_KEY,
|
||||||
});
|
});
|
||||||
}),
|
|
||||||
|
|
||||||
// TODO: issue #285
|
await this.sendCleaningEmail(workspaceMember, workspace.displayName);
|
||||||
// send email informing about deletion (cci @twenty.com)
|
}),
|
||||||
// remove clean-inactive-workspace.job.ts and .. files
|
|
||||||
// add new env var in infra
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,7 +276,7 @@ export class CleanSuspendedWorkspacesJob {
|
|||||||
workspaceInactivity > this.inactiveDaysBeforeWarn &&
|
workspaceInactivity > this.inactiveDaysBeforeWarn &&
|
||||||
workspaceInactivity <= this.inactiveDaysBeforeDelete
|
workspaceInactivity <= this.inactiveDaysBeforeDelete
|
||||||
) {
|
) {
|
||||||
await this.warnWorkspaceMembers(workspace);
|
await this.warnWorkspaceMembers(workspace, workspaceInactivity);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,11 +4,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
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 { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||||
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 { 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 { 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 { 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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -16,12 +13,6 @@ import { StopCleanInactiveWorkspacesCronCommand } from 'src/engine/workspace-man
|
|||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
DataSourceModule,
|
DataSourceModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [DeleteWorkspacesCommand, CleanSuspendedWorkspacesCronCommand],
|
||||||
DeleteWorkspacesCommand,
|
|
||||||
CleanInactiveWorkspacesCommand,
|
|
||||||
StartCleanInactiveWorkspacesCronCommand,
|
|
||||||
StopCleanInactiveWorkspacesCronCommand,
|
|
||||||
CleanSuspendedWorkspacesCronCommand,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class WorkspaceCleanerModule {}
|
export class WorkspaceCleanerModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user