From 49a9a2c2be8ff52bc19f589d309be2cee7b177b3 Mon Sep 17 00:00:00 2001 From: martmull Date: Sat, 13 Jan 2024 12:03:41 +0100 Subject: [PATCH] 2252 build a script to cleanup inactive workspaces (#3307) * Add cron to message queue interfaces * Add command to launch cron job * Add command to stop cron job * Update clean inactive workspaces job * Add react-email * WIP * Fix import error * Rename services * Update logging * Update email template * Update email template * Add Base Email template * Move to proper place * Remove test files * Update logo * Add email theme * Revert "Remove test files" This reverts commit fe062dd05166b95125cf99f2165cc20efb6c275a. * Add email theme 2 * Revert "Revert "Remove test files"" This reverts commit 6c6471273ad765788f2eaf5a5614209edfb965ce. * Revert "Revert "Revert "Remove test files""" This reverts commit f851333c24e9cfe3f425c9cbbd1e079efce5c3dd. * Revert "Revert "Revert "Revert "Remove test files"""" This reverts commit 7838e19e88e269026e24803f26cd52b467b4ef36. * Fix theme * Reorganize files * Update clean inactive workspaces job * Use env variable to define inactive days * Remove FROM variable * Use feature flag * Fix cron command * Remove useless variable * Reorganize files * Refactor some code * Update email template * Update email object * Remove verbose log * Code review returns * Code review returns * Simplify handle * Code review returns * Review --------- Co-authored-by: Charles Bochet --- .../self-hosting/environment-variables.mdx | 13 +- packages/twenty-server/.env.example | 7 +- packages/twenty-server/src/command.module.ts | 6 + packages/twenty-server/src/command.ts | 2 +- .../core/feature-flag/feature-flag.entity.ts | 11 +- .../src/core/user/services/user.service.ts | 15 ++ .../typeorm-seeds/core/demo/feature-flags.ts | 8 +- .../typeorm-seeds/core/feature-flags.ts | 17 +- .../integrations/email/email-sender.job.ts | 6 +- .../is-strictly-lower-than.decorator.ts | 32 +++ .../environment/environment.service.ts | 33 +++ .../environment/environment.validation.ts | 16 ++ .../logger/interfaces/logger.interface.ts | 3 + .../logger/logger.module-factory.ts | 2 + .../src/integrations/logger/logger.module.ts | 13 +- .../interfaces/message-queue-job.interface.ts | 2 +- .../integrations/message-queue/jobs.module.ts | 14 +- .../data-source/data-source.service.ts | 8 +- .../object-metadata.service.ts | 15 ++ .../clean-inactive-workspace.cron.pattern.ts | 1 + .../clean-inactive-workspace.job.ts | 221 ++++++++++++++++++ .../clean-inactive-workspaces.email.tsx | 50 ++++ .../clean-inactive-workspaces.command.ts | 28 +++ ...-clean-inactive-workspaces.cron.command.ts | 30 +++ ...-clean-inactive-workspaces.cron.command.ts | 28 +++ .../delete-inactive-workspaces.email.tsx | 30 +++ .../commands/gmail-full-sync.command.ts | 7 +- 27 files changed, 594 insertions(+), 24 deletions(-) create mode 100644 packages/twenty-server/src/integrations/environment/decorators/is-strictly-lower-than.decorator.ts create mode 100644 packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.cron.pattern.ts create mode 100644 packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.job.ts create mode 100644 packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspaces.email.tsx create mode 100644 packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/commands/clean-inactive-workspaces.command.ts create mode 100644 packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/commands/start-clean-inactive-workspaces.cron.command.ts create mode 100644 packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/commands/stop-clean-inactive-workspaces.cron.command.ts create mode 100644 packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/delete-inactive-workspaces.email.tsx diff --git a/packages/twenty-docs/docs/start/self-hosting/environment-variables.mdx b/packages/twenty-docs/docs/start/self-hosting/environment-variables.mdx index db0a927e7..b372169d4 100644 --- a/packages/twenty-docs/docs/start/self-hosting/environment-variables.mdx +++ b/packages/twenty-docs/docs/start/self-hosting/environment-variables.mdx @@ -62,6 +62,9 @@ import TabItem from '@theme/TabItem'; ### Email @@ -159,3 +163,10 @@ import TabItem from '@theme/TabItem'; ['DEBUG_MODE', 'true', 'Activate debug mode'], ['SIGN_IN_PREFILLED', 'true', 'Prefill the Signin form for usage in a demo or dev environment'], ]}> + +### Workspace Cleaning + + diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index be10708fd..66a2563ad 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -34,13 +34,18 @@ SIGN_IN_PREFILLED=true # LOGGER_DRIVER=console # EXCEPTION_HANDLER_DRIVER=sentry # SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx -# LOG_LEVEL=error,warn +# LOG_LEVELS=error,warn # MESSAGE_QUEUE_TYPE=pg-boss # REDIS_HOST=127.0.0.1 # REDIS_PORT=6379 # DEMO_WORKSPACE_IDS=REPLACE_ME_WITH_A_RANDOM_UUID # SERVER_URL=http://localhost:3000 +# WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION=30 +# WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION=60 # Email Server Settings, see this doc for more info: https://docs.twenty.com/start/self-hosting/environment-variables +# EMAIL_FROM_ADDRESS=noreply@yourdomain.com +# EMAIL_SYSTEM_ADDRESS=system@yourdomain.com +# EMAIL_FROM_NAME='John from YourDomain' # EMAIL_DRIVER=logger # EMAIL_SMTP_HOST= # EMAIL_SMTP_PORT= diff --git a/packages/twenty-server/src/command.module.ts b/packages/twenty-server/src/command.module.ts index 74b29fbc7..9765e70b5 100644 --- a/packages/twenty-server/src/command.module.ts +++ b/packages/twenty-server/src/command.module.ts @@ -2,6 +2,9 @@ import { Module } from '@nestjs/common'; import { DatabaseCommandModule } from 'src/database/commands/database-command.module'; import { FetchWorkspaceMessagesCommandsModule } from 'src/workspace/messaging/commands/fetch-workspace-messages-commands.module'; +import { StartCleanInactiveWorkspacesCronCommand } from 'src/workspace/cron/clean-inactive-workspaces/commands/start-clean-inactive-workspaces.cron.command'; +import { StopCleanInactiveWorkspacesCronCommand } from 'src/workspace/cron/clean-inactive-workspaces/commands/stop-clean-inactive-workspaces.cron.command'; +import { CleanInactiveWorkspacesCommand } from 'src/workspace/cron/clean-inactive-workspaces/commands/clean-inactive-workspaces.command'; import { WorkspaceHealthCommandModule } from 'src/workspace/workspace-health/commands/workspace-health-command.module'; import { AppModule } from './app.module'; @@ -14,6 +17,9 @@ import { WorkspaceSyncMetadataCommandsModule } from './workspace/workspace-sync- WorkspaceSyncMetadataCommandsModule, DatabaseCommandModule, FetchWorkspaceMessagesCommandsModule, + StartCleanInactiveWorkspacesCronCommand, + StopCleanInactiveWorkspacesCronCommand, + CleanInactiveWorkspacesCommand, WorkspaceHealthCommandModule, ], }) diff --git a/packages/twenty-server/src/command.ts b/packages/twenty-server/src/command.ts index 981ac1e2d..ffee901de 100644 --- a/packages/twenty-server/src/command.ts +++ b/packages/twenty-server/src/command.ts @@ -4,6 +4,6 @@ import { CommandModule } from './command.module'; async function bootstrap() { // TODO: inject our own logger service to handle the output (Sentry, etc.) - await CommandFactory.run(CommandModule, ['warn', 'error']); + await CommandFactory.run(CommandModule, ['warn', 'error', 'log']); } bootstrap(); diff --git a/packages/twenty-server/src/core/feature-flag/feature-flag.entity.ts b/packages/twenty-server/src/core/feature-flag/feature-flag.entity.ts index 4216a7430..4b3df346d 100644 --- a/packages/twenty-server/src/core/feature-flag/feature-flag.entity.ts +++ b/packages/twenty-server/src/core/feature-flag/feature-flag.entity.ts @@ -13,6 +13,15 @@ import { IDField } from '@ptc-org/nestjs-query-graphql'; import { Workspace } from 'src/core/workspace/workspace.entity'; +export enum FeatureFlagKeys { + IsRelationFieldTypeEnabled = 'IS_RELATION_FIELD_TYPE_ENABLED', + IsMessagingEnabled = 'IS_MESSAGING_ENABLED', + IsNoteCreateImagesEnabled = 'IS_NOTE_CREATE_IMAGES_ENABLED', + IsSelectFieldTypeEnabled = 'IS_SELECT_FIELD_TYPE_ENABLED', + IsRatingFieldTypeEnabled = 'IS_RATING_FIELD_TYPE_ENABLED', + IsWorkspaceCleanable = 'IS_WORKSPACE_CLEANABLE', +} + @Entity({ name: 'featureFlag', schema: 'core' }) @ObjectType('FeatureFlag') @Unique('IndexOnKeyAndWorkspaceIdUnique', ['key', 'workspaceId']) @@ -23,7 +32,7 @@ export class FeatureFlagEntity { @Field() @Column({ nullable: false, type: 'text' }) - key: string; + key: FeatureFlagKeys; @Field() @Column({ nullable: false, type: 'uuid' }) diff --git a/packages/twenty-server/src/core/user/services/user.service.ts b/packages/twenty-server/src/core/user/services/user.service.ts index 41e80450d..39b816d5b 100644 --- a/packages/twenty-server/src/core/user/services/user.service.ts +++ b/packages/twenty-server/src/core/user/services/user.service.ts @@ -8,6 +8,7 @@ import { User } from 'src/core/user/user.entity'; import { WorkspaceMember } from 'src/core/user/dtos/workspace-member.dto'; import { DataSourceService } from 'src/metadata/data-source/data-source.service'; import { TypeORMService } from 'src/database/typeorm/typeorm.service'; +import { DataSourceEntity } from 'src/metadata/data-source/data-source.entity'; export class UserService extends TypeOrmQueryService { constructor( @@ -48,6 +49,20 @@ export class UserService extends TypeOrmQueryService { return userWorkspaceMember; } + async loadWorkspaceMembers(dataSource: DataSourceEntity) { + const workspaceDataSource = + await this.typeORMService.connectToDataSource(dataSource); + + return await workspaceDataSource?.query( + ` + SELECT * + FROM ${dataSource.schema}."workspaceMember" AS s + INNER JOIN core.user AS u + ON s."userId" = u.id + `, + ); + } + async createWorkspaceMember(user: User, avatarUrl?: string) { const dataSourceMetadata = await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/demo/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/demo/feature-flags.ts index 60ed256d0..b9f51f5b4 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/demo/feature-flags.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/demo/feature-flags.ts @@ -1,5 +1,7 @@ import { DataSource } from 'typeorm'; +import { FeatureFlagKeys } from 'src/core/feature-flag/feature-flag.entity'; + const tableName = 'featureFlag'; export const seedFeatureFlags = async ( @@ -14,17 +16,17 @@ export const seedFeatureFlags = async ( .orIgnore() .values([ { - key: 'IS_RELATION_FIELD_TYPE_ENABLED', + key: FeatureFlagKeys.IsRelationFieldTypeEnabled, workspaceId: workspaceId, value: true, }, { - key: 'IS_SELECT_FIELD_TYPE_ENABLED', + key: FeatureFlagKeys.IsSelectFieldTypeEnabled, workspaceId: workspaceId, value: true, }, { - key: 'IS_RATING_FIELD_TYPE_ENABLED', + key: FeatureFlagKeys.IsRatingFieldTypeEnabled, workspaceId: workspaceId, value: false, }, diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts index a72fa0ab1..4ea743158 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts @@ -1,5 +1,7 @@ import { DataSource } from 'typeorm'; +import { FeatureFlagKeys } from 'src/core/feature-flag/feature-flag.entity'; + const tableName = 'featureFlag'; export const seedFeatureFlags = async ( @@ -14,27 +16,32 @@ export const seedFeatureFlags = async ( .orIgnore() .values([ { - key: 'IS_RELATION_FIELD_TYPE_ENABLED', + key: FeatureFlagKeys.IsRelationFieldTypeEnabled, workspaceId: workspaceId, value: true, }, { - key: 'IS_MESSAGING_ENABLED', + key: FeatureFlagKeys.IsMessagingEnabled, workspaceId: workspaceId, value: true, }, { - key: 'IS_NOTE_CREATE_IMAGES_ENABLED', + key: FeatureFlagKeys.IsNoteCreateImagesEnabled, workspaceId: workspaceId, value: true, }, { - key: 'IS_SELECT_FIELD_TYPE_ENABLED', + key: FeatureFlagKeys.IsSelectFieldTypeEnabled, workspaceId: workspaceId, value: true, }, { - key: 'IS_RATING_FIELD_TYPE_ENABLED', + key: FeatureFlagKeys.IsRatingFieldTypeEnabled, + workspaceId: workspaceId, + value: true, + }, + { + key: FeatureFlagKeys.IsWorkspaceCleanable, workspaceId: workspaceId, value: true, }, diff --git a/packages/twenty-server/src/integrations/email/email-sender.job.ts b/packages/twenty-server/src/integrations/email/email-sender.job.ts index d6ad62c37..c44b57e55 100644 --- a/packages/twenty-server/src/integrations/email/email-sender.job.ts +++ b/packages/twenty-server/src/integrations/email/email-sender.job.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { SendMailOptions } from 'nodemailer'; @@ -8,11 +8,11 @@ import { EmailSenderService } from 'src/integrations/email/email-sender.service' @Injectable() export class EmailSenderJob implements MessageQueueJob { + private readonly logger = new Logger(EmailSenderJob.name); constructor(private readonly emailSenderService: EmailSenderService) {} async handle(data: SendMailOptions): Promise { - process.stdout.write(`Sending email to ${data.to} ...`); await this.emailSenderService.send(data); - console.log(' done!'); + this.logger.log(`Email to ${data.to} sent`); } } diff --git a/packages/twenty-server/src/integrations/environment/decorators/is-strictly-lower-than.decorator.ts b/packages/twenty-server/src/integrations/environment/decorators/is-strictly-lower-than.decorator.ts new file mode 100644 index 000000000..0ff049bbb --- /dev/null +++ b/packages/twenty-server/src/integrations/environment/decorators/is-strictly-lower-than.decorator.ts @@ -0,0 +1,32 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, +} from 'class-validator'; + +export const IsStrictlyLowerThan = ( + property: string, + validationOptions?: ValidationOptions, +) => { + return (object: object, propertyName: string) => { + registerDecorator({ + name: 'isStrictlyLowerThan', + target: object.constructor, + propertyName: propertyName, + constraints: [property], + options: validationOptions, + validator: { + validate(value: any, args: ValidationArguments) { + const [relatedPropertyName] = args.constraints; + const relatedValue = (args.object as any)[relatedPropertyName]; + + return ( + typeof value === 'number' && + typeof relatedValue === 'number' && + value < relatedValue + ); + }, + }, + }); + }; +}; diff --git a/packages/twenty-server/src/integrations/environment/environment.service.ts b/packages/twenty-server/src/integrations/environment/environment.service.ts index f9cdb7c4e..1a7209075 100644 --- a/packages/twenty-server/src/integrations/environment/environment.service.ts +++ b/packages/twenty-server/src/integrations/environment/environment.service.ts @@ -172,6 +172,27 @@ export class EnvironmentService { ); } + getEmailFromAddress(): string { + return ( + this.configService.get('EMAIL_FROM_ADDRESS') ?? + 'noreply@yourdomain.com' + ); + } + + getEmailSystemAddress(): string { + return ( + this.configService.get('EMAIL_SYSTEM_ADDRESS') ?? + 'system@yourdomain.com' + ); + } + + getEmailFromName(): string { + return ( + this.configService.get('EMAIL_FROM_NAME') ?? + 'John from YourDomain' + ); + } + getEmailDriver(): EmailDriver { return ( this.configService.get('EMAIL_DRIVER') ?? EmailDriver.Logger @@ -245,6 +266,18 @@ export class EnvironmentService { return this.configService.get('OPENROUTER_API_KEY'); } + getInactiveDaysBeforeEmail(): number | undefined { + return this.configService.get( + 'WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION', + ); + } + + getInactiveDaysBeforeDelete(): number | undefined { + return this.configService.get( + 'WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', + ); + } + isSignUpDisabled(): boolean { return this.configService.get('IS_SIGN_UP_DISABLED') ?? false; } diff --git a/packages/twenty-server/src/integrations/environment/environment.validation.ts b/packages/twenty-server/src/integrations/environment/environment.validation.ts index c67f22bba..ea29dbe42 100644 --- a/packages/twenty-server/src/integrations/environment/environment.validation.ts +++ b/packages/twenty-server/src/integrations/environment/environment.validation.ts @@ -17,6 +17,7 @@ import { CastToStringArray } from 'src/integrations/environment/decorators/cast- import { ExceptionHandlerDriver } from 'src/integrations/exception-handler/interfaces'; import { StorageDriverType } from 'src/integrations/file-storage/interfaces'; import { LoggerDriverType } from 'src/integrations/logger/interfaces'; +import { IsStrictlyLowerThan } from 'src/integrations/environment/decorators/is-strictly-lower-than.decorator'; import { IsDuration } from './decorators/is-duration.decorator'; import { AwsRegion } from './interfaces/aws-region.interface'; @@ -171,6 +172,21 @@ export class EnvironmentVariables { @IsString() SENTRY_DSN?: string; + @CastToPositiveNumber() + @IsNumber() + @ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION > 0) + @IsStrictlyLowerThan('WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', { + message: + '"WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION" should be strictly lower that "WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION"', + }) + @ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION > 0) + WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION: number; + + @CastToPositiveNumber() + @IsNumber() + @ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION > 0) + WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION: number; + @CastToBoolean() @IsOptional() @IsBoolean() diff --git a/packages/twenty-server/src/integrations/logger/interfaces/logger.interface.ts b/packages/twenty-server/src/integrations/logger/interfaces/logger.interface.ts index 09a76383a..87a0e4730 100644 --- a/packages/twenty-server/src/integrations/logger/interfaces/logger.interface.ts +++ b/packages/twenty-server/src/integrations/logger/interfaces/logger.interface.ts @@ -1,9 +1,12 @@ +import { LogLevel } from '@nestjs/common'; + export enum LoggerDriverType { Console = 'console', } export interface ConsoleDriverFactoryOptions { type: LoggerDriverType.Console; + logLevels?: LogLevel[]; } export type LoggerModuleOptions = ConsoleDriverFactoryOptions; diff --git a/packages/twenty-server/src/integrations/logger/logger.module-factory.ts b/packages/twenty-server/src/integrations/logger/logger.module-factory.ts index 683f77b44..2e4ec8cd0 100644 --- a/packages/twenty-server/src/integrations/logger/logger.module-factory.ts +++ b/packages/twenty-server/src/integrations/logger/logger.module-factory.ts @@ -13,11 +13,13 @@ export const loggerModuleFactory = async ( environmentService: EnvironmentService, ): Promise => { const driverType = environmentService.getLoggerDriverType(); + const logLevels = environmentService.getLogLevels(); switch (driverType) { case LoggerDriverType.Console: { return { type: LoggerDriverType.Console, + logLevels: logLevels, }; } default: diff --git a/packages/twenty-server/src/integrations/logger/logger.module.ts b/packages/twenty-server/src/integrations/logger/logger.module.ts index 0a60e44f1..1cd4a4ed8 100644 --- a/packages/twenty-server/src/integrations/logger/logger.module.ts +++ b/packages/twenty-server/src/integrations/logger/logger.module.ts @@ -42,9 +42,16 @@ export class LoggerModule extends ConfigurableModuleClass { return null; } - return config?.type === LoggerDriverType.Console - ? new ConsoleLogger() - : undefined; + const logLevels = config.logLevels ?? []; + + const logger = + config?.type === LoggerDriverType.Console + ? new ConsoleLogger() + : undefined; + + logger?.setLogLevels(logLevels); + + return logger; }, inject: options.inject || [], }; diff --git a/packages/twenty-server/src/integrations/message-queue/interfaces/message-queue-job.interface.ts b/packages/twenty-server/src/integrations/message-queue/interfaces/message-queue-job.interface.ts index 539c2d2ff..87423ffd2 100644 --- a/packages/twenty-server/src/integrations/message-queue/interfaces/message-queue-job.interface.ts +++ b/packages/twenty-server/src/integrations/message-queue/interfaces/message-queue-job.interface.ts @@ -1,4 +1,4 @@ -export interface MessageQueueJob { +export interface MessageQueueJob { handle(data: T): Promise | void; } diff --git a/packages/twenty-server/src/integrations/message-queue/jobs.module.ts b/packages/twenty-server/src/integrations/message-queue/jobs.module.ts index dc7c345f7..3faab9030 100644 --- a/packages/twenty-server/src/integrations/message-queue/jobs.module.ts +++ b/packages/twenty-server/src/integrations/message-queue/jobs.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { HttpModule } from '@nestjs/axios'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { GmailFullSyncJob } from 'src/workspace/messaging/jobs/gmail-full-sync.job'; import { CallWebhookJobsJob } from 'src/workspace/workspace-query-runner/jobs/call-webhook-jobs.job'; @@ -8,10 +9,14 @@ import { CallWebhookJob } from 'src/workspace/workspace-query-runner/jobs/call-w import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module'; import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module'; import { DataSourceModule } from 'src/metadata/data-source/data-source.module'; +import { CleanInactiveWorkspaceJob } from 'src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.job'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { FetchWorkspaceMessagesModule } from 'src/workspace/messaging/services/fetch-workspace-messages.module'; import { GmailPartialSyncJob } from 'src/workspace/messaging/jobs/gmail-partial-sync.job'; import { EmailSenderJob } from 'src/integrations/email/email-sender.job'; +import { UserModule } from 'src/core/user/user.module'; +import { EnvironmentModule } from 'src/integrations/environment/environment.module'; +import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity'; @Module({ imports: [ @@ -21,6 +26,10 @@ import { EmailSenderJob } from 'src/integrations/email/email-sender.job'; HttpModule, TypeORMModule, FetchWorkspaceMessagesModule, + UserModule, + EnvironmentModule, + TypeORMModule, + TypeOrmModule.forFeature([FeatureFlagEntity], 'core'), ], providers: [ { @@ -40,9 +49,10 @@ import { EmailSenderJob } from 'src/integrations/email/email-sender.job'; useClass: CallWebhookJob, }, { - provide: EmailSenderJob.name, - useClass: EmailSenderJob, + provide: CleanInactiveWorkspaceJob.name, + useClass: CleanInactiveWorkspaceJob, }, + { provide: EmailSenderJob.name, useClass: EmailSenderJob }, ], }) export class JobsModule { diff --git a/packages/twenty-server/src/metadata/data-source/data-source.service.ts b/packages/twenty-server/src/metadata/data-source/data-source.service.ts index 2d94a3458..3b644f11f 100644 --- a/packages/twenty-server/src/metadata/data-source/data-source.service.ts +++ b/packages/twenty-server/src/metadata/data-source/data-source.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { FindManyOptions, Repository } from 'typeorm'; import { DataSourceEntity } from './data-source.entity'; @@ -28,6 +28,12 @@ export class DataSourceService { }); } + async getManyDataSourceMetadata( + options: FindManyOptions = {}, + ) { + return this.dataSourceMetadataRepository.find(options); + } + async getDataSourcesMetadataFromWorkspaceId(workspaceId: string) { return this.dataSourceMetadataRepository.find({ where: { workspaceId }, diff --git a/packages/twenty-server/src/metadata/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/metadata/object-metadata/object-metadata.service.ts index 9cafcb64e..86d80beca 100644 --- a/packages/twenty-server/src/metadata/object-metadata/object-metadata.service.ts +++ b/packages/twenty-server/src/metadata/object-metadata/object-metadata.service.ts @@ -315,6 +315,21 @@ export class ObjectMetadataService extends TypeOrmQueryService) { + return this.objectMetadataRepository.find({ + relations: [ + 'fields', + 'fields.fromRelationMetadata', + 'fields.toRelationMetadata', + 'fields.fromRelationMetadata.toObjectMetadata', + ], + ...options, + where: { + ...options?.where, + }, + }); + } + public async deleteObjectsMetadata(workspaceId: string) { await this.objectMetadataRepository.delete({ workspaceId }); } diff --git a/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.cron.pattern.ts b/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.cron.pattern.ts new file mode 100644 index 000000000..c1fdacec3 --- /dev/null +++ b/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.cron.pattern.ts @@ -0,0 +1 @@ +export const cleanInactiveWorkspaceCronPattern = '0 22 * * *'; // Every day at 10pm diff --git a/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.job.ts b/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.job.ts new file mode 100644 index 000000000..f91fccc38 --- /dev/null +++ b/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.job.ts @@ -0,0 +1,221 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { render } from '@react-email/render'; +import { Repository } from 'typeorm'; + +import { MessageQueueJob } from 'src/integrations/message-queue/interfaces/message-queue-job.interface'; + +import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service'; +import { DataSourceService } from 'src/metadata/data-source/data-source.service'; +import { TypeORMService } from 'src/database/typeorm/typeorm.service'; +import { DataSourceEntity } from 'src/metadata/data-source/data-source.entity'; +import { UserService } from 'src/core/user/services/user.service'; +import { EmailService } from 'src/integrations/email/email.service'; +import CleanInactiveWorkspaceEmail from 'src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspaces.email'; +import { EnvironmentService } from 'src/integrations/environment/environment.service'; +import { + FeatureFlagEntity, + FeatureFlagKeys, +} from 'src/core/feature-flag/feature-flag.entity'; +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; +import DeleteInactiveWorkspaceEmail from 'src/workspace/cron/clean-inactive-workspaces/delete-inactive-workspaces.email'; + +const MILLISECONDS_IN_ONE_DAY = 1000 * 3600 * 24; + +@Injectable() +export class CleanInactiveWorkspaceJob implements MessageQueueJob { + private readonly logger = new Logger(CleanInactiveWorkspaceJob.name); + private readonly inactiveDaysBeforeDelete; + private readonly inactiveDaysBeforeEmail; + + constructor( + private readonly dataSourceService: DataSourceService, + private readonly objectMetadataService: ObjectMetadataService, + private readonly typeORMService: TypeORMService, + private readonly userService: UserService, + private readonly emailService: EmailService, + private readonly environmentService: EnvironmentService, + @InjectRepository(FeatureFlagEntity, 'core') + private readonly featureFlagRepository: Repository, + ) { + this.inactiveDaysBeforeDelete = + this.environmentService.getInactiveDaysBeforeDelete(); + this.inactiveDaysBeforeEmail = + this.environmentService.getInactiveDaysBeforeEmail(); + } + + async getmostRecentUpdatedAt( + dataSource: DataSourceEntity, + objectsMetadata: ObjectMetadataEntity[], + ): Promise { + const tableNames = objectsMetadata + .filter( + (objectMetadata) => + objectMetadata.workspaceId === dataSource.workspaceId, + ) + .map((objectMetadata) => objectMetadata.targetTableName); + + const workspaceDataSource = + await this.typeORMService.connectToDataSource(dataSource); + + let mostRecentUpdatedAtDate = new Date(0); + + for (const tableName of tableNames) { + const mostRecentTableUpdatedAt = ( + await workspaceDataSource?.query( + `SELECT MAX("updatedAt") FROM ${dataSource.schema}."${tableName}"`, + ) + )[0].max; + + if (mostRecentTableUpdatedAt) { + const mostRecentTableUpdatedAtDate = new Date(mostRecentTableUpdatedAt); + + if ( + !mostRecentUpdatedAtDate || + mostRecentTableUpdatedAtDate > mostRecentUpdatedAtDate + ) { + mostRecentUpdatedAtDate = mostRecentTableUpdatedAtDate; + } + } + } + + return mostRecentUpdatedAtDate; + } + + async warnWorkspaceUsers( + dataSource: DataSourceEntity, + daysSinceInactive: number, + ) { + const workspaceMembers = + await this.userService.loadWorkspaceMembers(dataSource); + + const workspaceDataSource = + await this.typeORMService.connectToDataSource(dataSource); + + const displayName = ( + await workspaceDataSource?.query( + `SELECT "displayName" FROM core.workspace WHERE id='${dataSource.workspaceId}'`, + ) + )?.[0].displayName; + + this.logger.log( + `Sending workspace ${ + dataSource.workspaceId + } inactive since ${daysSinceInactive} days emails to users ['${workspaceMembers + .map((workspaceUser) => workspaceUser.email) + .join(', ')}']`, + ); + + workspaceMembers.forEach((workspaceMember) => { + const emailData = { + daysLeft: this.inactiveDaysBeforeDelete - daysSinceInactive, + userName: `${workspaceMember.nameFirstName} ${workspaceMember.nameLastName}`, + workspaceDisplayName: `${displayName}`, + }; + const emailTemplate = CleanInactiveWorkspaceEmail(emailData); + const html = render(emailTemplate, { + pretty: true, + }); + const text = render(emailTemplate, { + plainText: true, + }); + + this.emailService.send({ + to: workspaceMember.email, + from: `${this.environmentService.getEmailFromName()} <${this.environmentService.getEmailFromAddress()}>`, + subject: 'Action Needed to Prevent Workspace Deletion', + html, + text, + }); + }); + } + + async deleteWorkspace( + dataSource: DataSourceEntity, + daysSinceInactive: number, + ): Promise { + this.logger.log( + `Sending email to delete workspace ${dataSource.workspaceId} inactive since ${daysSinceInactive} days`, + ); + + const emailData = { + daysSinceDead: daysSinceInactive - this.inactiveDaysBeforeDelete, + workspaceId: `${dataSource.workspaceId}`, + }; + const emailTemplate = DeleteInactiveWorkspaceEmail(emailData); + const html = render(emailTemplate, { + pretty: true, + }); + + const text = `Workspace '${dataSource.workspaceId}' should be deleted as inactive since ${daysSinceInactive} days`; + + await this.emailService.send({ + to: this.environmentService.getEmailSystemAddress(), + from: `${this.environmentService.getEmailFromName()} <${this.environmentService.getEmailFromAddress()}>`, + subject: 'Action Needed to Delete Workspace', + html, + text, + }); + } + + async processWorkspace( + dataSource: DataSourceEntity, + objectsMetadata: ObjectMetadataEntity[], + ): Promise { + const mostRecentUpdatedAt = await this.getmostRecentUpdatedAt( + dataSource, + objectsMetadata, + ); + const daysSinceInactive = Math.floor( + (new Date().getTime() - mostRecentUpdatedAt.getTime()) / + MILLISECONDS_IN_ONE_DAY, + ); + + if (daysSinceInactive > this.inactiveDaysBeforeDelete) { + await this.deleteWorkspace(dataSource, daysSinceInactive); + } else if (daysSinceInactive > this.inactiveDaysBeforeEmail) { + await this.warnWorkspaceUsers(dataSource, daysSinceInactive); + } + } + + async isWorkspaceCleanable(dataSource: DataSourceEntity): Promise { + const workspaceFeatureFlags = await this.featureFlagRepository.find({ + where: { workspaceId: dataSource.workspaceId }, + }); + + return ( + workspaceFeatureFlags.filter( + (workspaceFeatureFlag) => + workspaceFeatureFlag.key === FeatureFlagKeys.IsWorkspaceCleanable && + workspaceFeatureFlag.value, + ).length > 0 + ); + } + + async handle(): Promise { + this.logger.log('Job running...'); + if (!this.inactiveDaysBeforeDelete && !this.inactiveDaysBeforeEmail) { + this.logger.log( + `'WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION' and 'WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION' environment variables not set, please check this doc for more info: https://docs.twenty.com/start/self-hosting/environment-variables`, + ); + + return; + } + const dataSources = + await this.dataSourceService.getManyDataSourceMetadata(); + + const objectsMetadata = await this.objectMetadataService.findMany(); + + for (const dataSource of dataSources) { + if (!(await this.isWorkspaceCleanable(dataSource))) { + continue; + } + + this.logger.log(`Cleaning Workspace ${dataSource.workspaceId}`); + await this.processWorkspace(dataSource, objectsMetadata); + } + + this.logger.log('job done!'); + } +} diff --git a/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspaces.email.tsx b/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspaces.email.tsx new file mode 100644 index 000000000..fa7420ef9 --- /dev/null +++ b/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspaces.email.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; + +import { HighlightedText } from 'src/emails/components/HighlightedText'; +import { MainText } from 'src/emails/components/MainText'; +import { Title } from 'src/emails/components/Title'; +import { BaseEmail } from 'src/emails/components/BaseEmail'; +import { CallToAction } from 'src/emails/components/CallToAction'; + +type CleanInactiveWorkspaceEmailData = { + daysLeft: number; + userName: string; + workspaceDisplayName: string; +}; + +export const CleanInactiveWorkspaceEmail = ({ + daysLeft, + userName, + workspaceDisplayName, +}: CleanInactiveWorkspaceEmailData) => { + const dayOrDays = daysLeft > 1 ? 'days' : 'day'; + const remainingDays = daysLeft > 1 ? `${daysLeft} ` : ''; + + const helloString = userName?.length > 1 ? `Hello ${userName}` : 'Hello'; + + return ( + + + <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-server/src/workspace/cron/clean-inactive-workspaces/commands/clean-inactive-workspaces.command.ts b/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/commands/clean-inactive-workspaces.command.ts new file mode 100644 index 000000000..1aa8f9425 --- /dev/null +++ b/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/commands/clean-inactive-workspaces.command.ts @@ -0,0 +1,28 @@ +import { Inject } from '@nestjs/common'; + +import { Command, CommandRunner } from 'nest-commander'; + +import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service'; +import { CleanInactiveWorkspaceJob } from 'src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.job'; + +@Command({ + name: 'clean-inactive-workspaces', + description: 'Clean inactive workspaces', +}) +export class CleanInactiveWorkspacesCommand extends CommandRunner { + constructor( + @Inject(MessageQueue.taskAssignedQueue) + private readonly messageQueueService: MessageQueueService, + ) { + super(); + } + + async run(): Promise<void> { + await this.messageQueueService.add<any>( + CleanInactiveWorkspaceJob.name, + {}, + { retryLimit: 3 }, + ); + } +} diff --git a/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/commands/start-clean-inactive-workspaces.cron.command.ts b/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/commands/start-clean-inactive-workspaces.cron.command.ts new file mode 100644 index 000000000..da000c75d --- /dev/null +++ b/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/commands/start-clean-inactive-workspaces.cron.command.ts @@ -0,0 +1,30 @@ +import { Inject } from '@nestjs/common'; + +import { Command, CommandRunner } from 'nest-commander'; + +import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service'; +import { cleanInactiveWorkspaceCronPattern } from 'src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.cron.pattern'; +import { CleanInactiveWorkspaceJob } from 'src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.job'; + +@Command({ + name: 'clean-inactive-workspace:cron:start', + description: 'Starts a cron job to clean inactive workspaces', +}) +export class StartCleanInactiveWorkspacesCronCommand extends CommandRunner { + constructor( + @Inject(MessageQueue.cronQueue) + private readonly messageQueueService: MessageQueueService, + ) { + super(); + } + + async run(): Promise<void> { + await this.messageQueueService.addCron<undefined>( + CleanInactiveWorkspaceJob.name, + undefined, + cleanInactiveWorkspaceCronPattern, + { retryLimit: 3 }, + ); + } +} diff --git a/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/commands/stop-clean-inactive-workspaces.cron.command.ts b/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/commands/stop-clean-inactive-workspaces.cron.command.ts new file mode 100644 index 000000000..151e0f5dc --- /dev/null +++ b/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/commands/stop-clean-inactive-workspaces.cron.command.ts @@ -0,0 +1,28 @@ +import { Inject } from '@nestjs/common'; + +import { Command, CommandRunner } from 'nest-commander'; + +import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service'; +import { cleanInactiveWorkspaceCronPattern } from 'src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.cron.pattern'; +import { CleanInactiveWorkspaceJob } from 'src/workspace/cron/clean-inactive-workspaces/clean-inactive-workspace.job'; + +@Command({ + name: 'clean-inactive-workspace:cron:stop', + description: 'Stops the clean inactive workspaces cron job', +}) +export class StopCleanInactiveWorkspacesCronCommand extends CommandRunner { + constructor( + @Inject(MessageQueue.cronQueue) + private readonly messageQueueService: MessageQueueService, + ) { + super(); + } + + async run(): Promise<void> { + await this.messageQueueService.removeCron( + CleanInactiveWorkspaceJob.name, + cleanInactiveWorkspaceCronPattern, + ); + } +} diff --git a/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/delete-inactive-workspaces.email.tsx b/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/delete-inactive-workspaces.email.tsx new file mode 100644 index 000000000..7686696af --- /dev/null +++ b/packages/twenty-server/src/workspace/cron/clean-inactive-workspaces/delete-inactive-workspaces.email.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; + +import { HighlightedText } from 'src/emails/components/HighlightedText'; +import { MainText } from 'src/emails/components/MainText'; +import { Title } from 'src/emails/components/Title'; +import { BaseEmail } from 'src/emails/components/BaseEmail'; +import { CallToAction } from 'src/emails/components/CallToAction'; + +type DeleteInactiveWorkspaceEmailData = { + daysSinceDead: number; + workspaceId: string; +}; + +export const DeleteInactiveWorkspaceEmail = ({ + daysSinceDead, + workspaceId, +}: DeleteInactiveWorkspaceEmailData) => { + return ( + <BaseEmail> + <Title value="Dead Workspace 😵" /> + <HighlightedText value={`Inactive since ${daysSinceDead} day(s)`} /> + <MainText> + Workspace <b>{workspaceId}</b> should be deleted. + </MainText> + <CallToAction href="https://app.twenty.com" value="Connect to Twenty" /> + </BaseEmail> + ); +}; + +export default DeleteInactiveWorkspaceEmail; diff --git a/packages/twenty-server/src/workspace/messaging/commands/gmail-full-sync.command.ts b/packages/twenty-server/src/workspace/messaging/commands/gmail-full-sync.command.ts index dc8b4a0c3..f5ed4b69f 100644 --- a/packages/twenty-server/src/workspace/messaging/commands/gmail-full-sync.command.ts +++ b/packages/twenty-server/src/workspace/messaging/commands/gmail-full-sync.command.ts @@ -3,7 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Command, CommandRunner, Option } from 'nest-commander'; import { Repository } from 'typeorm'; -import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity'; +import { + FeatureFlagEntity, + FeatureFlagKeys, +} from 'src/core/feature-flag/feature-flag.entity'; import { MessagingProducer } from 'src/workspace/messaging/producers/messaging-producer'; import { MessagingUtilsService } from 'src/workspace/messaging/services/messaging-utils.service'; @@ -32,7 +35,7 @@ export class GmailFullSyncCommand extends CommandRunner { ): Promise<void> { const isMessagingEnabled = await this.featureFlagRepository.findOneBy({ workspaceId: options.workspaceId, - key: 'IS_MESSAGING_ENABLED', + key: FeatureFlagKeys.IsMessagingEnabled, value: true, });