"use client"; /** * TASK-0133 — Retention tab: per-scope policy editor + dry-run / execute-now. * * Four scopes (the worker's daily job + the admin trigger both run the SAME * core): * trash — permanently delete trashed pages past N days * comments — purge comments past N days * audit_logs — HOLD (legal hold, NEVER purged) or purge past N days * files — delete files past N days * * `Run dry` logs what WOULD be purged (no deletes); `Run now` executes * (gated by `WORKER_RETENTION_EXECUTE` on the worker side — a dev without the * flag dry-runs even an "execute" request). Both enqueue the same maintenance * job and return `202 {runId}`; this tab polls `GET …/retention/runs/:runId` * for the report the worker writes to Redis. */ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { orgAdminApi, type RetentionScope, type RetentionScopeRow, type RetentionType } from "@/lib/org-admin"; import { isApiError } from "@/lib/api"; import { toast } from "@/stores/ui-store"; import { Section, Field, inputCls } from "@/components/settings/settings-shell"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; const SCOPES: { id: RetentionScope; label: string; description: string }[] = [ { id: "trash", label: "Trash", description: "Permanently delete trashed pages." }, { id: "comments", label: "Comments", description: "Purge comments." }, { id: "audit_logs", label: "Audit log", description: "Purge audit rows (HOLD = legal hold, never purged)." }, { id: "files", label: "Files", description: "Delete uploaded files." }, ]; function formatBytes(n: number): string { if (n < 1024) return `${n} B`; const units = ["KB", "MB", "GB", "TB"]; let v = n / 1024; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; } return `${v.toFixed(1)} ${units[i]}`; } interface RunReport { orgId?: string; scope?: string; mode?: "dry" | "execute"; status?: string; cutoff?: string; counts?: { pages: number; blocks: number; comments: number; files: number; bytesFreed: number } | null; reason?: string; durationMs?: number; } export function AdminRetentionTab({ orgId }: { orgId: string }) { const qc = useQueryClient(); const retention = useQuery({ queryKey: ["orgs", orgId, "admin", "retention"], queryFn: () => orgAdminApi.retention(orgId), retry: false, }); const [drafts, setDrafts] = useState>>({}); const [run, setRun] = useState<{ runId: string; scope: RetentionScope; mode: "dry" | "execute" } | null>(null); const [confirm, setConfirm] = useState(null); const rows = retention.data?.data ?? []; const row = (s: RetentionScope) => rows.find((r) => r.scope === s) as RetentionScopeRow | undefined; const draft = (s: RetentionScope) => drafts[s] ?? { type: row(s)?.type ?? "PURGE", retainDays: row(s)?.retainDays ?? 0 }; const patch = useMutation({ mutationFn: (body: Partial>) => orgAdminApi.retentionPatch(orgId, body), onSuccess: () => { toast("success", "Retention policy updated"); setDrafts({}); qc.invalidateQueries({ queryKey: ["orgs", orgId, "admin", "retention"] }); }, onError: (e) => toast("error", "Could not update retention", isApiError(e) ? e.message : undefined), }); const runMut = useMutation({ mutationFn: (v: { scope: RetentionScope; mode: "dry" | "execute" }) => orgAdminApi.retentionRun(orgId, v.scope, v.mode), onSuccess: (res, v) => { setConfirm(null); setRun({ runId: res.runId, scope: v.scope, mode: v.mode }); }, onError: (e) => toast("error", "Could not start retention run", isApiError(e) ? e.message : undefined), }); // Poll the run report while a run is in flight (worker writes it on finish). const runStatus = useQuery({ queryKey: ["orgs", orgId, "admin", "retention", "run", run?.runId], queryFn: () => orgAdminApi.retentionRunStatus(orgId, run!.runId), enabled: Boolean(run), refetchInterval: (q) => { const status = (q.state.data as RunReport | undefined)?.status; return status === "processing" || !status ? 1500 : false; // stop when terminal }, refetchIntervalInBackground: false, retry: false, }); const report = runStatus.data as RunReport | undefined; // Re-read the retention rows after a run (lastRun updates). useEffect(() => { if (report && report.status !== "processing") { qc.invalidateQueries({ queryKey: ["orgs", orgId, "admin", "retention"] }); } }, [report, qc, orgId]); if (retention.isLoading) return

Loading retention…

; if (retention.isError) return

Could not load retention.

; return (
{SCOPES.map((s) => { const r = row(s.id); const d = draft(s.id); return (
{s.label}
{s.description}
setDrafts((p) => ({ ...p, [s.id]: { ...d, retainDays: Math.max(0, Number(e.target.value) || 0) } })) } className={`${inputCls} disabled:opacity-50`} aria-label={`${s.label} retain days`} />
{r && (
Default {r.default.type.toLowerCase()} {r.default.retainDays}d {r.lastRun ? ` · last run ${new Date(r.lastRun).toLocaleString()}` : ""} {r.lastDryRun ? ` · last dry ${new Date(r.lastDryRun).toLocaleString()}` : ""}
)}
); })}
{SCOPES.map((s) => (
{s.label}
))}
{run && (
{runStatus.isFetching && !report?.status ? ( Running {run.scope} ({run.mode})… ) : report ? ( setRun(null)} /> ) : null}
)}
confirm && runMut.mutate({ scope: confirm, mode: "execute" })} onClose={() => setConfirm(null)} />
); } function ReportView({ report, onDone }: { report: RunReport; onDone: () => void }) { const c = report.counts; return (
{report.status === "dry_run" ? "Dry-run complete" : report.status === "purged" ? "Executed" : report.status} {report.reason && reason: {report.reason}} {c && ( {c.pages} page{c.pages === 1 ? "" : "s"} · {c.blocks} blocks · {c.comments} comments · {c.files} files ·{" "} {formatBytes(c.bytesFreed)} freed )} {c === null && nothing to {report.mode === "dry" ? "purge" : "purge"} (held or empty)} {report.durationMs !== undefined && {report.durationMs} ms}
); }