"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 { 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 { 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; } 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; }; }>("/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); 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); } // 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 }>( `/api/admin/backups/${row.id}/restore`, { confirm: "RESTORE" }, { headers: { "Idempotency-Key": idempotencyKey } }, ); }, onSuccess: () => { 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")}

{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, })}

)}
); }