mirror of
https://github.com/twentyhq/twenty.git
synced 2024-11-23 14:03:35 +03:00
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 commitfe062dd051
. * Add email theme 2 * Revert "Revert "Remove test files"" This reverts commit6c6471273a
. * Revert "Revert "Revert "Remove test files""" This reverts commitf851333c24
. * Revert "Revert "Revert "Revert "Remove test files"""" This reverts commit7838e19e88
. * 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:
parent
03bf597301
commit
49a9a2c2be
@ -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>
|
||||
|
@ -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=
|
||||
|
@ -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,
|
||||
],
|
||||
})
|
||||
|
@ -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();
|
||||
|
@ -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' })
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
@ -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;
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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;
|
||||
|
@ -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:
|
||||
|
@ -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 || [],
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
export interface MessageQueueJob<T extends MessageQueueJobData> {
|
||||
export interface MessageQueueJob<T extends MessageQueueJobData | undefined> {
|
||||
handle(data: T): Promise<void> | void;
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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 },
|
||||
|
@ -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 });
|
||||
}
|
||||
|
@ -0,0 +1 @@
|
||||
export const cleanInactiveWorkspaceCronPattern = '0 22 * * *'; // Every day at 10pm
|
@ -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!');
|
||||
}
|
||||
}
|
@ -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;
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
@ -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,
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user