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 fe062dd051.

* Add email theme 2

* Revert "Revert "Remove test files""

This reverts commit 6c6471273a.

* Revert "Revert "Revert "Remove test files"""

This reverts commit f851333c24.

* Revert "Revert "Revert "Revert "Remove test files""""

This reverts commit 7838e19e88.

* 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 <charles@twenty.com>
This commit is contained in:
martmull 2024-01-13 12:03:41 +01:00 committed by GitHub
parent 03bf597301
commit 49a9a2c2be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 594 additions and 24 deletions

View File

@ -62,6 +62,9 @@ import TabItem from '@theme/TabItem';
### Email
<OptionTable options={[
['EMAIL_FROM_ADDRESS', 'noreply@yourdomain.com', 'Global email From: header used to send emails'],
['EMAIL_FROM_NAME', 'John from YourDomain', 'Global name From: header used to send emails'],
['EMAIL_SYSTEM_ADDRESS', 'system@yourdomain.com', 'Email address used as a destination to send internal system notification'],
['EMAIL_DRIVER', 'logger', "Email driver: 'logger' (to log emails in console) or 'smtp'"],
['EMAIL_SMTP_HOST', '', 'Email Smtp Host'],
['EMAIL_SMTP_PORT', '', 'Email Smtp Port'],
@ -126,7 +129,8 @@ import TabItem from '@theme/TabItem';
<OptionTable options={[
['LOGGER_DRIVER', 'console', "The logging driver can be: 'console' or 'sentry'"],
['LOG_LEVEL', 'error,warn', "The loglevels which are logged to the logging driver. Can include: 'log', 'warn', 'error'"],
['LOG_LEVELS', 'error,warn', "The loglevels which are logged to the logging driver. Can include: 'log', 'warn', 'error'"],
['EXCEPTION_HANDLER_DRIVER', 'sentry', "The exception handler driver can be: 'console' or 'sentry'"],
['SENTRY_DSN', 'https://xxx@xxx.ingest.sentry.io/xxx', 'The sentry logging endpoint used if sentry logging driver is selected'],
]}></OptionTable>
@ -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'],
]}></OptionTable>
### Workspace Cleaning
<OptionTable options={[
['WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION', '', 'Number of inactive days before sending workspace deleting warning email'],
['WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', '', 'Number of inactive days before deleting workspace'],
]}></OptionTable>

View File

@ -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=

View File

@ -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,
],
})

View File

@ -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();

View File

@ -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' })

View File

@ -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<User> {
constructor(
@ -48,6 +49,20 @@ export class UserService extends TypeOrmQueryService<User> {
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(

View File

@ -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,
},

View File

@ -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,
},

View File

@ -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<SendMailOptions> {
private readonly logger = new Logger(EmailSenderJob.name);
constructor(private readonly emailSenderService: EmailSenderService) {}
async handle(data: SendMailOptions): Promise<void> {
process.stdout.write(`Sending email to ${data.to} ...`);
await this.emailSenderService.send(data);
console.log(' done!');
this.logger.log(`Email to ${data.to} sent`);
}
}

View File

@ -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
);
},
},
});
};
};

View File

@ -172,6 +172,27 @@ export class EnvironmentService {
);
}
getEmailFromAddress(): string {
return (
this.configService.get<string>('EMAIL_FROM_ADDRESS') ??
'noreply@yourdomain.com'
);
}
getEmailSystemAddress(): string {
return (
this.configService.get<string>('EMAIL_SYSTEM_ADDRESS') ??
'system@yourdomain.com'
);
}
getEmailFromName(): string {
return (
this.configService.get<string>('EMAIL_FROM_NAME') ??
'John from YourDomain'
);
}
getEmailDriver(): EmailDriver {
return (
this.configService.get<EmailDriver>('EMAIL_DRIVER') ?? EmailDriver.Logger
@ -245,6 +266,18 @@ export class EnvironmentService {
return this.configService.get<string | undefined>('OPENROUTER_API_KEY');
}
getInactiveDaysBeforeEmail(): number | undefined {
return this.configService.get<number | undefined>(
'WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION',
);
}
getInactiveDaysBeforeDelete(): number | undefined {
return this.configService.get<number | undefined>(
'WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION',
);
}
isSignUpDisabled(): boolean {
return this.configService.get<boolean>('IS_SIGN_UP_DISABLED') ?? false;
}

View File

@ -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()

View File

@ -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;

View File

@ -13,11 +13,13 @@ export const loggerModuleFactory = async (
environmentService: EnvironmentService,
): Promise<LoggerModuleOptions> => {
const driverType = environmentService.getLoggerDriverType();
const logLevels = environmentService.getLogLevels();
switch (driverType) {
case LoggerDriverType.Console: {
return {
type: LoggerDriverType.Console,
logLevels: logLevels,
};
}
default:

View File

@ -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 || [],
};

View File

@ -1,4 +1,4 @@
export interface MessageQueueJob<T extends MessageQueueJobData> {
export interface MessageQueueJob<T extends MessageQueueJobData | undefined> {
handle(data: T): Promise<void> | void;
}

View File

@ -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 {

View File

@ -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<DataSourceEntity> = {},
) {
return this.dataSourceMetadataRepository.find(options);
}
async getDataSourcesMetadataFromWorkspaceId(workspaceId: string) {
return this.dataSourceMetadataRepository.find({
where: { workspaceId },

View File

@ -315,6 +315,21 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
});
}
public async findMany(options?: FindManyOptions<ObjectMetadataEntity>) {
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 });
}

View File

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

View File

@ -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<undefined> {
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<FeatureFlagEntity>,
) {
this.inactiveDaysBeforeDelete =
this.environmentService.getInactiveDaysBeforeDelete();
this.inactiveDaysBeforeEmail =
this.environmentService.getInactiveDaysBeforeEmail();
}
async getmostRecentUpdatedAt(
dataSource: DataSourceEntity,
objectsMetadata: ObjectMetadataEntity[],
): Promise<Date> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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!');
}
}

View File

@ -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 (
<BaseEmail>
<Title value="Inactive Workspace 😴" />
<HighlightedText value={`${daysLeft} ${dayOrDays} left`} />
<MainText>
{helloString},
<br />
<br />
It appears that there has been a period of inactivity on your{' '}
<b>{workspaceDisplayName}</b> workspace.
<br />
<br />
Please note that the account is due for deactivation soon, and all
associated data within this workspace will be deleted.
<br />
<br />
No need for concern, though! Simply create or edit a record within the
next {remainingDays}
{dayOrDays} to retain access.
</MainText>
<CallToAction href="https://app.twenty.com" value="Connect to Twenty" />
</BaseEmail>
);
};
export default CleanInactiveWorkspaceEmail;

View File

@ -0,0 +1,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 },
);
}
}

View File

@ -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 },
);
}
}

View File

@ -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,
);
}
}

View File

@ -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;

View File

@ -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,
});