2252 build a script to cleanup inactive workspaces (#3307)
* Add cron to message queue interfaces * Add command to launch cron job * Add command to stop cron job * Update clean inactive workspaces job * Add react-email * WIP * Fix import error * Rename services * Update logging * Update email template * Update email template * Add Base Email template * Move to proper place * Remove test files * Update logo * Add email theme * Revert "Remove test files" This reverts commit fe062dd05166b95125cf99f2165cc20efb6c275a. * Add email theme 2 * Revert "Revert "Remove test files"" This reverts commit 6c6471273ad765788f2eaf5a5614209edfb965ce. * Revert "Revert "Revert "Remove test files""" This reverts commit f851333c24e9cfe3f425c9cbbd1e079efce5c3dd. * Revert "Revert "Revert "Revert "Remove test files"""" This reverts commit 7838e19e88e269026e24803f26cd52b467b4ef36. * Fix theme * Reorganize files * Update clean inactive workspaces job * Use env variable to define inactive days * Remove FROM variable * Use feature flag * Fix cron command * Remove useless variable * Reorganize files * Refactor some code * Update email template * Update email object * Remove verbose log * Code review returns * Code review returns * Simplify handle * Code review returns * Review --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -62,6 +62,9 @@ import TabItem from '@theme/TabItem';
|
|||||||
### Email
|
### Email
|
||||||
|
|
||||||
<OptionTable options={[
|
<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_DRIVER', 'logger', "Email driver: 'logger' (to log emails in console) or 'smtp'"],
|
||||||
['EMAIL_SMTP_HOST', '', 'Email Smtp Host'],
|
['EMAIL_SMTP_HOST', '', 'Email Smtp Host'],
|
||||||
['EMAIL_SMTP_PORT', '', 'Email Smtp Port'],
|
['EMAIL_SMTP_PORT', '', 'Email Smtp Port'],
|
||||||
@ -126,7 +129,8 @@ import TabItem from '@theme/TabItem';
|
|||||||
|
|
||||||
<OptionTable options={[
|
<OptionTable options={[
|
||||||
['LOGGER_DRIVER', 'console', "The logging driver can be: 'console' or 'sentry'"],
|
['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'],
|
['SENTRY_DSN', 'https://xxx@xxx.ingest.sentry.io/xxx', 'The sentry logging endpoint used if sentry logging driver is selected'],
|
||||||
]}></OptionTable>
|
]}></OptionTable>
|
||||||
|
|
||||||
@ -159,3 +163,10 @@ import TabItem from '@theme/TabItem';
|
|||||||
['DEBUG_MODE', 'true', 'Activate debug mode'],
|
['DEBUG_MODE', 'true', 'Activate debug mode'],
|
||||||
['SIGN_IN_PREFILLED', 'true', 'Prefill the Signin form for usage in a demo or dev environment'],
|
['SIGN_IN_PREFILLED', 'true', 'Prefill the Signin form for usage in a demo or dev environment'],
|
||||||
]}></OptionTable>
|
]}></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
|
# LOGGER_DRIVER=console
|
||||||
# EXCEPTION_HANDLER_DRIVER=sentry
|
# EXCEPTION_HANDLER_DRIVER=sentry
|
||||||
# SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx
|
# SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx
|
||||||
# LOG_LEVEL=error,warn
|
# LOG_LEVELS=error,warn
|
||||||
# MESSAGE_QUEUE_TYPE=pg-boss
|
# MESSAGE_QUEUE_TYPE=pg-boss
|
||||||
# REDIS_HOST=127.0.0.1
|
# REDIS_HOST=127.0.0.1
|
||||||
# REDIS_PORT=6379
|
# REDIS_PORT=6379
|
||||||
# DEMO_WORKSPACE_IDS=REPLACE_ME_WITH_A_RANDOM_UUID
|
# DEMO_WORKSPACE_IDS=REPLACE_ME_WITH_A_RANDOM_UUID
|
||||||
# SERVER_URL=http://localhost:3000
|
# 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 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_DRIVER=logger
|
||||||
# EMAIL_SMTP_HOST=
|
# EMAIL_SMTP_HOST=
|
||||||
# EMAIL_SMTP_PORT=
|
# EMAIL_SMTP_PORT=
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import { Module } from '@nestjs/common';
|
|||||||
|
|
||||||
import { DatabaseCommandModule } from 'src/database/commands/database-command.module';
|
import { DatabaseCommandModule } from 'src/database/commands/database-command.module';
|
||||||
import { FetchWorkspaceMessagesCommandsModule } from 'src/workspace/messaging/commands/fetch-workspace-messages-commands.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 { WorkspaceHealthCommandModule } from 'src/workspace/workspace-health/commands/workspace-health-command.module';
|
||||||
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
@ -14,6 +17,9 @@ import { WorkspaceSyncMetadataCommandsModule } from './workspace/workspace-sync-
|
|||||||
WorkspaceSyncMetadataCommandsModule,
|
WorkspaceSyncMetadataCommandsModule,
|
||||||
DatabaseCommandModule,
|
DatabaseCommandModule,
|
||||||
FetchWorkspaceMessagesCommandsModule,
|
FetchWorkspaceMessagesCommandsModule,
|
||||||
|
StartCleanInactiveWorkspacesCronCommand,
|
||||||
|
StopCleanInactiveWorkspacesCronCommand,
|
||||||
|
CleanInactiveWorkspacesCommand,
|
||||||
WorkspaceHealthCommandModule,
|
WorkspaceHealthCommandModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -4,6 +4,6 @@ import { CommandModule } from './command.module';
|
|||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
// TODO: inject our own logger service to handle the output (Sentry, etc.)
|
// 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();
|
bootstrap();
|
||||||
|
|||||||
@ -13,6 +13,15 @@ import { IDField } from '@ptc-org/nestjs-query-graphql';
|
|||||||
|
|
||||||
import { Workspace } from 'src/core/workspace/workspace.entity';
|
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' })
|
@Entity({ name: 'featureFlag', schema: 'core' })
|
||||||
@ObjectType('FeatureFlag')
|
@ObjectType('FeatureFlag')
|
||||||
@Unique('IndexOnKeyAndWorkspaceIdUnique', ['key', 'workspaceId'])
|
@Unique('IndexOnKeyAndWorkspaceIdUnique', ['key', 'workspaceId'])
|
||||||
@ -23,7 +32,7 @@ export class FeatureFlagEntity {
|
|||||||
|
|
||||||
@Field()
|
@Field()
|
||||||
@Column({ nullable: false, type: 'text' })
|
@Column({ nullable: false, type: 'text' })
|
||||||
key: string;
|
key: FeatureFlagKeys;
|
||||||
|
|
||||||
@Field()
|
@Field()
|
||||||
@Column({ nullable: false, type: 'uuid' })
|
@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 { WorkspaceMember } from 'src/core/user/dtos/workspace-member.dto';
|
||||||
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
|
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
|
||||||
import { TypeORMService } from 'src/database/typeorm/typeorm.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> {
|
export class UserService extends TypeOrmQueryService<User> {
|
||||||
constructor(
|
constructor(
|
||||||
@ -48,6 +49,20 @@ export class UserService extends TypeOrmQueryService<User> {
|
|||||||
return userWorkspaceMember;
|
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) {
|
async createWorkspaceMember(user: User, avatarUrl?: string) {
|
||||||
const dataSourceMetadata =
|
const dataSourceMetadata =
|
||||||
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
|
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { FeatureFlagKeys } from 'src/core/feature-flag/feature-flag.entity';
|
||||||
|
|
||||||
const tableName = 'featureFlag';
|
const tableName = 'featureFlag';
|
||||||
|
|
||||||
export const seedFeatureFlags = async (
|
export const seedFeatureFlags = async (
|
||||||
@ -14,17 +16,17 @@ export const seedFeatureFlags = async (
|
|||||||
.orIgnore()
|
.orIgnore()
|
||||||
.values([
|
.values([
|
||||||
{
|
{
|
||||||
key: 'IS_RELATION_FIELD_TYPE_ENABLED',
|
key: FeatureFlagKeys.IsRelationFieldTypeEnabled,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
value: true,
|
value: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'IS_SELECT_FIELD_TYPE_ENABLED',
|
key: FeatureFlagKeys.IsSelectFieldTypeEnabled,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
value: true,
|
value: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'IS_RATING_FIELD_TYPE_ENABLED',
|
key: FeatureFlagKeys.IsRatingFieldTypeEnabled,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
value: false,
|
value: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { FeatureFlagKeys } from 'src/core/feature-flag/feature-flag.entity';
|
||||||
|
|
||||||
const tableName = 'featureFlag';
|
const tableName = 'featureFlag';
|
||||||
|
|
||||||
export const seedFeatureFlags = async (
|
export const seedFeatureFlags = async (
|
||||||
@ -14,27 +16,32 @@ export const seedFeatureFlags = async (
|
|||||||
.orIgnore()
|
.orIgnore()
|
||||||
.values([
|
.values([
|
||||||
{
|
{
|
||||||
key: 'IS_RELATION_FIELD_TYPE_ENABLED',
|
key: FeatureFlagKeys.IsRelationFieldTypeEnabled,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
value: true,
|
value: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'IS_MESSAGING_ENABLED',
|
key: FeatureFlagKeys.IsMessagingEnabled,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
value: true,
|
value: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'IS_NOTE_CREATE_IMAGES_ENABLED',
|
key: FeatureFlagKeys.IsNoteCreateImagesEnabled,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
value: true,
|
value: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'IS_SELECT_FIELD_TYPE_ENABLED',
|
key: FeatureFlagKeys.IsSelectFieldTypeEnabled,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
value: true,
|
value: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'IS_RATING_FIELD_TYPE_ENABLED',
|
key: FeatureFlagKeys.IsRatingFieldTypeEnabled,
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: FeatureFlagKeys.IsWorkspaceCleanable,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
value: true,
|
value: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
import { SendMailOptions } from 'nodemailer';
|
import { SendMailOptions } from 'nodemailer';
|
||||||
|
|
||||||
@ -8,11 +8,11 @@ import { EmailSenderService } from 'src/integrations/email/email-sender.service'
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailSenderJob implements MessageQueueJob<SendMailOptions> {
|
export class EmailSenderJob implements MessageQueueJob<SendMailOptions> {
|
||||||
|
private readonly logger = new Logger(EmailSenderJob.name);
|
||||||
constructor(private readonly emailSenderService: EmailSenderService) {}
|
constructor(private readonly emailSenderService: EmailSenderService) {}
|
||||||
|
|
||||||
async handle(data: SendMailOptions): Promise<void> {
|
async handle(data: SendMailOptions): Promise<void> {
|
||||||
process.stdout.write(`Sending email to ${data.to} ...`);
|
|
||||||
await this.emailSenderService.send(data);
|
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 {
|
getEmailDriver(): EmailDriver {
|
||||||
return (
|
return (
|
||||||
this.configService.get<EmailDriver>('EMAIL_DRIVER') ?? EmailDriver.Logger
|
this.configService.get<EmailDriver>('EMAIL_DRIVER') ?? EmailDriver.Logger
|
||||||
@ -245,6 +266,18 @@ export class EnvironmentService {
|
|||||||
return this.configService.get<string | undefined>('OPENROUTER_API_KEY');
|
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 {
|
isSignUpDisabled(): boolean {
|
||||||
return this.configService.get<boolean>('IS_SIGN_UP_DISABLED') ?? false;
|
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 { ExceptionHandlerDriver } from 'src/integrations/exception-handler/interfaces';
|
||||||
import { StorageDriverType } from 'src/integrations/file-storage/interfaces';
|
import { StorageDriverType } from 'src/integrations/file-storage/interfaces';
|
||||||
import { LoggerDriverType } from 'src/integrations/logger/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 { IsDuration } from './decorators/is-duration.decorator';
|
||||||
import { AwsRegion } from './interfaces/aws-region.interface';
|
import { AwsRegion } from './interfaces/aws-region.interface';
|
||||||
@ -171,6 +172,21 @@ export class EnvironmentVariables {
|
|||||||
@IsString()
|
@IsString()
|
||||||
SENTRY_DSN?: string;
|
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()
|
@CastToBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
|
import { LogLevel } from '@nestjs/common';
|
||||||
|
|
||||||
export enum LoggerDriverType {
|
export enum LoggerDriverType {
|
||||||
Console = 'console',
|
Console = 'console',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConsoleDriverFactoryOptions {
|
export interface ConsoleDriverFactoryOptions {
|
||||||
type: LoggerDriverType.Console;
|
type: LoggerDriverType.Console;
|
||||||
|
logLevels?: LogLevel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LoggerModuleOptions = ConsoleDriverFactoryOptions;
|
export type LoggerModuleOptions = ConsoleDriverFactoryOptions;
|
||||||
|
|||||||
@ -13,11 +13,13 @@ export const loggerModuleFactory = async (
|
|||||||
environmentService: EnvironmentService,
|
environmentService: EnvironmentService,
|
||||||
): Promise<LoggerModuleOptions> => {
|
): Promise<LoggerModuleOptions> => {
|
||||||
const driverType = environmentService.getLoggerDriverType();
|
const driverType = environmentService.getLoggerDriverType();
|
||||||
|
const logLevels = environmentService.getLogLevels();
|
||||||
|
|
||||||
switch (driverType) {
|
switch (driverType) {
|
||||||
case LoggerDriverType.Console: {
|
case LoggerDriverType.Console: {
|
||||||
return {
|
return {
|
||||||
type: LoggerDriverType.Console,
|
type: LoggerDriverType.Console,
|
||||||
|
logLevels: logLevels,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -42,9 +42,16 @@ export class LoggerModule extends ConfigurableModuleClass {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return config?.type === LoggerDriverType.Console
|
const logLevels = config.logLevels ?? [];
|
||||||
? new ConsoleLogger()
|
|
||||||
: undefined;
|
const logger =
|
||||||
|
config?.type === LoggerDriverType.Console
|
||||||
|
? new ConsoleLogger()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
logger?.setLogLevels(logLevels);
|
||||||
|
|
||||||
|
return logger;
|
||||||
},
|
},
|
||||||
inject: options.inject || [],
|
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;
|
handle(data: T): Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { GmailFullSyncJob } from 'src/workspace/messaging/jobs/gmail-full-sync.job';
|
import { GmailFullSyncJob } from 'src/workspace/messaging/jobs/gmail-full-sync.job';
|
||||||
import { CallWebhookJobsJob } from 'src/workspace/workspace-query-runner/jobs/call-webhook-jobs.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 { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module';
|
||||||
import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module';
|
import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module';
|
||||||
import { DataSourceModule } from 'src/metadata/data-source/data-source.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 { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||||
import { FetchWorkspaceMessagesModule } from 'src/workspace/messaging/services/fetch-workspace-messages.module';
|
import { FetchWorkspaceMessagesModule } from 'src/workspace/messaging/services/fetch-workspace-messages.module';
|
||||||
import { GmailPartialSyncJob } from 'src/workspace/messaging/jobs/gmail-partial-sync.job';
|
import { GmailPartialSyncJob } from 'src/workspace/messaging/jobs/gmail-partial-sync.job';
|
||||||
import { EmailSenderJob } from 'src/integrations/email/email-sender.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -21,6 +26,10 @@ import { EmailSenderJob } from 'src/integrations/email/email-sender.job';
|
|||||||
HttpModule,
|
HttpModule,
|
||||||
TypeORMModule,
|
TypeORMModule,
|
||||||
FetchWorkspaceMessagesModule,
|
FetchWorkspaceMessagesModule,
|
||||||
|
UserModule,
|
||||||
|
EnvironmentModule,
|
||||||
|
TypeORMModule,
|
||||||
|
TypeOrmModule.forFeature([FeatureFlagEntity], 'core'),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
@ -40,9 +49,10 @@ import { EmailSenderJob } from 'src/integrations/email/email-sender.job';
|
|||||||
useClass: CallWebhookJob,
|
useClass: CallWebhookJob,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: EmailSenderJob.name,
|
provide: CleanInactiveWorkspaceJob.name,
|
||||||
useClass: EmailSenderJob,
|
useClass: CleanInactiveWorkspaceJob,
|
||||||
},
|
},
|
||||||
|
{ provide: EmailSenderJob.name, useClass: EmailSenderJob },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class JobsModule {
|
export class JobsModule {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { Repository } from 'typeorm';
|
import { FindManyOptions, Repository } from 'typeorm';
|
||||||
|
|
||||||
import { DataSourceEntity } from './data-source.entity';
|
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) {
|
async getDataSourcesMetadataFromWorkspaceId(workspaceId: string) {
|
||||||
return this.dataSourceMetadataRepository.find({
|
return this.dataSourceMetadataRepository.find({
|
||||||
where: { workspaceId },
|
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) {
|
public async deleteObjectsMetadata(workspaceId: string) {
|
||||||
await this.objectMetadataRepository.delete({ workspaceId });
|
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 { Command, CommandRunner, Option } from 'nest-commander';
|
||||||
import { Repository } from 'typeorm';
|
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 { MessagingProducer } from 'src/workspace/messaging/producers/messaging-producer';
|
||||||
import { MessagingUtilsService } from 'src/workspace/messaging/services/messaging-utils.service';
|
import { MessagingUtilsService } from 'src/workspace/messaging/services/messaging-utils.service';
|
||||||
|
|
||||||
@ -32,7 +35,7 @@ export class GmailFullSyncCommand extends CommandRunner {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const isMessagingEnabled = await this.featureFlagRepository.findOneBy({
|
const isMessagingEnabled = await this.featureFlagRepository.findOneBy({
|
||||||
workspaceId: options.workspaceId,
|
workspaceId: options.workspaceId,
|
||||||
key: 'IS_MESSAGING_ENABLED',
|
key: FeatureFlagKeys.IsMessagingEnabled,
|
||||||
value: true,
|
value: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user