1
20import { Inject, Injectable } from "@nestjs/common";
21import type { Request } from "express";
22import { randomUUID } from "node:crypto";
23import { ApiError } from "../../common/errors.js";
24import { jobTraceMetadata } from "../../common/trace-metadata.js";
25import { PrismaService } from "../../common/prisma.service.js";
26import { RedisService } from "../../common/redis.service.js";
27import { AuditService } from "../../common/audit.service.js";
28import { requireUser } from "../../common/types.js";
29import { assertOrgAdmin } from "./guards.js";
30
31export type RetentionScope = "trash" | "comments" | "audit_logs" | "files";
32export const RETENTION_SCOPES: RetentionScope[] = ["trash", "comments", "audit_logs", "files"];
33
34const DEFAULTS: Record<RetentionScope, { type: "PURGE" | "HOLD"; retainDays: number }> = {
35 trash: { type: "PURGE", retainDays: 30 },
36 comments: { type: "PURGE", retainDays: 90 },
37 audit_logs: { type: "HOLD", retainDays: 365 },
38 files: { type: "PURGE", retainDays: 0 }
39};
40
41const runReportKey = (runId: string) => `worker:retention:report:${runId}`;
42
43@Injectable()
44export class OrgRetentionService {
45 constructor(
46 @Inject(PrismaService) private readonly prisma: PrismaService,
47 @Inject(RedisService) private readonly redis: RedisService,
48 @Inject(AuditService) private readonly audit: AuditService
49 ) {}
50
51
52 async list(orgId: string, req: Request) {
53 const userId = requireUser(req);
54 await assertOrgAdmin(this.prisma, orgId, userId);
55 const rows = await this.prisma.retentionPolicy.findMany({ where: { organizationId: orgId } });
56 const byScope = new Map(rows.map((r) => [r.scope, r]));
57 return {
58 data: RETENTION_SCOPES.map((scope) => {
59 const row = byScope.get(scope);
60 const def = DEFAULTS[scope];
61 const cfg = (row?.config as Record<string, unknown> | null) ?? {};
62 return {
63 scope,
64 type: row?.type ?? def.type,
65 retainDays: row?.retainDays ?? def.retainDays,
66 default: def,
67 lastRun: cfg.lastRun ?? null,
68 lastDryRun: cfg.lastDryRun ?? null,
69 updatedAt: row?.updatedAt ?? null
70 };
71 })
72 };
73 }
74
75
76 async update(
77 orgId: string,
78 body: Partial<Record<RetentionScope, { type: "PURGE" | "HOLD"; retainDays: number }>>,
79 req: Request
80 ) {
81 const userId = requireUser(req);
82 await assertOrgAdmin(this.prisma, orgId, userId);
83 const changed: Record<string, { from: unknown; to: unknown }> = {};
84 for (const scope of Object.keys(body) as RetentionScope[]) {
85 const input = body[scope]!;
86 const existing = await this.prisma.retentionPolicy.findUnique({
87 where: { organizationId_scope: { organizationId: orgId, scope } }
88 });
89 if (!existing) {
90 changed[scope] = { from: DEFAULTS[scope], to: input };
91 await this.prisma.retentionPolicy.create({
92 data: { organizationId: orgId, scope, type: input.type, retainDays: input.retainDays }
93 });
94 } else if (existing.type !== input.type || existing.retainDays !== input.retainDays) {
95 changed[scope] = {
96 from: { type: existing.type, retainDays: existing.retainDays },
97 to: input
98 };
99 await this.prisma.retentionPolicy.update({
100 where: { id: existing.id },
101 data: { type: input.type, retainDays: input.retainDays }
102 });
103 }
104 }
105 if (Object.keys(changed).length) {
106 await this.audit.audit(req, req.auth, {
107 action: "org.retention_updated",
108 resourceType: "organization",
109 resourceId: orgId,
110 metadata: { orgId, changes: changed }
111 });
112 }
113 return { ok: true, changes: changed };
114 }
115
116
123 async enqueueRun(
124 orgId: string,
125 scope: RetentionScope,
126 mode: "dry" | "execute",
127 req: Request
128 ): Promise<{ runId: string; status: "processing" }> {
129 const userId = requireUser(req);
130 await assertOrgAdmin(this.prisma, orgId, userId);
131 const runId = randomUUID();
132
133 const r = this.redis.getRedis();
134 await r.set(
135 runReportKey(runId),
136 JSON.stringify({
137 status: "processing",
138 scope,
139 mode,
140 orgId,
141 requestedBy: userId,
142 startedAt: new Date().toISOString()
143 }),
144 "EX",
145 7 * 86_400
146 );
147 await this.audit.audit(req, req.auth, {
148 action: "retention.run_requested",
149 resourceType: "organization",
150 resourceId: orgId,
151 metadata: { orgId, runId, scope, mode }
152 });
153
154 const { Queue } = await import("bullmq");
155 const q = new Queue("maintenance", {
156 connection: this.redis.getRedis().options as never
157 });
158 try {
159 await q.add(
160 "maintenance",
161 {
162 type: "retention.run",
163 orgId,
164 scope,
165 mode,
166 runId,
167 actor: { type: "USER", id: userId }
168 },
169 { jobId: runId, ...jobTraceMetadata() }
170 );
171 } finally {
172 await q.close();
173 }
174 return { runId, status: "processing" };
175 }
176
177
178 async runStatus(orgId: string, runId: string, req: Request) {
179 const userId = requireUser(req);
180 await assertOrgAdmin(this.prisma, orgId, userId);
181 const raw = await this.redis.getRedis().get(runReportKey(runId));
182 if (!raw) throw ApiError.notFound("Retention run not found (expired).");
183 const report = JSON.parse(raw) as Record<string, unknown>;
184 if (report.orgId !== orgId) throw ApiError.notFound("Retention run not found.");
185 return report;
186 }
187}
188