1
18import { Worker, type Job } from "bullmq";
19import { withJobOtel } from "../lib/job-otel.js";
20
21import type { Redis } from "ioredis";
22import { z } from "zod";
23import type { PrismaClient } from "@prisma/client";
24import { QUEUES, enqueue } from "../queues.js";
25import { getLogger } from "../logger.js";
26import type { WorkerEnv } from "../config.js";
27import { notifyWorkspaceAdmins } from "../lib/notify.js";
28import { runDigestRun } from "./email-decision.processor.js";
29import { S3Client } from "@factory/storage";
30import { runOrphanSweep } from "./files/cleanup.js";
31import { runAuditExport } from "./audit-export.js";
32import { runRetentionScope, runRetentionDaily, pruneAllPageVersions, pruneWorkspacePageVersions } from "./retention.js";
33import { runTaskDueSweep, runReviewDueSweep } from "./tasks.js";
34
35const log = getLogger("maintenance");
36
37const INVITE_EXPIRY_DAYS = 7;
38
39export const maintenanceJobSchema = z.union([
40 z.object({ type: z.literal("retention") }),
41
42
43 z.object({
44 type: z.literal("retention.run"),
45 orgId: z.string().min(1),
46 scope: z.enum(["trash", "comments", "audit_logs", "files"]),
47 mode: z.enum(["dry", "execute"]),
48 runId: z.string().min(1),
49 actor: z.object({ type: z.enum(["USER", "SYSTEM"]), id: z.string().min(1) })
50 }),
51
52 z.object({
53 type: z.literal("audit.export"),
54 orgId: z.string().min(1),
55 exportId: z.string().min(1)
56 }),
57 z.object({ type: z.literal("reconcile") }),
58
59
60 z.object({ type: z.literal("search-reconcile") }),
61 z.object({ type: z.literal("dlq-report") }),
62 z.object({ type: z.literal("cleanup") }),
63 z.object({ type: z.literal("workspace-resync"), workspaceId: z.string().min(1) }),
64
65 z.object({ type: z.literal("notification-digest") }),
66
67 z.object({ type: z.literal("orphans") }),
68
69 z.object({
70 type: z.literal("versions.prune"),
71 workspaceId: z.string().min(1),
72 actor: z.object({ type: z.enum(["USER", "SYSTEM"]), id: z.string().min(1) })
73 }),
74
75 z.object({ type: z.literal("task-due-sweep") }),
76
77 z.object({ type: z.literal("review-due-sweep") })
78]);
79export type MaintenanceJobData = z.infer<typeof maintenanceJobSchema>;
80
81export const MAINTENANCE_REPEATABLES = [
82
83
84 { id: "maintenance:retention", pattern: "0 4 * * *", data: { type: "retention" } },
85 { id: "maintenance:reconcile", pattern: "30 2 * * 0", data: { type: "reconcile" } },
86
87
88 { id: "maintenance:search-reconcile", pattern: "*/15 * * * *", data: { type: "search-reconcile" } },
89 { id: "maintenance:webhook-dlq", pattern: "0 * * * *", data: { type: "dlq-report" } },
90 { id: "maintenance:cleanup", pattern: "15 * * * *", data: { type: "cleanup" } },
91
92
93 { id: "maintenance:notification-digest", pattern: "30 * * * *", data: { type: "notification-digest" } },
94
95
96 { id: "maintenance:orphans", pattern: "30 3 * * *", data: { type: "orphans" } },
97
98
99 { id: "maintenance:task-due", pattern: "45 * * * *", data: { type: "task-due-sweep" } },
100 { id: "maintenance:review-due", pattern: "46 * * * *", data: { type: "review-due-sweep" } }
101] as const;
102
103
116export async function runRetention(
117 prisma: PrismaClient,
118 redis: Redis,
119 env: WorkerEnv
120): Promise<Record<string, unknown>> {
121 const report = await runRetentionDaily(prisma, redis, env);
122
123 report.versions = await pruneAllPageVersions(prisma, { type: "SYSTEM", id: "worker:maintenance" });
124 await redis
125 .set("worker:retention:report", JSON.stringify({ ...report, at: new Date().toISOString() }), "EX", 7 * 86_400)
126 .catch(() => undefined);
127 return report;
128}
129
130
136export async function runReconcile(connection: Redis): Promise<{ enqueued: "search-index:reconcile" }> {
137 await enqueue(connection, QUEUES.searchIndex, { type: "reconcile" });
138 log.info({}, "search reconcile enqueued (search-index queue)");
139 return { enqueued: "search-index:reconcile" };
140}
141
142
147export async function runWebhookDlqReport(
148 prisma: PrismaClient,
149 connection: Redis,
150 env: WorkerEnv
151): Promise<{ stale: number; notified: number }> {
152 const t0 = Date.now();
153 const cutoff = new Date(Date.now() - 24 * 3_600_000);
154 const stale = await prisma.webhookDelivery.findMany({
155 where: { status: "FAILED", createdAt: { lt: cutoff } },
156 select: { id: true, webhookId: true, event: true, error: true },
157 take: 100
158 });
159 let notified = 0;
160 const byWorkspace = new Map<string, typeof stale>();
161 for (const d of stale) {
162 const webhook = await prisma.webhook.findUnique({
163 where: { id: d.webhookId },
164 select: { workspaceId: true }
165 });
166 if (!webhook) continue;
167 const flag = `worker:dlq-reported:${d.id}`;
168 const already = await connection.exists(flag).catch(() => 0);
169 if (already) continue;
170 await connection.set(flag, "1", "EX", 7 * 86_400).catch(() => undefined);
171 const list = byWorkspace.get(webhook.workspaceId) ?? [];
172 list.push(d);
173 byWorkspace.set(webhook.workspaceId, list);
174 notified++;
175 }
176 for (const [workspaceId, items] of byWorkspace) {
177 await notifyWorkspaceAdmins(
178 prisma,
179 connection,
180 env.SMTP_HOST,
181 workspaceId,
182 `${items.length} webhook delivery(s) dead-lettered`,
183 items
184 .slice(0, 5)
185 .map((d) => `• ${d.event}: ${d.error ?? "unknown error"}`)
186 .join("\n"),
187 undefined,
188 "INTEGRATION"
189 );
190 }
191 log.info({ stale: stale.length, notified, durationMs: Date.now() - t0 }, "webhook DLQ report done");
192 return { stale: stale.length, notified };
193}
194
195
199export async function runCleanup(prisma: PrismaClient): Promise<Record<string, number>> {
200 const t0 = Date.now();
201 const now = new Date();
202 const inviteCutoff = new Date(Date.now() - INVITE_EXPIRY_DAYS * 86_400_000);
203
204 const refreshTokenRes = await prisma.refreshToken.deleteMany({
205 where: { expiresAt: { lt: now }, revokedAt: null }
206 });
207 const sessionRes = await prisma.session.updateMany({
208 where: { expiresAt: { lt: now }, revokedAt: null },
209 data: { revokedAt: now }
210 });
211 const invitationRes = await prisma.invitation.updateMany({
212 where: {
213 status: "PENDING",
214 expiresAt: { lt: now },
215 revokedAt: null,
216 createdAt: { lt: inviteCutoff }
217 },
218 data: { status: "EXPIRED" }
219 });
220 const apiKeyRes = await prisma.apiKey.updateMany({
221 where: { expiresAt: { lt: now }, revokedAt: null },
222 data: { revokedAt: now }
223 });
224
225 const counts = {
226 expiredRefreshTokens: refreshTokenRes.count,
227 revokedSessions: sessionRes.count,
228 expiredInvitations: invitationRes.count,
229 revokedApiKeys: apiKeyRes.count
230 };
231 log.info({ ...counts, durationMs: Date.now() - t0 }, "cleanup done");
232 return counts;
233}
234
235
245export async function runOrphans(
246 prisma: PrismaClient,
247 env: WorkerEnv
248): Promise<Record<string, unknown>> {
249 const s3 = new S3Client({
250 config: {
251
252
253
254 endpoint: env.S3_ENDPOINT.replace(/\/+$/, ""),
255 region: env.S3_REGION,
256 bucket: env.S3_BUCKET_FILES || env.S3_BUCKET,
257 accessKeyId: env.S3_ACCESS_KEY,
258 secretAccessKey: env.S3_SECRET_KEY,
259 forcePathStyle: true
260 }
261 });
262 const report = await runOrphanSweep({ s3, prisma, log });
263 return { ...report };
264}
265
266
267
268
269
270export function createMaintenanceWorker(opts: {
271 connection: Redis;
272 prisma: PrismaClient;
273 env: WorkerEnv;
274 concurrency?: number;
275}): Worker {
276 const { connection, prisma, env } = opts;
277 return new Worker(
278 QUEUES.maintenance,
279 withJobOtel(QUEUES.maintenance, async (job: Job) => {
280 const started = Date.now();
281 const data = maintenanceJobSchema.parse(job.data);
282 log.info({ jobId: job.id, type: data.type }, "maintenance job");
283 switch (data.type) {
284 case "retention":
285 return runRetention(prisma, connection, env);
286 case "retention.run": {
287
288
289
290 const report = await runRetentionScope({
291 prisma,
292 redis: connection,
293 orgId: data.orgId,
294 scope: data.scope,
295 mode: data.mode,
296 actor: data.actor,
297 runId: data.runId
298 });
299 log.info({ jobId: job.id, orgId: data.orgId, scope: data.scope, ...report }, "retention.run done");
300 return report;
301 }
302 case "audit.export": {
303
304 const res = await runAuditExport(prisma, connection, data.orgId, data.exportId);
305 log.info({ jobId: job.id, ...res }, "audit export done");
306 return res;
307 }
308 case "reconcile":
309 case "search-reconcile":
310 return runReconcile(connection);
311 case "dlq-report":
312 return runWebhookDlqReport(prisma, connection, env);
313 case "cleanup":
314 return runCleanup(prisma);
315 case "notification-digest": {
316
317 const res = await runDigestRun(prisma, connection, env);
318 log.info({ jobId: job.id, enqueued: res.enqueued }, "notification digest done");
319 return res;
320 }
321 case "orphans": {
322
323 const res = await runOrphans(prisma, env);
324 log.info({ jobId: job.id, ...res }, "orphan sweep done");
325 return res;
326 }
327 case "versions.prune": {
328 const res = await pruneWorkspacePageVersions(prisma, data.workspaceId, data.actor);
329 log.info({ jobId: job.id, ...res }, "versions.prune done");
330 return res;
331 }
332 case "task-due-sweep": {
333
334 const res = await runTaskDueSweep(prisma, connection);
335 log.info({ jobId: job.id, ...res }, "task-due-sweep done");
336 return res;
337 }
338 case "review-due-sweep": {
339
340 const res = await runReviewDueSweep(prisma, connection);
341 log.info({ jobId: job.id, ...res }, "review-due-sweep done");
342 return res;
343 }
344 case "workspace-resync": {
345
346
347 await enqueue(connection, QUEUES.searchIndex, {
348 type: "reindex",
349 workspaceId: data.workspaceId
350 });
351 log.info({ jobId: job.id, workspaceId: data.workspaceId }, "workspace resync enqueued");
352 return { resynced: data.workspaceId };
353 }
354 }
355 log.info({ jobId: job.id, ms: Date.now() - started }, "done");
356 }),
357 {
358 connection,
359 concurrency: opts.concurrency ?? env.WORKER_CONCURRENCY
360 }
361 );
362}
363