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:
Abdullah.
2025-10-14 21:49:40 +05:00
committed by GitHub
parent bca79f91ac
commit d750df7fff
26 changed files with 714 additions and 28 deletions
@@ -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,
@@ -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;
}
@@ -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,