/** * TASK-0133 — org RETENTION policies (the admin surface). * * One `RetentionPolicy` per `(org, scope)` (unique index). Scopes + defaults: * trash PURGE 30 d (trashed pages — `trashedAt`; recorded conflict: * the spec says `Page.deletedAt`, the live trash * path + worker key off `trashedAt`) * comments PURGE 90 d (comments + replies + reactions) * audit_logs HOLD 365 d (legal hold — never purge while HOLD) * files PURGE 0 d (0 = no purge) * * The PURGE CORE lives in the WORKER (`apps/worker/src/processors/ * retention.ts` — the daily 04:00 job). The admin "Run now / Run dry" * endpoint ENQUEUES the same core (one implementation — rule 4/5) and * returns `202 {runId}`; the UI polls `GET .../retention/runs/:runId` * (a Redis report, TTL 7 d — the existing `worker:retention:report` * pattern). This is also why the worker can be the single writer: the * admin trigger and the cron never race (BullMQ serializes the queue). */ import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import { randomUUID } from "node:crypto"; import { ApiError } from "../../common/errors.js"; import { jobTraceMetadata } from "../../common/trace-metadata.js"; import { PrismaService } from "../../common/prisma.service.js"; import { RedisService } from "../../common/redis.service.js"; import { AuditService } from "../../common/audit.service.js"; import { requireUser } from "../../common/types.js"; import { assertOrgAdmin } from "./guards.js"; export type RetentionScope = "trash" | "comments" | "audit_logs" | "files"; export const RETENTION_SCOPES: RetentionScope[] = ["trash", "comments", "audit_logs", "files"]; const DEFAULTS: Record = { trash: { type: "PURGE", retainDays: 30 }, comments: { type: "PURGE", retainDays: 90 }, audit_logs: { type: "HOLD", retainDays: 365 }, files: { type: "PURGE", retainDays: 0 } }; const runReportKey = (runId: string) => `worker:retention:report:${runId}`; @Injectable() export class OrgRetentionService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(RedisService) private readonly redis: RedisService, @Inject(AuditService) private readonly audit: AuditService ) {} /** `GET /orgs/:orgId/admin/retention` — the four scopes (+ defaults). */ async list(orgId: string, req: Request) { const userId = requireUser(req); await assertOrgAdmin(this.prisma, orgId, userId); const rows = await this.prisma.retentionPolicy.findMany({ where: { organizationId: orgId } }); const byScope = new Map(rows.map((r) => [r.scope, r])); return { data: RETENTION_SCOPES.map((scope) => { const row = byScope.get(scope); const def = DEFAULTS[scope]; const cfg = (row?.config as Record | null) ?? {}; return { scope, type: row?.type ?? def.type, retainDays: row?.retainDays ?? def.retainDays, default: def, lastRun: cfg.lastRun ?? null, lastDryRun: cfg.lastDryRun ?? null, updatedAt: row?.updatedAt ?? null }; }) }; } /** `PATCH /orgs/:orgId/admin/retention` — upsert the given scopes. */ async update( orgId: string, body: Partial>, req: Request ) { const userId = requireUser(req); await assertOrgAdmin(this.prisma, orgId, userId); const changed: Record = {}; for (const scope of Object.keys(body) as RetentionScope[]) { const input = body[scope]!; const existing = await this.prisma.retentionPolicy.findUnique({ where: { organizationId_scope: { organizationId: orgId, scope } } }); if (!existing) { changed[scope] = { from: DEFAULTS[scope], to: input }; await this.prisma.retentionPolicy.create({ data: { organizationId: orgId, scope, type: input.type, retainDays: input.retainDays } }); } else if (existing.type !== input.type || existing.retainDays !== input.retainDays) { changed[scope] = { from: { type: existing.type, retainDays: existing.retainDays }, to: input }; await this.prisma.retentionPolicy.update({ where: { id: existing.id }, data: { type: input.type, retainDays: input.retainDays } }); } } if (Object.keys(changed).length) { await this.audit.audit(req, req.auth, { action: "org.retention_updated", resourceType: "organization", resourceId: orgId, metadata: { orgId, changes: changed } }); } return { ok: true, changes: changed }; } /** * `POST /orgs/:orgId/admin/retention/run` — enqueue the worker's retention * core for ONE scope (`mode: "dry"|"execute"`) and return `202 {runId}`. * The run is audited BY THE WORKER (the actor is the triggering admin, * carried in the job payload — `retention.dry_run` / `retention.purged` / * `retention.hold_skipped` / `retention.failed`). */ async enqueueRun( orgId: string, scope: RetentionScope, mode: "dry" | "execute", req: Request ): Promise<{ runId: string; status: "processing" }> { const userId = requireUser(req); await assertOrgAdmin(this.prisma, orgId, userId); const runId = randomUUID(); // Seed the report (the poll endpoint reads it; TTL 7 d). const r = this.redis.getRedis(); await r.set( runReportKey(runId), JSON.stringify({ status: "processing", scope, mode, orgId, requestedBy: userId, startedAt: new Date().toISOString() }), "EX", 7 * 86_400 ); await this.audit.audit(req, req.auth, { action: "retention.run_requested", resourceType: "organization", resourceId: orgId, metadata: { orgId, runId, scope, mode } }); // Enqueue on the maintenance queue (the worker registers the job). const { Queue } = await import("bullmq"); const q = new Queue("maintenance", { connection: this.redis.getRedis().options as never }); try { await q.add( "maintenance", { type: "retention.run", orgId, scope, mode, runId, actor: { type: "USER", id: userId } }, { jobId: runId, ...jobTraceMetadata() } ); } finally { await q.close(); } return { runId, status: "processing" }; } /** `GET /orgs/:orgId/admin/retention/runs/:runId` — poll the run report. */ async runStatus(orgId: string, runId: string, req: Request) { const userId = requireUser(req); await assertOrgAdmin(this.prisma, orgId, userId); const raw = await this.redis.getRedis().get(runReportKey(runId)); if (!raw) throw ApiError.notFound("Retention run not found (expired)."); const report = JSON.parse(raw) as Record; if (report.orgId !== orgId) throw ApiError.notFound("Retention run not found."); return report; } }