diff --git a/packages/twenty-emails/src/emails/clean-inactive-workspaces.email.tsx b/packages/twenty-emails/src/emails/clean-inactive-workspaces.email.tsx deleted file mode 100644 index 0948a1b47..000000000 --- a/packages/twenty-emails/src/emails/clean-inactive-workspaces.email.tsx +++ /dev/null @@ -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 ( - - - <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; diff --git a/packages/twenty-emails/src/emails/clean-suspended-workspace.email.tsx b/packages/twenty-emails/src/emails/clean-suspended-workspace.email.tsx new file mode 100644 index 000000000..715768a71 --- /dev/null +++ b/packages/twenty-emails/src/emails/clean-suspended-workspace.email.tsx @@ -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> + ); +}; diff --git a/packages/twenty-emails/src/emails/delete-inactive-workspaces.email.tsx b/packages/twenty-emails/src/emails/delete-inactive-workspaces.email.tsx deleted file mode 100644 index c8ce348a3..000000000 --- a/packages/twenty-emails/src/emails/delete-inactive-workspaces.email.tsx +++ /dev/null @@ -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; diff --git a/packages/twenty-emails/src/emails/warn-suspended-workspace.email.tsx b/packages/twenty-emails/src/emails/warn-suspended-workspace.email.tsx new file mode 100644 index 000000000..10bf689ed --- /dev/null +++ b/packages/twenty-emails/src/emails/warn-suspended-workspace.email.tsx @@ -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> + ); +}; diff --git a/packages/twenty-emails/src/index.ts b/packages/twenty-emails/src/index.ts index 5bb9cd0c9..9c25bea49 100644 --- a/packages/twenty-emails/src/index.ts +++ b/packages/twenty-emails/src/index.ts @@ -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'; diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts index 85375708e..df1afd094 100644 --- a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts +++ b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/clean-inactive-workspaces.command.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/clean-inactive-workspaces.command.ts deleted file mode 100644 index 744f37bfb..000000000 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/clean-inactive-workspaces.command.ts +++ /dev/null @@ -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 }, - ); - } -} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/start-clean-inactive-workspaces.cron.command.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/start-clean-inactive-workspaces.cron.command.ts deleted file mode 100644 index d93cbf6b3..000000000 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/start-clean-inactive-workspaces.cron.command.ts +++ /dev/null @@ -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 } }, - ); - } -} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/stop-clean-inactive-workspaces.cron.command.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/stop-clean-inactive-workspaces.cron.command.ts deleted file mode 100644 index fa0fd6668..000000000 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/commands/stop-clean-inactive-workspaces.cron.command.ts +++ /dev/null @@ -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, - ); - } -} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.cron.pattern.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.cron.pattern.ts deleted file mode 100644 index c1fdacec3..000000000 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.cron.pattern.ts +++ /dev/null @@ -1 +0,0 @@ -export const cleanInactiveWorkspaceCronPattern = '0 22 * * *'; // Every day at 10pm diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.job.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.job.ts deleted file mode 100644 index ee883ec3a..000000000 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.job.ts +++ /dev/null @@ -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!`); - } -} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-suspended-workspaces.job.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-suspended-workspaces.job.ts index f05f3a050..f5c756997 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-suspended-workspaces.job.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/crons/clean-suspended-workspaces.job.ts @@ -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; } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module.ts index 34da9983e..fa18ef9ff 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module.ts @@ -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 {}