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 (
+
+
+
+
+ {helloString},
+
+
+ It appears that there has been a period of inactivity on your{' '}
+ {workspaceDisplayName} workspace.
+
+
+ Please note that the account is due for deactivation soon, and all
+ associated data within this workspace will be deleted.
+
+
+ No need for concern, though! Simply create or edit a record within the
+ next {remainingDays}
+ {dayOrDays} to retain access.
+
+
+
+ );
+};
+
+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 {
+ await this.messageQueueService.add(
+ 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 {
+ await this.messageQueueService.addCron(
+ 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 {
+ 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 (
+
+
+
+
+ Workspace {workspaceId} should be deleted.
+
+
+
+ );
+};
+
+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 {
const isMessagingEnabled = await this.featureFlagRepository.findOneBy({
workspaceId: options.workspaceId,
- key: 'IS_MESSAGING_ENABLED',
+ key: FeatureFlagKeys.IsMessagingEnabled,
value: true,
});