1"use client";
2
3
19import { useEffect, useState } from "react";
20import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
21import { Loader2 } from "lucide-react";
22import { orgAdminApi, type RetentionScope, type RetentionScopeRow, type RetentionType } from "@/lib/org-admin";
23import { isApiError } from "@/lib/api";
24import { toast } from "@/stores/ui-store";
25import { Section, Field, inputCls } from "@/components/settings/settings-shell";
26import { ConfirmDialog } from "@/components/ui/confirm-dialog";
27
28const SCOPES: { id: RetentionScope; label: string; description: string }[] = [
29 { id: "trash", label: "Trash", description: "Permanently delete trashed pages." },
30 { id: "comments", label: "Comments", description: "Purge comments." },
31 { id: "audit_logs", label: "Audit log", description: "Purge audit rows (HOLD = legal hold, never purged)." },
32 { id: "files", label: "Files", description: "Delete uploaded files." },
33];
34
35function formatBytes(n: number): string {
36 if (n < 1024) return `${n} B`;
37 const units = ["KB", "MB", "GB", "TB"];
38 let v = n / 1024;
39 let i = 0;
40 while (v >= 1024 && i < units.length - 1) {
41 v /= 1024;
42 i += 1;
43 }
44 return `${v.toFixed(1)} ${units[i]}`;
45}
46
47interface RunReport {
48 orgId?: string;
49 scope?: string;
50 mode?: "dry" | "execute";
51 status?: string;
52 cutoff?: string;
53 counts?: { pages: number; blocks: number; comments: number; files: number; bytesFreed: number } | null;
54 reason?: string;
55 durationMs?: number;
56}
57
58export function AdminRetentionTab({ orgId }: { orgId: string }) {
59 const qc = useQueryClient();
60 const retention = useQuery({
61 queryKey: ["orgs", orgId, "admin", "retention"],
62 queryFn: () => orgAdminApi.retention(orgId),
63 retry: false,
64 });
65
66 const [drafts, setDrafts] = useState<Partial<Record<RetentionScope, { type: RetentionType; retainDays: number }>>>({});
67 const [run, setRun] = useState<{ runId: string; scope: RetentionScope; mode: "dry" | "execute" } | null>(null);
68 const [confirm, setConfirm] = useState<RetentionScope | null>(null);
69
70 const rows = retention.data?.data ?? [];
71 const row = (s: RetentionScope) => rows.find((r) => r.scope === s) as RetentionScopeRow | undefined;
72 const draft = (s: RetentionScope) => drafts[s] ?? { type: row(s)?.type ?? "PURGE", retainDays: row(s)?.retainDays ?? 0 };
73
74 const patch = useMutation({
75 mutationFn: (body: Partial<Record<RetentionScope, { type: RetentionType; retainDays: number }>>) =>
76 orgAdminApi.retentionPatch(orgId, body),
77 onSuccess: () => {
78 toast("success", "Retention policy updated");
79 setDrafts({});
80 qc.invalidateQueries({ queryKey: ["orgs", orgId, "admin", "retention"] });
81 },
82 onError: (e) => toast("error", "Could not update retention", isApiError(e) ? e.message : undefined),
83 });
84
85 const runMut = useMutation({
86 mutationFn: (v: { scope: RetentionScope; mode: "dry" | "execute" }) => orgAdminApi.retentionRun(orgId, v.scope, v.mode),
87 onSuccess: (res, v) => {
88 setConfirm(null);
89 setRun({ runId: res.runId, scope: v.scope, mode: v.mode });
90 },
91 onError: (e) => toast("error", "Could not start retention run", isApiError(e) ? e.message : undefined),
92 });
93
94
95 const runStatus = useQuery({
96 queryKey: ["orgs", orgId, "admin", "retention", "run", run?.runId],
97 queryFn: () => orgAdminApi.retentionRunStatus(orgId, run!.runId),
98 enabled: Boolean(run),
99 refetchInterval: (q) => {
100 const status = (q.state.data as RunReport | undefined)?.status;
101 return status === "processing" || !status ? 1500 : false;
102 },
103 refetchIntervalInBackground: false,
104 retry: false,
105 });
106 const report = runStatus.data as RunReport | undefined;
107
108
109 useEffect(() => {
110 if (report && report.status !== "processing") {
111 qc.invalidateQueries({ queryKey: ["orgs", orgId, "admin", "retention"] });
112 }
113 }, [report, qc, orgId]);
114
115 if (retention.isLoading) return <p className="text-sm text-muted-foreground" role="status">Loading retention…</p>;
116 if (retention.isError) return <p className="text-sm text-destructive" role="alert">Could not load retention.</p>;
117
118 return (
119 <div className="space-y-6">
120 <Section
121 title="Policies"
122 description="The worker runs these daily (dry by default; `WORKER_RETENTION_EXECUTE` enables the daily execute). Audit rows on HOLD are never purged."
123 >
124 <div className="grid gap-4 sm:grid-cols-2">
125 {SCOPES.map((s) => {
126 const r = row(s.id);
127 const d = draft(s.id);
128 return (
129 <div key={s.id} className="rounded-lg border border-border p-3">
130 <div className="text-sm font-medium">{s.label}</div>
131 <div className="mb-3 text-xs text-muted-foreground">{s.description}</div>
132 <div className="flex items-end gap-2">
133 <Field label="Action">
134 <select
135 value={d.type}
136 onChange={(e) => setDrafts((p) => ({ ...p, [s.id]: { ...d, type: e.target.value as RetentionType } }))}
137 className={inputCls}
138 aria-label={`${s.label} action`}
139 >
140 <option value="PURGE">Purge</option>
141 <option value="HOLD">Hold</option>
142 </select>
143 </Field>
144 <Field label="After (days)">
145 <input
146 type="number"
147 min={0}
148 max={3650}
149 value={d.retainDays}
150 disabled={d.type === "HOLD"}
151 onChange={(e) =>
152 setDrafts((p) => ({ ...p, [s.id]: { ...d, retainDays: Math.max(0, Number(e.target.value) || 0) } }))
153 }
154 className={`${inputCls} disabled:opacity-50`}
155 aria-label={`${s.label} retain days`}
156 />
157 </Field>
158 <button
159 type="button"
160 onClick={() => patch.mutate({ [s.id]: d } as never)}
161 disabled={patch.isPending || JSON.stringify(d) === JSON.stringify({ type: r?.type, retainDays: r?.retainDays })}
162 className="rounded-md border border-input px-3 py-1.5 text-sm font-medium hover:bg-muted disabled:opacity-50"
163 >
164 Save
165 </button>
166 </div>
167 {r && (
168 <div className="mt-2 text-[11px] text-muted-foreground">
169 Default {r.default.type.toLowerCase()} {r.default.retainDays}d
170 {r.lastRun ? ` · last run ${new Date(r.lastRun).toLocaleString()}` : ""}
171 {r.lastDryRun ? ` · last dry ${new Date(r.lastDryRun).toLocaleString()}` : ""}
172 </div>
173 )}
174 </div>
175 );
176 })}
177 </div>
178 </Section>
179
180 <Section title="Run now" description="Dry-run logs only; execute purges (the worker flag gates daily execute).">
181 <div className="flex flex-wrap items-center gap-2">
182 {SCOPES.map((s) => (
183 <div key={s.id} data-testid={`retention-run-${s.id}`} className="flex items-center gap-1 rounded-md border border-border p-1">
184 <span className="px-1.5 text-xs font-medium text-muted-foreground">{s.label}</span>
185 <button
186 type="button"
187 onClick={() => runMut.mutate({ scope: s.id, mode: "dry" })}
188 disabled={runMut.isPending}
189 className="rounded px-2 py-1 text-xs font-medium hover:bg-muted disabled:opacity-50"
190 >
191 Dry
192 </button>
193 <button
194 type="button"
195 onClick={() => setConfirm(s.id)}
196 disabled={runMut.isPending}
197 className="rounded px-2 py-1 text-xs font-medium text-destructive hover:bg-muted disabled:opacity-50"
198 >
199 Execute
200 </button>
201 </div>
202 ))}
203 </div>
204
205 {run && (
206 <div data-testid="org-admin-retention-report" className="mt-4 rounded-md border border-border p-3 text-sm" aria-live="polite">
207 {runStatus.isFetching && !report?.status ? (
208 <span className="inline-flex items-center gap-2 text-muted-foreground">
209 <Loader2 className="size-4 animate-spin" /> Running {run.scope} ({run.mode})…
210 </span>
211 ) : report ? (
212 <ReportView report={report} onDone={() => setRun(null)} />
213 ) : null}
214 </div>
215 )}
216 </Section>
217
218 <ConfirmDialog
219 open={confirm !== null}
220 title={`Execute retention on ${confirm ?? ""}?`}
221 description="This PURGES data that exceeds the policy (irreversible). Audit rows on HOLD are never touched. Consider a dry-run first."
222 confirmLabel="Execute"
223 destructive
224 busy={runMut.isPending}
225 onConfirm={() => confirm && runMut.mutate({ scope: confirm, mode: "execute" })}
226 onClose={() => setConfirm(null)}
227 />
228 </div>
229 );
230}
231
232function ReportView({ report, onDone }: { report: RunReport; onDone: () => void }) {
233 const c = report.counts;
234 return (
235 <div className="flex flex-wrap items-center gap-x-4 gap-y-1">
236 <span
237 className={
238 "rounded px-1.5 py-0.5 text-xs font-medium " +
239 (report.status === "dry_run" || report.status === "purged"
240 ? "bg-primary/10 text-primary"
241 : "bg-muted text-muted-foreground")
242 }
243 >
244 {report.status === "dry_run" ? "Dry-run complete" : report.status === "purged" ? "Executed" : report.status}
245 </span>
246 {report.reason && <span className="text-xs text-muted-foreground">reason: {report.reason}</span>}
247 {c && (
248 <span className="text-xs">
249 {c.pages} page{c.pages === 1 ? "" : "s"} · {c.blocks} blocks · {c.comments} comments · {c.files} files ·{" "}
250 {formatBytes(c.bytesFreed)} freed
251 </span>
252 )}
253 {c === null && <span className="text-xs text-muted-foreground">nothing to {report.mode === "dry" ? "purge" : "purge"} (held or empty)</span>}
254 {report.durationMs !== undefined && <span className="text-[11px] text-muted-foreground">{report.durationMs} ms</span>}
255 <button type="button" onClick={onDone} className="ml-auto text-xs text-muted-foreground hover:text-foreground hover:underline">
256 Dismiss
257 </button>
258 </div>
259 );
260}
261