"use client"; /** * `` — admin view of the weekly `DataBackup` snapshots. * * Lists every snapshot row (one per user × backup-type) with size and age, * plus a "Run backup now" CTA that enqueues the pg-boss `data-backup` job. * The encrypted payload itself is never shipped to the browser; only the * size in bytes is surfaced. * * Folds in v1.4.6 deferred T2.6. */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, BookOpen, Database, Download, History, Loader2, PlayCircle, Upload, } from "lucide-react"; import { useRef, useState, type ChangeEvent } from "react"; import { toast } from "sonner"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { ListRow } from "@/components/ui/list-row"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { BackupRow, BackupsList } from "@/types/backups"; import type { RestoreSkipSummary, SkippedCatalogue, } from "@/lib/export/restore-skips"; import { getApiErrorMessage } from "./_shared"; import { apiFetch, apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch"; /** * Typed-confirmation dialog for restore. The destructive Restore button * is gated behind: * 1. Opening the dialog (one click). * 2. Reading the warning copy (Title + Description spell out exactly * what's about to happen + which user it affects). * 3. Typing the literal string `RESTORE` into the prompt input — * anything else keeps the confirm button disabled. * * Three independent steps before the request fires == "triple confirm". * Mirrors the wipe dialog's pattern but adds the typed gate because the * blast radius is bigger (re-creates rows, not just deletes). */ function RestoreRowDialog({ row, pending, onConfirm, }: { row: BackupRow; pending: boolean; onConfirm: () => void; }) { const { t } = useTranslations(); const fmt = useFormatters(); const [open, setOpen] = useState(false); const [typed, setTyped] = useState(""); const matched = typed.trim() === "RESTORE"; return ( { setOpen(next); if (!next) setTyped(""); }} > {t("admin.section.backups.restoreTitle")} {t("admin.section.backups.restoreDescription", { username: row.username, when: fmt.dateTime(row.createdAt), })}
setTyped(e.target.value)} autoComplete="off" spellCheck={false} placeholder="RESTORE" />
{t("common.cancel")} { if (!matched) return; setOpen(false); setTyped(""); onConfirm(); }} > {pending ? t("admin.section.backups.restoreInProgress") : t("admin.section.backups.restoreConfirm")}
); } function formatBytes(bytes: number, fmt: ReturnType) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) { return `${fmt.integer(Math.round(bytes / 1024))} KB`; } return `${fmt.number(bytes / 1024 / 1024, 2)} MB`; } /** * Render the `DataBackup.type` enum (`WEEKLY_AUTO` / `MANUAL`) as a * human label. Unknown values fall through to the raw enum so a future * type added in the schema is still legible at a glance. */ function formatBackupType( type: string, t: ReturnType["t"], ) { if (type === "WEEKLY_AUTO") return t("admin.section.backups.typeWeeklyAuto"); if (type === "MANUAL") return t("admin.section.backups.typeManual"); return type; } /** * Which catalogue a skipped key belonged to, as a label. * * Spelled out with literal `t()` calls rather than an interpolated key, so * `i18n-call-site-coverage.test.ts` can see each one and prove the bundle has * it. An interpolated key is invisible to that guard and shows up as raw dot * notation in production the day someone renames a catalogue. */ function catalogueLabel( catalogue: SkippedCatalogue, t: ReturnType["t"], ) { if (catalogue === "cycleSymptom") { return t("admin.section.backups.restoreSkippedCycleSymptom"); } if (catalogue === "illnessSymptom") { return t("admin.section.backups.restoreSkippedIllnessSymptom"); } return t("admin.section.backups.restoreSkippedMoodFactor"); } /** * What the last restore could not put back. * * Its own component, and exported, so it can be rendered against a report in a * test instead of only through a state transition the SSR smoke tests here * cannot drive. A report that reaches the response and never reaches the screen * is the same silence the reporting exists to end, so the render is worth * proving on its own. * * It names every key rather than totalling them away: an operator who cannot * see WHICH symptom went missing cannot decide whether it mattered. */ export function RestoreSkipReport({ report, onDismiss, }: { report: RestoreSkipSummary; onDismiss: () => void; }) { const { t } = useTranslations(); if (report.links === 0) return null; return (
); } export function BackupsSection() { const { t } = useTranslations(); const fmt = useFormatters(); const queryClient = useQueryClient(); const { data, isLoading, isError } = useQuery({ queryKey: queryKeys.adminBackups(), queryFn: async () => { return apiGet("/api/admin/backups"); }, }); const runBackup = useMutation({ mutationFn: async () => { return apiPost<{ jobId: string | null }>("/api/admin/backups/run"); }, onSuccess: () => { toast.success(t("admin.section.backups.runEnqueued")); // The job runs async; refetch after a short delay so the new row // shows up without leaving the user wondering whether the click // did anything. setTimeout(() => { queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() }); }, 2000); }, onError: (err) => { toast.error( err instanceof Error && err.message ? err.message : t("admin.section.backups.runFailed"), ); }, }); const rows: BackupRow[] = data?.rows ?? []; // Per-row download in-flight state — keyed by backup id so two // parallel clicks on different rows don't share a single spinner. const [downloadingId, setDownloadingId] = useState(null); // Upload state — single-file flow, drives the file input + button. const fileInputRef = useRef(null); const upload = useMutation({ mutationFn: async (file: File) => { const fd = new FormData(); fd.append("file", file); // apiFetch with a raw init: the multipart body must NOT be JSON // re-encoded, so the verb helper (which JSON-stringifies) is out. return apiFetch<{ id: string; valid: true; summary: { measurements: number; medications: number; intakeEvents: number; moodEntries: number; cycles?: number; cycleDayLogs?: number; // v1.28 backup-completeness — optional so an older-schema upload // (pre-v1.28) still totals correctly without these keys. labResults?: number; biomarkers?: number; illnessEpisodes?: number; illnessDayLogs?: number; allergies?: number; familyHistory?: number; workouts?: number; documents?: number; // Optional for the same reason: a file written before each of these // rode the wire carries no key, and an absent key must total as // nothing rather than break the count. nutrientDays?: number; healthProfile?: number; healthProfileFactRevisions?: number; customMetrics?: number; customMetricEntries?: number; intradayProfiles?: number; }; }>("/api/admin/backups/upload", { method: "POST", body: fd }); }, onSuccess: (data) => { const total = data.summary.measurements + data.summary.medications + data.summary.intakeEvents + data.summary.moodEntries + (data.summary.cycles ?? 0) + (data.summary.cycleDayLogs ?? 0) + (data.summary.labResults ?? 0) + (data.summary.biomarkers ?? 0) + (data.summary.illnessEpisodes ?? 0) + (data.summary.illnessDayLogs ?? 0) + (data.summary.allergies ?? 0) + (data.summary.familyHistory ?? 0) + (data.summary.workouts ?? 0) + (data.summary.documents ?? 0) + // The count the admin is shown has to be the count the file carries. // The nutrient day totals were added to the payload and never to this // sum, so an upload that restored them reported fewer records than it // held — a smaller version of the same defect, on the reporting side. (data.summary.nutrientDays ?? 0) + (data.summary.healthProfile ?? 0) + (data.summary.healthProfileFactRevisions ?? 0) + (data.summary.customMetrics ?? 0) + (data.summary.customMetricEntries ?? 0) + (data.summary.intradayProfiles ?? 0); toast.success( t("admin.section.backups.uploadSuccess", { count: String(total) }), ); queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() }); }, onError: (err) => { toast.error( err instanceof Error && err.message ? err.message : t("admin.section.backups.uploadFailed"), ); }, onSettled: () => { // Reset the file input so the same file can be re-selected after a // rejected upload (browsers keep the previous selection otherwise). if (fileInputRef.current) fileInputRef.current.value = ""; }, }); function handleFileChange(e: ChangeEvent) { const file = e.target.files?.[0]; if (file) upload.mutate(file); } // What the last restore could not put back. Held in state rather than left // to the toast: a toast for "twelve symptom links are gone" disappears in // four seconds and the operator has no way to get it back. This panel stays // until they dismiss it. const [skipped, setSkipped] = useState(null); // Restore: typed-confirmation dialog. The mutation is keyed by row id // and used inline by `` below — keeping the // mutation here lets the parent invalidate the list query on success. const restore = useMutation({ mutationFn: async (row: BackupRow) => { // Idempotency-Key prevents a double-click from re-running the // destructive transaction. Include the row id so two different // backups can both be restored independently in the same minute. const idempotencyKey = `restore-${row.id}-${crypto.randomUUID()}`; return apiPost<{ restored: true; skipped?: RestoreSkipSummary }>( `/api/admin/backups/${row.id}/restore`, { confirm: "RESTORE" }, { headers: { "Idempotency-Key": idempotencyKey } }, ); }, onSuccess: (data) => { // A restore that dropped links is not a plain success and must not read // as one. The count and the exact keys go on screen; the same report is // in the audit row for anyone asking later. const report = data.skipped; if (report && report.links > 0) { setSkipped(report); toast.warning( t("admin.section.backups.restoreSkippedToast", { links: String(report.links), }), ); } else { setSkipped(null); toast.success(t("admin.section.backups.restoreSuccess")); } queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() }); // Restore touches every personal-data table; nuke the broader // cache so dashboards / lists rebuild against the new state. queryClient.invalidateQueries(); }, onError: (err) => { toast.error( err instanceof Error && err.message ? err.message : t("admin.section.backups.restoreFailed"), ); }, }); async function handleDownload(row: BackupRow) { setDownloadingId(row.id); try { // apiFetchRaw: the download needs the raw Response for the blob + // the Content-Disposition filename header. const res = await apiFetchRaw(`/api/admin/backups/${row.id}/download`); if (!res.ok) { throw new Error(await getApiErrorMessage(res)); } // Use the server-provided Content-Disposition filename if present, // otherwise fall back to a deterministic client-side name. The // server filename is more accurate (uses the actual createdAt). const cd = res.headers.get("content-disposition") ?? ""; const match = cd.match(/filename="?([^";]+)"?/i); const fallback = `healthlog-backup-${row.userId}-${row.createdAt.slice(0, 10)}.json`; const filename = match?.[1] ?? fallback; const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast.success(t("admin.section.backups.downloadStarted")); } catch (err) { toast.error( err instanceof Error && err.message ? err.message : t("admin.section.backups.downloadFailed"), ); } finally { setDownloadingId(null); } } return ( {/* v1.18.1 E3 — the snapshot count leads (numbers first), the "Run now" button follows, and the explainer + docs link move into the header's description slot. Previously the description sat in a separate `mt-1 pl-7` paragraph that crowded the button directly above it; routing it through the header's `space-y-1` stack restores breathing room and left-aligns it under the title. */} {rows.length} ) : null } status={ } description={

{t("admin.section.backups.description")}{" "} {/* External docs link — `noopener noreferrer` because this leaves the admin shell. */}

} /> {/* Upload card — separate from the table so admins can ingest a backup file independently of any existing rows. The visible button proxies a hidden file input so the layout stays clean while keyboard / screen-reader users keep a labelled control. */}
{t("admin.section.backups.uploadTitle")}

{t("admin.section.backups.uploadDescription")}{" "} {t("admin.section.backups.uploadHelp")}

{skipped ? ( setSkipped(null)} /> ) : null} {isLoading ? (
{t("admin.section.backups.loading")}
) : isError ? (
{t("admin.section.backups.loadError")}
) : rows.length === 0 ? ( // v1.4.15 phase-C5: replace bare text with the EmptyState // primitive. The header already exposes "Backup now" but a // brand-new admin lands inside the card and benefits from a // duplicate CTA right next to the explanation.
} title={t("admin.section.backups.emptyTitle")} description={t("admin.section.backups.emptyDescription")} action={ } />
) : (
{/* Wide layout: the canonical table. Hidden below `md`, where the five columns push Download / Restore off-screen behind a horizontal scroll; the card list below takes over there. */}
{rows.map((row, i) => ( ))}
{t("admin.section.backups.colUser")} {t("admin.section.backups.colType")} {t("admin.section.backups.colSize")} {t("admin.section.backups.colCreatedAt")} {t("admin.section.backups.colActions")}
{row.username} {formatBackupType(row.type, t)} {formatBytes(row.sizeBytes, fmt)} {fmt.dateTime(row.createdAt)}
restore.mutate(row)} />
{/* Narrow layout: each backup as a self-contained card with the meta line + a flex-wrapping action row, so Download / Restore stay reachable on a phone instead of scrolling off-screen. */}
    {rows.map((row) => (
  • {row.username} {formatBackupType(row.type, t)}

    {t("admin.section.backups.colSize")}:{" "} {formatBytes(row.sizeBytes, fmt)} ·{" "} {fmt.dateTime(row.createdAt)}

    restore.mutate(row)} />
  • ))}

{t("admin.section.backups.retentionLabel", { days: data!.retentionDays, })}

)}
); }