Automatically clean up soft-deleted records after X days. (#14862)
Closes #14726 ### Added - `trashRetentionDays` field to workspace entity (default: 14 days) - Automated trash cleanup using BullMQ jobs - Daily cron (00:10 UTC) that enqueues cleanup jobs for all active workspaces - Per-workspace limit: 100k records deleted per day - Calendar-based retention: records deleted on day X are cleaned up X+14 days later (at midnight UTC boundaries) ### Architecture - **Cron (WorkspaceTrashCleanupCronJob):** Runs daily, enqueues jobs in parallel for all workspaces - **Job (WorkspaceTrashCleanupJob):** Processes individual workspace cleanup - **Service (WorkspaceTrashCleanupService):** Discovers tables with `deletedAt`, deletes old records with quota enforcement - **Command:** `npx nx run twenty-server:command cron:workspace:cleanup-trash` to register the cron ### Testing - Unit tests for service with 100% coverage of public API - Tested quota enforcement, error handling, and edge cases --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
@@ -56,6 +56,7 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv
|
||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
|
||||
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
|
||||
import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module';
|
||||
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
|
||||
import { ChannelSyncModule } from 'src/modules/connected-account/channel-sync/channel-sync.module';
|
||||
|
||||
@@ -134,6 +135,7 @@ import { FileModule } from './file/file.module';
|
||||
WebhookModule,
|
||||
PageLayoutModule,
|
||||
ImpersonationModule,
|
||||
TrashCleanupModule,
|
||||
],
|
||||
exports: [
|
||||
AuditModule,
|
||||
|
||||
+8
@@ -2,11 +2,13 @@ import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsNotIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
@@ -197,4 +199,10 @@ export class UpdateWorkspaceInput {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isTwoFactorAuthenticationEnforced?: boolean;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
trashRetentionDays?: number;
|
||||
}
|
||||
|
||||
+2
-1
@@ -476,7 +476,8 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
'displayName' in payload ||
|
||||
'subdomain' in payload ||
|
||||
'customDomain' in payload ||
|
||||
'logo' in payload
|
||||
'logo' in payload ||
|
||||
'trashRetentionDays' in payload
|
||||
) {
|
||||
if (!userWorkspaceId) {
|
||||
throw new Error('Missing userWorkspaceId in authContext');
|
||||
|
||||
@@ -92,6 +92,10 @@ export class Workspace {
|
||||
@Column({ default: true })
|
||||
isPublicInviteLinkEnabled: boolean;
|
||||
|
||||
@Field()
|
||||
@Column({ type: 'integer', default: 14 })
|
||||
trashRetentionDays: number;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => AppToken, (appToken) => appToken.workspace, {
|
||||
cascade: true,
|
||||
|
||||
Reference in New Issue
Block a user