From 5eafed8dd5b6dc77e645dbe28de60b2c131ee3ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:20:48 +0200 Subject: [PATCH] refactor(admin): split the backups section The section becomes a composer over backups/: the restore dialog with its preview, the refusal, skip and schedule notices, the restore jobs and their request, the upload flow, the run and download actions, and the two row layouts sharing one action pair. The upload total is a pure function and tested as one. The read-failure guard's 250-character window had stopped reaching the restore preview's own failure lines; the preview now sits in a smaller component, the guard sees it, and it is listed as a deliberate exception with its reason. --- .../baselines/source-file-lines.json | 5 +- .../baselines/source-function-lines.json | 6 +- .../baselines/version-stamp-comments.json | 5 +- .../client-poll-inventory-guard.test.ts | 2 +- .../read-failure-markup-guard.test.ts | 30 +- .../step-up-web-caller-guard.test.ts | 2 +- .../success-affordance-guard.test.ts | 5 +- .../backups-restore-preview.test.tsx | 7 +- .../backups-restore-refusal-notice.test.tsx | 2 +- .../backups-restore-skip-report.test.tsx | 2 +- .../__tests__/backups-section-empty.test.tsx | 50 +- src/components/admin/backups-section.tsx | 1349 +---------------- src/components/admin/backups/backup-rows.tsx | 185 +++ .../admin/backups/backup-upload-card.tsx | 65 + .../admin/backups/restore-dialog.tsx | 344 +++++ .../admin/backups/restore-notices.tsx | 353 +++++ .../admin/backups/use-backup-actions.ts | 100 ++ .../admin/backups/use-backup-restores.ts | 213 +++ .../admin/backups/use-backup-upload.ts | 156 ++ 19 files changed, 1533 insertions(+), 1348 deletions(-) create mode 100644 src/components/admin/backups/backup-rows.tsx create mode 100644 src/components/admin/backups/backup-upload-card.tsx create mode 100644 src/components/admin/backups/restore-dialog.tsx create mode 100644 src/components/admin/backups/restore-notices.tsx create mode 100644 src/components/admin/backups/use-backup-actions.ts create mode 100644 src/components/admin/backups/use-backup-restores.ts create mode 100644 src/components/admin/backups/use-backup-upload.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 98162b7e5..000c4445a 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 113, - "total": 130546, + "count": 112, + "total": 129084, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -12,7 +12,6 @@ "app/api/medications/intake/bulk/route.ts": 1044, "app/api/workouts/batch/route.ts": 958, "app/medications/page-client.tsx": 839, - "components/admin/backups-section.tsx": 1462, "components/charts/mood-chart.tsx": 1166, "components/cycle/log-day-sheet.tsx": 1639, "components/insights/coach-panel/chat-bubble.tsx": 888, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 817a4974c..e60a94b4a 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 401, - "total": 137456, + "count": 399, + "total": 136612, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -80,8 +80,6 @@ "components/admin/admin-shell.tsx :: AdminShell": 254, "components/admin/api-token-overview-section.tsx :: ApiTokenOverviewSection": 212, "components/admin/app-log-preview-section.tsx :: AppLogPreviewSection": 341, - "components/admin/backups-section.tsx :: BackupsSection": 618, - "components/admin/backups-section.tsx :: RestoreRowDialog": 226, "components/admin/central-codex-section.tsx :: CentralCodexSection": 241, "components/admin/encryption-section.tsx :: EncryptionSection": 303, "components/admin/general-settings-section.tsx :: GeneralSettingsSection": 220, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 78ad1d038..e4705fc3d 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2946, - "total": 10225, + "count": 2945, + "total": 10221, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -714,7 +714,6 @@ "components/admin/api-token-overview-section.tsx": 5, "components/admin/app-log-preview-section.tsx": 1, "components/admin/assistant-section.tsx": 1, - "components/admin/backups-section.tsx": 4, "components/admin/coach-feedback-section.tsx": 1, "components/admin/danger-zone-section.tsx": 1, "components/admin/general-settings-section.tsx": 1, diff --git a/src/__tests__/client-poll-inventory-guard.test.ts b/src/__tests__/client-poll-inventory-guard.test.ts index aa9df73ba..6fa158b6a 100644 --- a/src/__tests__/client-poll-inventory-guard.test.ts +++ b/src/__tests__/client-poll-inventory-guard.test.ts @@ -84,7 +84,7 @@ const INVENTORY: Readonly> = { shape: "until-done", cadence: "2 s until the job reports a terminal status", }, - "components/admin/backups-section.tsx": { + "components/admin/backups/use-backup-restores.ts": { sites: 1, shape: "until-done", cadence: "2 s until no restore is active", diff --git a/src/__tests__/read-failure-markup-guard.test.ts b/src/__tests__/read-failure-markup-guard.test.ts index b8c46a4c6..1c30d446b 100644 --- a/src/__tests__/read-failure-markup-guard.test.ts +++ b/src/__tests__/read-failure-markup-guard.test.ts @@ -107,6 +107,18 @@ function framedRowsInCards(source: string): number[] { return lines; } +/** + * Read failures that draw their own markup on purpose, each with its reason. + * Keyed by file, and held live by the case below: an entry whose file no + * longer hand-rolls a branch fails rather than lingering as a blind spot. + */ +const HAND_ROLLED_BY_DESIGN = new Map([ + [ + "components/admin/backups/restore-dialog.tsx", + "the restore preview never blocks the restore: a failed preview states its own absence in the dialog, and names the missing key when that is why", + ], +]); + const files = walk(SRC).map((file) => ({ file: relative(SRC, file), source: readFileSync(file, "utf8"), @@ -143,12 +155,24 @@ describe("read failures render through a primitive", () => { it("never hand-rolls one beside a read", () => { const offenders = files.flatMap(({ file, source }) => - readErrorBranches(source) - .filter((b) => b.handRolled) - .map((b) => `${file}:${b.line}`), + HAND_ROLLED_BY_DESIGN.has(file) + ? [] + : readErrorBranches(source) + .filter((b) => b.handRolled) + .map((b) => `${file}:${b.line}`), ); expect(offenders).toEqual([]); }); + + it("keeps every deliberate exception live", () => { + const stale = [...HAND_ROLLED_BY_DESIGN.keys()].filter((name) => { + const entry = files.find(({ file }) => file === name); + return ( + !entry || !readErrorBranches(entry.source).some((b) => b.handRolled) + ); + }); + expect(stale).toEqual([]); + }); }); describe("an error row inside a card draws no frame of its own", () => { diff --git a/src/__tests__/step-up-web-caller-guard.test.ts b/src/__tests__/step-up-web-caller-guard.test.ts index 0e9e3646e..cd07e2ffa 100644 --- a/src/__tests__/step-up-web-caller-guard.test.ts +++ b/src/__tests__/step-up-web-caller-guard.test.ts @@ -319,7 +319,7 @@ function findGatedWebCalls(): GatedCall[] { const PRECHECKED = new Set([ // `upload.mutationFn` asks first, then calls `uploadOnce`, so a large file // is not sent once only to be refused. - "components/admin/backups-section.tsx -> POST /api/admin/backups/upload", + "components/admin/backups/use-backup-upload.ts -> POST /api/admin/backups/upload", ]); describe("fresh-proof gates on the web", () => { diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index b9d53de54..7290edcf5 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -97,7 +97,10 @@ const PINNED_AFFORDANCES: Record< "src/components/admin/assistant-section.tsx": { "toast.success": 1 }, // v1.43 — the AI usage bounds: a settings save, not a write with counts. "src/components/admin/ai-usage-section.tsx": { "toast.success": 1 }, - "src/components/admin/backups-section.tsx": { "toast.success": 4 }, + // The run, the download, the upload and the finished restore, one toast each. + "src/components/admin/backups/use-backup-actions.ts": { "toast.success": 2 }, + "src/components/admin/backups/use-backup-restores.ts": { "toast.success": 1 }, + "src/components/admin/backups/use-backup-upload.ts": { "toast.success": 1 }, "src/components/admin/central-codex-section.tsx": { "toast.success": 2 }, "src/components/admin/danger-zone-section.tsx": { "text-success": 1 }, "src/components/admin/encryption-section.tsx": { diff --git a/src/components/admin/__tests__/backups-restore-preview.test.tsx b/src/components/admin/__tests__/backups-restore-preview.test.tsx index 892b55f2b..4107fe523 100644 --- a/src/components/admin/__tests__/backups-restore-preview.test.tsx +++ b/src/components/admin/__tests__/backups-restore-preview.test.tsx @@ -84,13 +84,16 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { BackupsSection, restorePreviewFailure } from "../backups-section"; +import { + RestorePreview, + restorePreviewFailure, +} from "../backups/restore-dialog"; describe("restore preview query", () => { it("is never retried and waits a bounded time", async () => { renderToStaticMarkup( - + , ); const key = JSON.stringify(queryKeys.adminBackupSummary("b1")); diff --git a/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx b/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx index f85d99c02..1fc6089c4 100644 --- a/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx +++ b/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx @@ -29,7 +29,7 @@ import { I18nProvider } from "@/lib/i18n/context"; import { RestoreRefusalNotice, missingSectionsOf, -} from "@/components/admin/backups-section"; +} from "@/components/admin/backups/restore-notices"; import type { MissingBackupSection } from "@/lib/export/restore-skips"; import { primeLocales } from "@/__tests__/helpers/prime-locales"; diff --git a/src/components/admin/__tests__/backups-restore-skip-report.test.tsx b/src/components/admin/__tests__/backups-restore-skip-report.test.tsx index e01f9353c..c5066e790 100644 --- a/src/components/admin/__tests__/backups-restore-skip-report.test.tsx +++ b/src/components/admin/__tests__/backups-restore-skip-report.test.tsx @@ -22,7 +22,7 @@ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; -import { RestoreSkipReport } from "@/components/admin/backups-section"; +import { RestoreSkipReport } from "@/components/admin/backups/restore-notices"; import type { RestoreSkipSummary } from "@/lib/export/restore-skips"; import { primeLocales } from "@/__tests__/helpers/prime-locales"; diff --git a/src/components/admin/__tests__/backups-section-empty.test.tsx b/src/components/admin/__tests__/backups-section-empty.test.tsx index 9c57e7eb3..b1d44b41d 100644 --- a/src/components/admin/__tests__/backups-section-empty.test.tsx +++ b/src/components/admin/__tests__/backups-section-empty.test.tsx @@ -17,10 +17,6 @@ const healthySchedule = { }; const mocks = vi.hoisted(() => ({ - mutationOptions: [] as Array<{ - onSuccess?: (data: { summary: Record }) => void; - }>, - toastSuccess: vi.fn(), // Mutable so one file can render both a healthy schedule and a broken one; // the query mock reads it on every render. queryData: { rows: [], retentionDays: 30 } as Record, @@ -43,7 +39,7 @@ vi.mock("next/navigation", () => ({ vi.mock("sonner", () => ({ toast: { - success: mocks.toastSuccess, + success: vi.fn(), error: vi.fn(), }, })); @@ -55,21 +51,14 @@ vi.mock("@tanstack/react-query", () => ({ isError: false, }), useQueryClient: () => ({ invalidateQueries: vi.fn() }), - useMutation: (options: unknown) => { - mocks.mutationOptions.push( - options as { - onSuccess?: (data: { summary: Record }) => void; - }, - ); - return { - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - isError: false, - error: null, - variables: undefined, - }; - }, + useMutation: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isError: false, + error: null, + variables: undefined, + }), })); vi.mock("@/hooks/use-auth", () => ({ @@ -83,6 +72,7 @@ vi.mock("@/hooks/use-auth", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { BackupsSection } from "../backups-section"; +import { uploadedRecordCount } from "../backups/use-backup-upload"; import { primeLocales } from "@/__tests__/helpers/prime-locales"; primeLocales("de"); @@ -224,26 +214,16 @@ describe("BackupsSection — schedule health", () => { }); }); -describe("BackupsSection — upload outcome", () => { +describe("backup upload outcome", () => { it("includes health profile fact revisions in the restored record total", () => { - mocks.toastSuccess.mockClear(); - const firstMutation = mocks.mutationOptions.length; - render(); - const uploadOutcome = mocks.mutationOptions[firstMutation + 1]?.onSuccess; - expect(uploadOutcome).toBeTypeOf("function"); - - uploadOutcome!({ - summary: { + expect( + uploadedRecordCount({ measurements: 1, medications: 2, intakeEvents: 3, moodEntries: 4, healthProfileFactRevisions: 5, - }, - }); - - expect(mocks.toastSuccess).toHaveBeenCalledWith( - expect.stringContaining("15"), - ); + }), + ).toBe(15); }); }); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 059bb8242..d8dcb62c2 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -7,845 +7,39 @@ * 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 { showErrorToast } from "@/lib/errors/error-toast"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; +import { useQuery } from "@tanstack/react-query"; +import { BookOpen, Database, Loader2, PlayCircle } from "lucide-react"; + import { BackupPassActivityNotice } from "@/components/admin/backup-pass-activity"; -import { Badge } from "@/components/ui/badge"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -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 { formatBytes } from "@/lib/format/bytes"; +import { useRecentProof } from "@/components/settings/security-section/use-recent-proof"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { randomId } from "@/lib/random-id"; import type { BackupRow, BackupsList } from "@/types/backups"; -import type { BackupScheduleStatus } from "@/lib/jobs/backup-schedule-status"; -import type { - MissingBackupSection, - RestoreSkipSummary, - SkippedCatalogue, -} from "@/lib/export/restore-skips"; -import type { BackupRestoreJobView } from "@/lib/jobs/backup-restore"; -import { getApiErrorMessage } from "./_shared"; + +import { RestoreJobStatus, visibleRestoreJobs } from "./backup-restore-status"; +import { BackupRowsCards, BackupRowsTable } from "./backups/backup-rows"; +import { BackupUploadCard } from "./backups/backup-upload-card"; import { - ReproofCancelledError, - throwIfReproofRequired, - useRecentProof, -} from "@/components/settings/security-section/use-recent-proof"; -import { - isActiveRestore, - RestoreJobStatus, - visibleRestoreJobs, -} from "./backup-restore-status"; -import { - ApiError, - 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). - */ -/** - * How long the restore dialog waits for the contents of a copy. A copy's - * counts are kept with it and come back at once; this only runs out on a copy - * from an earlier version that the server is still reading whole, and stays - * under the minute a reverse proxy commonly allows a request. - */ -const PREVIEW_TIMEOUT_MS = 55_000; - -/** - * What the restore dialog says when the contents did not come back: the key - * the copy needs and this server lacks, a copy from an earlier version still - * being read when the dialog stopped waiting, or anything else. - */ -export function restorePreviewFailure( - error: unknown, -): "keyMissing" | "stillReading" | "unavailable" { - if ( - error instanceof ApiError && - error.meta?.errorCode === "backup.key.missing" - ) { - return "keyMissing"; - } - // `AbortSignal.timeout` rejects the fetch with a DOMException of this name. - if ( - typeof error === "object" && - error !== null && - (error as { name?: unknown }).name === "TimeoutError" - ) { - return "stillReading"; - } - return "unavailable"; -} - -/** - * v1.37.20 — the sections a restore preview names, in render order. Each - * entry sums one or more `BackupSummary` counters under one human label; - * every counter NOT named here still reaches the operator through the - * trailing "other records" line, so a future summary key can be forgotten - * here without silently vanishing from the preview. - */ -const RESTORE_PREVIEW_SECTIONS: ReadonlyArray<{ - labelKey: string; - keys: readonly string[]; -}> = [ - { labelKey: "previewMeasurements", keys: ["measurements"] }, - { - labelKey: "previewMedications", - keys: ["medications", "intakeEvents", "medicationSideEffects"], - }, - { labelKey: "previewMood", keys: ["moodEntries"] }, - { labelKey: "previewLabs", keys: ["labResults", "biomarkers"] }, - { labelKey: "previewDocuments", keys: ["documents"] }, - { labelKey: "previewWorkouts", keys: ["workouts"] }, - { labelKey: "previewCycles", keys: ["cycles", "cycleDayLogs"] }, - { - labelKey: "previewVisits", - keys: ["encounters", "practitioners", "encounterLinks"], - }, - { - labelKey: "previewVaccinations", - keys: ["vaccinations", "vaccinationLinks", "customVaccines"], - }, - { - labelKey: "previewReminders", - keys: ["measurementReminders", "measurementReminderEvents"], - }, - { - labelKey: "previewCustomMetrics", - keys: ["customMetrics", "customMetricEntries"], - }, - { labelKey: "previewHealthScores", keys: ["healthScoreRecords"] }, -]; - -function RestoreRowDialog({ - row, - pending, - onConfirm, -}: { - row: BackupRow; - pending: boolean; - onConfirm: (options: { restoreInstanceSettings: boolean }) => void; -}) { - const { t } = useTranslations(); - const fmt = useFormatters(); - const [open, setOpen] = useState(false); - const [typed, setTyped] = useState(""); - const matched = typed.trim() === "RESTORE"; - // Off unless the operator says otherwise. A disaster-recovery snapshot - // carries the host's own settings alongside the account's data, and putting - // one account back is not a reason to reconfigure the installation for - // everybody on it — so the second effect is a second decision. - const [withInstanceSettings, setWithInstanceSettings] = useState(false); - - // v1.37.20 — restore preview: fetch what the file contains the moment the - // dialog opens, so the typed confirmation is an informed one. A failed - // preview never blocks the restore — it states its own absence instead. - // - // Never retried. The server answers from counts kept with the copy, at - // once; only a copy from an earlier version is read whole, and that read - // goes on (and is kept) after this request gives up. A retry would only - // start the same read a second time (#1031). - const preview = useQuery({ - queryKey: queryKeys.adminBackupSummary(row.id), - queryFn: () => - apiGet<{ summary: Record }>( - `/api/admin/backups/${row.id}/summary`, - { signal: AbortSignal.timeout(PREVIEW_TIMEOUT_MS) }, - ), - enabled: open, - staleTime: 60_000, - retry: false, - }); - const previewFailure = restorePreviewFailure(preview.error); - const numericSummary: Record = {}; - if (preview.data?.summary) { - for (const [key, value] of Object.entries(preview.data.summary)) { - if (typeof value === "number") numericSummary[key] = value; - } - } - const namedKeys = new Set( - RESTORE_PREVIEW_SECTIONS.flatMap((section) => section.keys), - ); - const previewRows = RESTORE_PREVIEW_SECTIONS.map((section) => ({ - labelKey: section.labelKey, - count: section.keys.reduce( - (sum, key) => sum + (numericSummary[key] ?? 0), - 0, - ), - })).filter((entry) => entry.count > 0); - const otherCount = Object.entries(numericSummary) - .filter(([key]) => !namedKeys.has(key)) - .reduce((sum, [, value]) => sum + value, 0); - - return ( - { - setOpen(next); - if (!next) { - setTyped(""); - setWithInstanceSettings(false); - } - }} - > - - - - - - - {t("admin.section.backups.restoreTitle")} - - - {t("admin.section.backups.restoreDescription", { - username: row.username, - when: fmt.dateTime(row.createdAt), - })} - - - {/* v1.37.20 — what the file contains, before the typed gate. */} -
-

- {t("admin.section.backups.previewTitle")} -

- {preview.isLoading && ( -

- {t("admin.section.backups.previewLoading")} -

- )} - {preview.isError && - (previewFailure === "keyMissing" && - preview.error instanceof ApiError ? ( - // The one preview failure the restore would repeat: say which - // key, so the operator can put it back before trying. -

- {t("admin.section.backups.previewKeyMissing", { - keys: Array.isArray(preview.error.meta?.keyIds) - ? preview.error.meta.keyIds.map(String).join(", ") - : "", - })} -

- ) : previewFailure === "stillReading" ? ( -

- {t("admin.section.backups.previewStillReading")} -

- ) : ( -

- {t("admin.section.backups.previewUnavailable")} -

- ))} - {preview.isSuccess && ( -
    - {previewRows.map((entry) => ( -
  • - - {t(`admin.section.backups.${entry.labelKey}`)} - - - {fmt.integer(entry.count)} - -
  • - ))} - {otherCount > 0 && ( -
  • - - {t("admin.section.backups.previewOther")} - - - {fmt.integer(otherCount)} - -
  • - )} - {previewRows.length === 0 && otherCount === 0 && ( -
  • - {t("admin.section.backups.previewEmpty")} -
  • - )} -
- )} -
- {/* The second decision, before the typed gate rather than after it: - the account's data comes back either way, the host's settings only - if this is ticked. */} - -
- - setTyped(e.target.value)} - autoComplete="off" - spellCheck={false} - placeholder="RESTORE" - /> -
- - {t("common.cancel")} - { - if (!matched) return; - setOpen(false); - setTyped(""); - onConfirm({ restoreInstanceSettings: withInstanceSettings }); - setWithInstanceSettings(false); - }} - > - {pending - ? t("admin.section.backups.restoreInProgress") - : t("admin.section.backups.restoreConfirm")} - - -
-
- ); -} - -/** - * 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"); - } - if (catalogue === "moodTag") { - return t("admin.section.backups.restoreSkippedMoodTag"); - } - if (catalogue === "visitReference") { - return t("admin.section.backups.restoreSkippedVisitReference"); - } - if (catalogue === "vaccinationReference") { - return t("admin.section.backups.restoreSkippedVaccinationReference"); - } - if (catalogue === "checkupClosure") { - return t("admin.section.backups.restoreSkippedCheckupClosure"); - } - if (catalogue === "reminderReference") { - return t("admin.section.backups.restoreSkippedReminderReference"); - } - if (catalogue === "coachAttachment") { - return t("admin.section.backups.restoreSkippedCoachAttachment"); - } - if (catalogue === "coachReference") { - return t("admin.section.backups.restoreSkippedCoachReference"); - } - if (catalogue === "documentConditionLink") { - return t("admin.section.backups.restoreSkippedDocumentConditionLink"); - } - if (catalogue === "relationLink") { - return t("admin.section.backups.restoreSkippedRelationLink"); - } - if (catalogue === "extractedFact") { - return t("admin.section.backups.restoreSkippedExtractedFact"); - } - if (catalogue === "factCommitment") { - return t("admin.section.backups.restoreSkippedFactCommitment"); - } - if (catalogue === "moodFactor") { - return t("admin.section.backups.restoreSkippedMoodFactor"); - } - if (catalogue === "personalRecordReference") { - return t("admin.section.backups.restoreSkippedPersonalRecordReference"); - } - if (catalogue === "ecgReference") { - return t("admin.section.backups.restoreSkippedEcgReference"); - } - if (catalogue === "symptomEpisodeReference") { - return t("admin.section.backups.restoreSkippedSymptomEpisodeReference"); - } - if (catalogue === "visitCiphertext") { - return t("admin.section.backups.restoreSkippedVisitCiphertext"); - } - if (catalogue === "vaccinationCiphertext") { - return t("admin.section.backups.restoreSkippedVaccinationCiphertext"); - } - if (catalogue === "environmentLocationCiphertext") { - return t( - "admin.section.backups.restoreSkippedEnvironmentLocationCiphertext", - ); - } - if (catalogue === "moodLabelCiphertext") { - return t("admin.section.backups.restoreSkippedMoodLabelCiphertext"); - } - if (catalogue === "cycleCiphertext") { - return t("admin.section.backups.restoreSkippedCycleCiphertext"); - } - if (catalogue === "medicationTarget") { - return t("admin.section.backups.restoreSkippedMedicationTarget"); - } - if (catalogue === "scheduleRevisionLink") { - return t("admin.section.backups.restoreSkippedScheduleRevisionLink"); - } - if (catalogue === "accountSetting") { - return t("admin.section.backups.restoreSkippedAccountSetting"); - } - // Not a fallback: the chain above is exhaustive and this line is what makes - // the compiler say so. A catalogue added without a label here now stops the - // build instead of shipping under a label that belongs to something else, - // which is what three of them were already doing — a dropped Coach - // attachment reached the operator's screen labelled "mood factor". - const unlabelled: never = catalogue; - return unlabelled; -} - -/** - * Which section a refused file claimed and did not carry, as a label. - * - * Spelled out with literal `t()` calls for the same reason as - * `catalogueLabel` above — an interpolated key is invisible to - * `i18n-call-site-coverage.test.ts`, and a renamed section would ship as raw - * dot notation. The `never` at the foot is what makes the compiler say the - * chain is exhaustive. - */ -function missingSectionLabel( - section: MissingBackupSection, - t: ReturnType["t"], -) { - if (section === "documents") { - return t("admin.section.backups.restoreMissingDocuments"); - } - if (section === "workouts") { - return t("admin.section.backups.restoreMissingWorkouts"); - } - if (section === "mentalHealth") { - return t("admin.section.backups.restoreMissingMentalHealth"); - } - if (section === "consent") { - return t("admin.section.backups.restoreMissingConsent"); - } - const unlabelled: never = section; - return unlabelled; -} - -/** - * Why a file was refused, and what to do about it. - * - * Held on screen rather than left to the toast, exactly like the skip report - * below it. The operator refused here is often holding the only copy of an - * account; "restore failed" for four seconds is not enough to act on, and the - * two facts that let them act — which section the file claimed and did not - * carry, and that nothing was changed — have to survive long enough to be read. - */ -/** - * The refused sections a failed restore job names, or none. - * - * Keyed on the failure's `code` and not on its message: the message is prose - * the server may reword, and matching on it would turn a copy edit into a - * silent loss of the panel. - */ - -const DISMISSED_RESTORES_KEY = "healthlog.admin.backups.dismissedRestores"; - -/** Dismissed restore panels, from this browser. Empty when storage is off. */ -function readDismissedRestores(): string[] { - try { - if (typeof window === "undefined") return []; - const raw = window.localStorage.getItem(DISMISSED_RESTORES_KEY); - const parsed: unknown = raw ? JSON.parse(raw) : []; - return Array.isArray(parsed) - ? parsed.filter((id): id is string => typeof id === "string") - : []; - } catch { - return []; - } -} - -function writeDismissedRestores(ids: string[]): void { - try { - // The list only ever shows a day of jobs; fifty ids is weeks of them. - window.localStorage.setItem( - DISMISSED_RESTORES_KEY, - JSON.stringify(ids.slice(-50)), - ); - } catch { - // A private window: the panel comes back on reload, nothing else breaks. - } -} - -/** - * The upload body for a backup file: the file itself when it is already - * gzip, otherwise the file compressed in the browser. The server accepts - * both; compressing keeps a large export well inside the request size limit. - */ -async function uploadBody(file: File): Promise { - const head = new Uint8Array(await file.slice(0, 2).arrayBuffer()); - if (head[0] === 0x1f && head[1] === 0x8b) return file; - return new Response( - file.stream().pipeThrough(new CompressionStream("gzip")), - ).blob(); -} - -export function missingSectionsOf( - failure: { code: string; sections?: readonly unknown[] } | null | undefined, -): MissingBackupSection[] { - if (failure?.code !== "backup.section.missing") return []; - const sections = failure.sections; - if (!Array.isArray(sections)) return []; - return sections.filter( - (section): section is MissingBackupSection => typeof section === "string", - ); -} - -export function RestoreRefusalNotice({ - sections, - onDismiss, -}: { - sections: MissingBackupSection[]; - onDismiss: () => void; -}) { - const { t } = useTranslations(); - if (sections.length === 0) return null; - return ( -
-
-
-
- -
-
- ); -} - -/** - * 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 ( -
-
-
-
- -
-
- ); -} - -/** - * Says out loud whether the SCHEDULE is still alive. - * - * The weekly pass on the maintainer's own instance stopped producing copies - * and nothing surfaced it for a month and a half: the table listed the rows it - * had, each with an ordinary timestamp, and a copy from six weeks ago reads - * exactly like one made on Sunday until somebody does the subtraction. Two - * independent facts are shown, because they fail at different times — the age - * of the newest scheduled copy never ages out, while the run's own outcome - * carries the reason ("job timed out") and survives only as long as pg-boss - * keeps the row. - * - * Not dismissible: it reflects live state, so the only way to clear it is to - * make a backup succeed. - */ -function ScheduleHealthNotice({ - schedule, -}: { - schedule: BackupScheduleStatus; -}) { - const { t } = useTranslations(); - const fmt = useFormatters(); - if (!schedule.stale && !schedule.lastRunFailed) return null; - - return ( -
-
-
-
- ); -} - -/** - * Send a backup file to the admin upload route. Sent as the raw body, - * compressed on the way unless it already is: the server reads it as a stream, - * and a large export (hundreds of MB of JSON) shrinks about tenfold. No request - * timeout, because a file of that size takes longer to check and store than - * the default allows. The body is built here, per call, so a retry after a - * re-proof sends the file again rather than a spent stream. - */ -async function uploadOnce(file: File) { - const body = await uploadBody(file); - return apiFetch<{ - id: string; - valid: true; - summary: { - measurements: number; - medications: number; - intakeEvents: number; - moodEntries: number; - // Optional for the same reason as the keys below: a file written - // before side effects rode the wire carries no key at all. - medicationSideEffects?: 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, - headers: { "Content-Type": "application/gzip" }, - signal: null, - }); -} + RestoreRefusalNotice, + RestoreSkipReport, + ScheduleHealthNotice, +} from "./backups/restore-notices"; +import { useBackupDownload, useRunBackup } from "./backups/use-backup-actions"; +import { useBackupRestores } from "./backups/use-backup-restores"; +import { useBackupUpload } from "./backups/use-backup-upload"; export function BackupsSection() { - const { t, locale } = useTranslations(); - const fmt = useFormatters(); - const queryClient = useQueryClient(); + const { t } = useTranslations(); const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.adminBackups(), @@ -853,286 +47,40 @@ export function BackupsSection() { 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) => { - showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.section.backups.runFailed"), - { cause: err }, - ); - }, - }); - + const runBackup = useRunBackup(); 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); // Download, upload and restore reach every account's data, so each asks for // a fresh proof unless this session signed in or re-proved within minutes. const recentProof = useRecentProof(); - const upload = useMutation({ - mutationFn: async (file: File) => { - // Ask first, so a large file is not sent once only to be refused. - await recentProof.run(() => apiGet("/api/auth/reproof")); - return uploadOnce(file); - }, - onSuccess: (data) => { - const total = - data.summary.measurements + - data.summary.medications + - data.summary.intakeEvents + - data.summary.moodEntries + - (data.summary.medicationSideEffects ?? 0) + - (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) => { - if (err instanceof ReproofCancelledError) return; - showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.section.backups.uploadFailed"), - { cause: err }, - ); - }, - 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); - - // Why the last restore was refused before it touched anything. Same reason - // the skip report is held rather than toasted: the operator has to be able to - // read it after the four seconds are up. - const [refusedSections, setRefusedSections] = useState< - MissingBackupSection[] - >([]); - - // A job this page watched while it ran, and has now seen finish. Only those - // raise a toast and refresh the page's data: a job that had already finished - // when the page opened is shown in its panel and nothing more. Read in the - // poll's own callback, where each answer arrives, rather than in an effect. - const watched = useRef>(new Map()); - function noticeFinishedRestores(jobs: readonly BackupRestoreJobView[]) { - for (const job of jobs) { - const before = watched.current.get(job.id); - watched.current.set(job.id, job.status); - if (before !== "queued" && before !== "running") continue; - if (job.status === "succeeded") { - setRefusedSections([]); - const report = job.result?.skipped; - if (report && report.links > 0) { - setSkipped(report as RestoreSkipSummary); - toast.warning( - t("admin.section.backups.restoreSkippedToast", { - links: String(report.links), - }), - ); - } else { - setSkipped(null); - toast.success(t("admin.section.backups.restoreSuccess")); - } - // Restore touches every personal-data table; nuke the broader cache - // so dashboards / lists rebuild against the new state. - void queryClient.invalidateQueries(); - } else if (job.status === "failed") { - setRefusedSections(missingSectionsOf(job.failure)); - showErrorToast(t("admin.section.backups.restoreFailed")); - } - } - } - - // The restore jobs, read from the server so a reload during a restore finds - // it again. Polled every two seconds while any is queued or running, and - // not at all otherwise. A poll that fails keeps the last answer on screen: - // a network blip is not a failed restore. - const restores = useQuery({ - queryKey: queryKeys.adminBackupRestores(), - queryFn: async () => { - const data = await apiGet<{ jobs: BackupRestoreJobView[] }>( - "/api/admin/backups/restores", - ); - noticeFinishedRestores(data.jobs); - return data; - }, - refetchInterval: (query) => - query.state.data?.jobs.some(isActiveRestore) ? 2000 : false, - }); - const restoreJobs = restores.data?.jobs ?? []; - const activeOwners = new Set( - restoreJobs.filter(isActiveRestore).map((job) => job.userId), - ); - - // Finished restores the operator dismissed. Remembered in this browser only, - // for convenience; the list itself forgets a job after a day. - const [dismissedRestores, setDismissedRestores] = useState>( - () => new Set(readDismissedRestores()), - ); - function dismissRestore(id: string) { - setDismissedRestores((previous) => { - const next = new Set(previous); - next.add(id); - writeDismissedRestores([...next]); - return next; - }); - } - - // Restore: typed-confirmation dialog. The request only queues the restore; - // the panel above the list follows it from there. - const restore = useMutation({ - mutationFn: async ({ - row, - restoreInstanceSettings, - }: { - row: BackupRow; - restoreInstanceSettings: boolean; - }) => { - // Idempotency-Key prevents a double-click from queuing a second - // restore. Include the row id so two different backups can both be - // restored independently in the same minute. - const idempotencyKey = `restore-${row.id}-${randomId()}`; - return recentProof.run(() => - apiPost<{ jobId: string; status: "queued" }>( - `/api/admin/backups/${row.id}/restore`, - { confirm: "RESTORE", restoreInstanceSettings }, - { headers: { "Idempotency-Key": idempotencyKey } }, - ), - ); - }, - onSuccess: (data) => { - setRefusedSections([]); - setSkipped(null); - watched.current.set(data.jobId, "queued"); - // Neutral, not a success: nothing has been restored yet. - toast.info(t("admin.section.backups.restoreStarted")); - void queryClient.invalidateQueries({ - queryKey: queryKeys.adminBackupRestores(), - }); - }, - onError: (err) => { - if (err instanceof ReproofCancelledError) return; - if ( - err instanceof ApiError && - err.meta?.errorCode === "backup.restore.active" - ) { - showErrorToast(t("admin.section.backups.restoreActive"), { - cause: err, - }); - void queryClient.invalidateQueries({ - queryKey: queryKeys.adminBackupRestores(), - }); - return; - } - setRefusedSections([]); - showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.section.backups.restoreFailed"), - { cause: err }, - ); - }, - }); - - 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 recentProof.run(async () => - throwIfReproofRequired( - 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) { - if (err instanceof ReproofCancelledError) return; - showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.section.backups.downloadFailed"), - { cause: err }, - ); - } finally { - setDownloadingId(null); - } - } + const { downloadingId, handleDownload } = useBackupDownload(recentProof); + const { upload, fileInputRef, handleFileChange } = + useBackupUpload(recentProof); + const { + restoreJobs, + dismissedRestores, + dismissRestore, + restore, + restorePending, + skipped, + setSkipped, + refusedSections, + setRefusedSections, + } = useBackupRestores(recentProof); + const rowActions = { + downloadingId, + onDownload: (row: BackupRow) => void handleDownload(row), + restorePending, + onRestore: ( + row: BackupRow, + options: { restoreInstanceSettings: boolean }, + ) => restore.mutate({ row, ...options }), + }; return ( {recentProof.dialog} - {/* v1.18.1 — the snapshot count leads (numbers first) and the + {/* The snapshot count leads (numbers first) and the "Run now" button follows. */} ) : null} - {/* 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")} - -

-
-
- - -
-
+ {visibleRestoreJobs(restoreJobs, dismissedRestores).map((job) => ( void refetch()} /> ) : rows.length === 0 ? ( - // v1.4.15: replace bare text with the EmptyState + // 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. @@ -1283,155 +192,9 @@ export function BackupsSection() { ) : (
- {/* 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. */} -
- - - - - - - - - - - {/* `backup-rows` names the read rows. The three branches above - are a spinner, a load error and an empty state, so the - marker cannot stand for a page that has not answered yet. - The narrow-layout list below carries it too, so a wait on - it holds at any viewport. */} - - {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, locale)} - - {fmt.dateTime(row.createdAt)} - -
- - - restore.mutate({ row, ...options }) - } - /> -
-
-
+ - {/* 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, locale)} ·{" "} - {fmt.dateTime(row.createdAt)} -

    -
    - - - restore.mutate({ row, ...options }) - } - /> -
    -
  • -
    - ))} -
+

{t("admin.section.backups.retentionLabel", { diff --git a/src/components/admin/backups/backup-rows.tsx b/src/components/admin/backups/backup-rows.tsx new file mode 100644 index 000000000..316148eb8 --- /dev/null +++ b/src/components/admin/backups/backup-rows.tsx @@ -0,0 +1,185 @@ +import { Download, Loader2 } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ListRow } from "@/components/ui/list-row"; +import { formatBytes } from "@/lib/format/bytes"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { BackupRow } from "@/types/backups"; + +import { RestoreRowDialog } from "./restore-dialog"; + +/** What a row's Download and Restore controls act through. */ +export type BackupRowActionProps = { + downloadingId: string | null; + onDownload: (row: BackupRow) => void; + restorePending: (row: BackupRow) => boolean; + onRestore: ( + row: BackupRow, + options: { restoreInstanceSettings: boolean }, + ) => void; +}; + +/** + * 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; +} + +/** Download and Restore for one copy; the same pair in both layouts. */ +function BackupRowActions({ + row, + downloadingId, + onDownload, + restorePending, + onRestore, +}: BackupRowActionProps & { row: BackupRow }) { + const { t } = useTranslations(); + return ( + <> + + onRestore(row, options)} + /> + + ); +} + +/** + * Wide layout: the canonical table. Hidden below `md`, where the five + * columns push Download / Restore off-screen behind a horizontal scroll; the + * card list takes over there. + */ +export function BackupRowsTable({ + rows, + ...actions +}: BackupRowActionProps & { rows: BackupRow[] }) { + const { t, locale } = useTranslations(); + const fmt = useFormatters(); + return ( +

+ + + + + + + + + + + {/* `backup-rows` names the read rows. The three branches above + are a spinner, a load error and an empty state, so the + marker cannot stand for a page that has not answered yet. + The narrow-layout list below carries it too, so a wait on + it holds at any viewport. */} + + {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, locale)} + + {fmt.dateTime(row.createdAt)} + +
+ +
+
+
+ ); +} + +/** + * Narrow layout: each backup as a self-contained card with the meta line and + * a flex-wrapping action row, so Download / Restore stay reachable on a phone + * instead of scrolling off-screen. + */ +export function BackupRowsCards({ + rows, + ...actions +}: BackupRowActionProps & { rows: BackupRow[] }) { + const { t, locale } = useTranslations(); + const fmt = useFormatters(); + return ( +
    + {rows.map((row) => ( + +
  • +
    + {row.username} + + {formatBackupType(row.type, t)} + +
    +

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

    +
    + +
    +
  • +
    + ))} +
+ ); +} diff --git a/src/components/admin/backups/backup-upload-card.tsx b/src/components/admin/backups/backup-upload-card.tsx new file mode 100644 index 000000000..e4d386520 --- /dev/null +++ b/src/components/admin/backups/backup-upload-card.tsx @@ -0,0 +1,65 @@ +import { Loader2, Upload } from "lucide-react"; +import type { ChangeEvent, RefObject } from "react"; + +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * The 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 and screen-reader users + * keep a labelled control. + */ +export function BackupUploadCard({ + pending, + fileInputRef, + onFileChange, +}: { + pending: boolean; + fileInputRef: RefObject; + onFileChange: (event: ChangeEvent) => void; +}) { + const { t } = useTranslations(); + return ( +
+
+
+ {t("admin.section.backups.uploadTitle")} +
+

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

+
+
+ + +
+
+ ); +} diff --git a/src/components/admin/backups/restore-dialog.tsx b/src/components/admin/backups/restore-dialog.tsx new file mode 100644 index 000000000..73a091cbb --- /dev/null +++ b/src/components/admin/backups/restore-dialog.tsx @@ -0,0 +1,344 @@ +import { useQuery } from "@tanstack/react-query"; +import { History, Loader2 } from "lucide-react"; +import { useState } from "react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ApiError, apiGet } from "@/lib/api/api-fetch"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { BackupRow } from "@/types/backups"; + +/** + * How long the restore dialog waits for the contents of a copy. A copy's + * counts are kept with it and come back at once; this only runs out on a copy + * from an earlier version that the server is still reading whole, and stays + * under the minute a reverse proxy commonly allows a request. + */ +const PREVIEW_TIMEOUT_MS = 55_000; + +/** + * What the restore dialog says when the contents did not come back: the key + * the copy needs and this server lacks, a copy from an earlier version still + * being read when the dialog stopped waiting, or anything else. + */ +export function restorePreviewFailure( + error: unknown, +): "keyMissing" | "stillReading" | "unavailable" { + if ( + error instanceof ApiError && + error.meta?.errorCode === "backup.key.missing" + ) { + return "keyMissing"; + } + // `AbortSignal.timeout` rejects the fetch with a DOMException of this name. + if ( + typeof error === "object" && + error !== null && + (error as { name?: unknown }).name === "TimeoutError" + ) { + return "stillReading"; + } + return "unavailable"; +} + +/** + * The sections a restore preview names, in render order. Each + * entry sums one or more `BackupSummary` counters under one human label; + * every counter NOT named here still reaches the operator through the + * trailing "other records" line, so a future summary key can be forgotten + * here without silently vanishing from the preview. + */ +const RESTORE_PREVIEW_SECTIONS: ReadonlyArray<{ + labelKey: string; + keys: readonly string[]; +}> = [ + { labelKey: "previewMeasurements", keys: ["measurements"] }, + { + labelKey: "previewMedications", + keys: ["medications", "intakeEvents", "medicationSideEffects"], + }, + { labelKey: "previewMood", keys: ["moodEntries"] }, + { labelKey: "previewLabs", keys: ["labResults", "biomarkers"] }, + { labelKey: "previewDocuments", keys: ["documents"] }, + { labelKey: "previewWorkouts", keys: ["workouts"] }, + { labelKey: "previewCycles", keys: ["cycles", "cycleDayLogs"] }, + { + labelKey: "previewVisits", + keys: ["encounters", "practitioners", "encounterLinks"], + }, + { + labelKey: "previewVaccinations", + keys: ["vaccinations", "vaccinationLinks", "customVaccines"], + }, + { + labelKey: "previewReminders", + keys: ["measurementReminders", "measurementReminderEvents"], + }, + { + labelKey: "previewCustomMetrics", + keys: ["customMetrics", "customMetricEntries"], + }, + { labelKey: "previewHealthScores", keys: ["healthScoreRecords"] }, +]; + +/** + * What a copy contains, fetched the moment the restore dialog opens, so the + * typed confirmation is an informed one. A failed preview never blocks the + * restore; it states its own absence instead. + */ +export function RestorePreview({ + backupId, + open, +}: { + backupId: string; + open: boolean; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + // Never retried. The server answers from counts kept with the copy, at + // once; only a copy from an earlier version is read whole, and that read + // goes on (and is kept) after this request gives up. A retry would only + // start the same read a second time (#1031). + const preview = useQuery({ + queryKey: queryKeys.adminBackupSummary(backupId), + queryFn: () => + apiGet<{ summary: Record }>( + `/api/admin/backups/${backupId}/summary`, + { signal: AbortSignal.timeout(PREVIEW_TIMEOUT_MS) }, + ), + enabled: open, + staleTime: 60_000, + retry: false, + }); + const previewFailure = restorePreviewFailure(preview.error); + const numericSummary: Record = {}; + if (preview.data?.summary) { + for (const [key, value] of Object.entries(preview.data.summary)) { + if (typeof value === "number") numericSummary[key] = value; + } + } + const namedKeys = new Set( + RESTORE_PREVIEW_SECTIONS.flatMap((section) => section.keys), + ); + const previewRows = RESTORE_PREVIEW_SECTIONS.map((section) => ({ + labelKey: section.labelKey, + count: section.keys.reduce( + (sum, key) => sum + (numericSummary[key] ?? 0), + 0, + ), + })).filter((entry) => entry.count > 0); + const otherCount = Object.entries(numericSummary) + .filter(([key]) => !namedKeys.has(key)) + .reduce((sum, [, value]) => sum + value, 0); + + return ( +
+

+ {t("admin.section.backups.previewTitle")} +

+ {preview.isLoading && ( +

+ {t("admin.section.backups.previewLoading")} +

+ )} + {preview.isError && + (previewFailure === "keyMissing" && + preview.error instanceof ApiError ? ( + // The one preview failure the restore would repeat: say which + // key, so the operator can put it back before trying. +

+ {t("admin.section.backups.previewKeyMissing", { + keys: Array.isArray(preview.error.meta?.keyIds) + ? preview.error.meta.keyIds.map(String).join(", ") + : "", + })} +

+ ) : previewFailure === "stillReading" ? ( +

+ {t("admin.section.backups.previewStillReading")} +

+ ) : ( +

+ {t("admin.section.backups.previewUnavailable")} +

+ ))} + {preview.isSuccess && ( +
    + {previewRows.map((entry) => ( +
  • + + {t(`admin.section.backups.${entry.labelKey}`)} + + {fmt.integer(entry.count)} +
  • + ))} + {otherCount > 0 && ( +
  • + + {t("admin.section.backups.previewOther")} + + {fmt.integer(otherCount)} +
  • + )} + {previewRows.length === 0 && otherCount === 0 && ( +
  • + {t("admin.section.backups.previewEmpty")} +
  • + )} +
+ )} +
+ ); +} + +/** + * 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). + */ +export function RestoreRowDialog({ + row, + pending, + onConfirm, +}: { + row: BackupRow; + pending: boolean; + onConfirm: (options: { restoreInstanceSettings: boolean }) => void; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const [open, setOpen] = useState(false); + const [typed, setTyped] = useState(""); + const matched = typed.trim() === "RESTORE"; + // Off unless the operator says otherwise. A disaster-recovery snapshot + // carries the host's own settings alongside the account's data, and putting + // one account back is not a reason to reconfigure the installation for + // everybody on it — so the second effect is a second decision. + const [withInstanceSettings, setWithInstanceSettings] = useState(false); + + return ( + { + setOpen(next); + if (!next) { + setTyped(""); + setWithInstanceSettings(false); + } + }} + > + + + + + + + {t("admin.section.backups.restoreTitle")} + + + {t("admin.section.backups.restoreDescription", { + username: row.username, + when: fmt.dateTime(row.createdAt), + })} + + + {/* What the file contains, before the typed gate. */} + + {/* The second decision, before the typed gate rather than after it: + the account's data comes back either way, the host's settings only + if this is ticked. */} + +
+ + setTyped(e.target.value)} + autoComplete="off" + spellCheck={false} + placeholder="RESTORE" + /> +
+ + {t("common.cancel")} + { + if (!matched) return; + setOpen(false); + setTyped(""); + onConfirm({ restoreInstanceSettings: withInstanceSettings }); + setWithInstanceSettings(false); + }} + > + {pending + ? t("admin.section.backups.restoreInProgress") + : t("admin.section.backups.restoreConfirm")} + + +
+
+ ); +} diff --git a/src/components/admin/backups/restore-notices.tsx b/src/components/admin/backups/restore-notices.tsx new file mode 100644 index 000000000..bb1dd6bff --- /dev/null +++ b/src/components/admin/backups/restore-notices.tsx @@ -0,0 +1,353 @@ +import { AlertTriangle } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { BackupScheduleStatus } from "@/lib/jobs/backup-schedule-status"; +import type { + MissingBackupSection, + RestoreSkipSummary, + SkippedCatalogue, +} from "@/lib/export/restore-skips"; + +/** + * 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"); + } + if (catalogue === "moodTag") { + return t("admin.section.backups.restoreSkippedMoodTag"); + } + if (catalogue === "visitReference") { + return t("admin.section.backups.restoreSkippedVisitReference"); + } + if (catalogue === "vaccinationReference") { + return t("admin.section.backups.restoreSkippedVaccinationReference"); + } + if (catalogue === "checkupClosure") { + return t("admin.section.backups.restoreSkippedCheckupClosure"); + } + if (catalogue === "reminderReference") { + return t("admin.section.backups.restoreSkippedReminderReference"); + } + if (catalogue === "coachAttachment") { + return t("admin.section.backups.restoreSkippedCoachAttachment"); + } + if (catalogue === "coachReference") { + return t("admin.section.backups.restoreSkippedCoachReference"); + } + if (catalogue === "documentConditionLink") { + return t("admin.section.backups.restoreSkippedDocumentConditionLink"); + } + if (catalogue === "relationLink") { + return t("admin.section.backups.restoreSkippedRelationLink"); + } + if (catalogue === "extractedFact") { + return t("admin.section.backups.restoreSkippedExtractedFact"); + } + if (catalogue === "factCommitment") { + return t("admin.section.backups.restoreSkippedFactCommitment"); + } + if (catalogue === "moodFactor") { + return t("admin.section.backups.restoreSkippedMoodFactor"); + } + if (catalogue === "personalRecordReference") { + return t("admin.section.backups.restoreSkippedPersonalRecordReference"); + } + if (catalogue === "ecgReference") { + return t("admin.section.backups.restoreSkippedEcgReference"); + } + if (catalogue === "symptomEpisodeReference") { + return t("admin.section.backups.restoreSkippedSymptomEpisodeReference"); + } + if (catalogue === "visitCiphertext") { + return t("admin.section.backups.restoreSkippedVisitCiphertext"); + } + if (catalogue === "vaccinationCiphertext") { + return t("admin.section.backups.restoreSkippedVaccinationCiphertext"); + } + if (catalogue === "environmentLocationCiphertext") { + return t( + "admin.section.backups.restoreSkippedEnvironmentLocationCiphertext", + ); + } + if (catalogue === "moodLabelCiphertext") { + return t("admin.section.backups.restoreSkippedMoodLabelCiphertext"); + } + if (catalogue === "cycleCiphertext") { + return t("admin.section.backups.restoreSkippedCycleCiphertext"); + } + if (catalogue === "medicationTarget") { + return t("admin.section.backups.restoreSkippedMedicationTarget"); + } + if (catalogue === "scheduleRevisionLink") { + return t("admin.section.backups.restoreSkippedScheduleRevisionLink"); + } + if (catalogue === "accountSetting") { + return t("admin.section.backups.restoreSkippedAccountSetting"); + } + // Not a fallback: the chain above is exhaustive and this line is what makes + // the compiler say so. A catalogue added without a label here now stops the + // build instead of shipping under a label that belongs to something else, + // which is what three of them were already doing — a dropped Coach + // attachment reached the operator's screen labelled "mood factor". + const unlabelled: never = catalogue; + return unlabelled; +} + +/** + * Which section a refused file claimed and did not carry, as a label. + * + * Spelled out with literal `t()` calls for the same reason as + * `catalogueLabel` above — an interpolated key is invisible to + * `i18n-call-site-coverage.test.ts`, and a renamed section would ship as raw + * dot notation. The `never` at the foot is what makes the compiler say the + * chain is exhaustive. + */ +function missingSectionLabel( + section: MissingBackupSection, + t: ReturnType["t"], +) { + if (section === "documents") { + return t("admin.section.backups.restoreMissingDocuments"); + } + if (section === "workouts") { + return t("admin.section.backups.restoreMissingWorkouts"); + } + if (section === "mentalHealth") { + return t("admin.section.backups.restoreMissingMentalHealth"); + } + if (section === "consent") { + return t("admin.section.backups.restoreMissingConsent"); + } + const unlabelled: never = section; + return unlabelled; +} + +/** + * The refused sections a failed restore job names, or none. + * + * Keyed on the failure's `code` and not on its message: the message is prose + * the server may reword, and matching on it would turn a copy edit into a + * silent loss of the panel. + */ +export function missingSectionsOf( + failure: { code: string; sections?: readonly unknown[] } | null | undefined, +): MissingBackupSection[] { + if (failure?.code !== "backup.section.missing") return []; + const sections = failure.sections; + if (!Array.isArray(sections)) return []; + return sections.filter( + (section): section is MissingBackupSection => typeof section === "string", + ); +} + +/** + * Why a file was refused, and what to do about it. + * + * Held on screen rather than left to the toast, exactly like the skip report + * below it. The operator refused here is often holding the only copy of an + * account; "restore failed" for four seconds is not enough to act on, and the + * two facts that let them act — which section the file claimed and did not + * carry, and that nothing was changed — have to survive long enough to be read. + */ +export function RestoreRefusalNotice({ + sections, + onDismiss, +}: { + sections: MissingBackupSection[]; + onDismiss: () => void; +}) { + const { t } = useTranslations(); + if (sections.length === 0) return null; + return ( +
+
+
+
+ +
+
+ ); +} + +/** + * 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 ( +
+
+
+
+ +
+
+ ); +} + +/** + * Says out loud whether the SCHEDULE is still alive. + * + * The weekly pass on the maintainer's own instance stopped producing copies + * and nothing surfaced it for a month and a half: the table listed the rows it + * had, each with an ordinary timestamp, and a copy from six weeks ago reads + * exactly like one made on Sunday until somebody does the subtraction. Two + * independent facts are shown, because they fail at different times — the age + * of the newest scheduled copy never ages out, while the run's own outcome + * carries the reason ("job timed out") and survives only as long as pg-boss + * keeps the row. + * + * Not dismissible: it reflects live state, so the only way to clear it is to + * make a backup succeed. + */ +export function ScheduleHealthNotice({ + schedule, +}: { + schedule: BackupScheduleStatus; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + if (!schedule.stale && !schedule.lastRunFailed) return null; + + return ( +
+
+
+
+ ); +} diff --git a/src/components/admin/backups/use-backup-actions.ts b/src/components/admin/backups/use-backup-actions.ts new file mode 100644 index 000000000..033ea391b --- /dev/null +++ b/src/components/admin/backups/use-backup-actions.ts @@ -0,0 +1,100 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; + +import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof"; +import { + ReproofCancelledError, + throwIfReproofRequired, +} from "@/components/settings/security-section/use-recent-proof"; +import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { BackupRow } from "@/types/backups"; + +import { getApiErrorMessage } from "../_shared"; + +/** "Run backup now": enqueues the pg-boss `data-backup` job. */ +export function useRunBackup() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + return 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) => { + showErrorToast( + err instanceof Error && err.message + ? err.message + : t("admin.section.backups.runFailed"), + { cause: err }, + ); + }, + }); +} + +/** One backup file to the browser, after a fresh proof when one is due. */ +export function useBackupDownload( + recentProof: ReturnType, +) { + const { t } = useTranslations(); + // 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); + + 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 recentProof.run(async () => + throwIfReproofRequired( + 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) { + if (err instanceof ReproofCancelledError) return; + showErrorToast( + err instanceof Error && err.message + ? err.message + : t("admin.section.backups.downloadFailed"), + { cause: err }, + ); + } finally { + setDownloadingId(null); + } + } + + return { downloadingId, handleDownload }; +} diff --git a/src/components/admin/backups/use-backup-restores.ts b/src/components/admin/backups/use-backup-restores.ts new file mode 100644 index 000000000..97531da48 --- /dev/null +++ b/src/components/admin/backups/use-backup-restores.ts @@ -0,0 +1,213 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; + +import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof"; +import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof"; +import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import type { + MissingBackupSection, + RestoreSkipSummary, +} from "@/lib/export/restore-skips"; +import type { BackupRestoreJobView } from "@/lib/jobs/backup-restore"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { randomId } from "@/lib/random-id"; +import type { BackupRow } from "@/types/backups"; + +import { isActiveRestore } from "../backup-restore-status"; +import { missingSectionsOf } from "./restore-notices"; + +const DISMISSED_RESTORES_KEY = "healthlog.admin.backups.dismissedRestores"; + +/** Dismissed restore panels, from this browser. Empty when storage is off. */ +function readDismissedRestores(): string[] { + try { + if (typeof window === "undefined") return []; + const raw = window.localStorage.getItem(DISMISSED_RESTORES_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) + ? parsed.filter((id): id is string => typeof id === "string") + : []; + } catch { + return []; + } +} + +function writeDismissedRestores(ids: string[]): void { + try { + // The list only ever shows a day of jobs; fifty ids is weeks of them. + window.localStorage.setItem( + DISMISSED_RESTORES_KEY, + JSON.stringify(ids.slice(-50)), + ); + } catch { + // A private window: the panel comes back on reload, nothing else breaks. + } +} + +/** + * The restores: the server's job list (polled while one runs), what this page + * saw finish, the panels the operator dismissed, the refusal and skip reports + * held on screen, and the typed-confirmation request that queues a restore. + */ +export function useBackupRestores( + recentProof: ReturnType, +) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + // 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); + + // Why the last restore was refused before it touched anything. Same reason + // the skip report is held rather than toasted: the operator has to be able to + // read it after the four seconds are up. + const [refusedSections, setRefusedSections] = useState< + MissingBackupSection[] + >([]); + + // A job this page watched while it ran, and has now seen finish. Only those + // raise a toast and refresh the page's data: a job that had already finished + // when the page opened is shown in its panel and nothing more. Read in the + // poll's own callback, where each answer arrives, rather than in an effect. + const watched = useRef>(new Map()); + function noticeFinishedRestores(jobs: readonly BackupRestoreJobView[]) { + for (const job of jobs) { + const before = watched.current.get(job.id); + watched.current.set(job.id, job.status); + if (before !== "queued" && before !== "running") continue; + if (job.status === "succeeded") { + setRefusedSections([]); + const report = job.result?.skipped; + if (report && report.links > 0) { + setSkipped(report as RestoreSkipSummary); + toast.warning( + t("admin.section.backups.restoreSkippedToast", { + links: String(report.links), + }), + ); + } else { + setSkipped(null); + toast.success(t("admin.section.backups.restoreSuccess")); + } + // Restore touches every personal-data table; nuke the broader cache + // so dashboards / lists rebuild against the new state. + void queryClient.invalidateQueries(); + } else if (job.status === "failed") { + setRefusedSections(missingSectionsOf(job.failure)); + showErrorToast(t("admin.section.backups.restoreFailed")); + } + } + } + + // The restore jobs, read from the server so a reload during a restore finds + // it again. Polled every two seconds while any is queued or running, and + // not at all otherwise. A poll that fails keeps the last answer on screen: + // a network blip is not a failed restore. + const restores = useQuery({ + queryKey: queryKeys.adminBackupRestores(), + queryFn: async () => { + const data = await apiGet<{ jobs: BackupRestoreJobView[] }>( + "/api/admin/backups/restores", + ); + noticeFinishedRestores(data.jobs); + return data; + }, + refetchInterval: (query) => + query.state.data?.jobs.some(isActiveRestore) ? 2000 : false, + }); + const restoreJobs = restores.data?.jobs ?? []; + const activeOwners = new Set( + restoreJobs.filter(isActiveRestore).map((job) => job.userId), + ); + + // Finished restores the operator dismissed. Remembered in this browser only, + // for convenience; the list itself forgets a job after a day. + const [dismissedRestores, setDismissedRestores] = useState>( + () => new Set(readDismissedRestores()), + ); + function dismissRestore(id: string) { + setDismissedRestores((previous) => { + const next = new Set(previous); + next.add(id); + writeDismissedRestores([...next]); + return next; + }); + } + + // Restore: typed-confirmation dialog. The request only queues the restore; + // the panel above the list follows it from there. + const restore = useMutation({ + mutationFn: async ({ + row, + restoreInstanceSettings, + }: { + row: BackupRow; + restoreInstanceSettings: boolean; + }) => { + // Idempotency-Key prevents a double-click from queuing a second + // restore. Include the row id so two different backups can both be + // restored independently in the same minute. + const idempotencyKey = `restore-${row.id}-${randomId()}`; + return recentProof.run(() => + apiPost<{ jobId: string; status: "queued" }>( + `/api/admin/backups/${row.id}/restore`, + { confirm: "RESTORE", restoreInstanceSettings }, + { headers: { "Idempotency-Key": idempotencyKey } }, + ), + ); + }, + onSuccess: (data) => { + setRefusedSections([]); + setSkipped(null); + watched.current.set(data.jobId, "queued"); + // Neutral, not a success: nothing has been restored yet. + toast.info(t("admin.section.backups.restoreStarted")); + void queryClient.invalidateQueries({ + queryKey: queryKeys.adminBackupRestores(), + }); + }, + onError: (err) => { + if (err instanceof ReproofCancelledError) return; + if ( + err instanceof ApiError && + err.meta?.errorCode === "backup.restore.active" + ) { + showErrorToast(t("admin.section.backups.restoreActive"), { + cause: err, + }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.adminBackupRestores(), + }); + return; + } + setRefusedSections([]); + showErrorToast( + err instanceof Error && err.message + ? err.message + : t("admin.section.backups.restoreFailed"), + { cause: err }, + ); + }, + }); + + return { + restoreJobs, + dismissedRestores, + dismissRestore, + restore, + /** Whether a row's restore control waits: its request or its owner's job. */ + restorePending: (row: BackupRow) => + (restore.isPending && restore.variables?.row.id === row.id) || + activeOwners.has(row.userId), + skipped, + setSkipped, + refusedSections, + setRefusedSections, + }; +} diff --git a/src/components/admin/backups/use-backup-upload.ts b/src/components/admin/backups/use-backup-upload.ts new file mode 100644 index 000000000..30c7f7efc --- /dev/null +++ b/src/components/admin/backups/use-backup-upload.ts @@ -0,0 +1,156 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRef, type ChangeEvent } from "react"; +import { toast } from "sonner"; + +import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof"; +import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof"; +import { apiFetch, apiGet } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * The upload body for a backup file: the file itself when it is already + * gzip, otherwise the file compressed in the browser. The server accepts + * both; compressing keeps a large export well inside the request size limit. + */ +async function uploadBody(file: File): Promise { + const head = new Uint8Array(await file.slice(0, 2).arrayBuffer()); + if (head[0] === 0x1f && head[1] === 0x8b) return file; + return new Response( + file.stream().pipeThrough(new CompressionStream("gzip")), + ).blob(); +} + +/** + * Send a backup file to the admin upload route. Sent as the raw body, + * compressed on the way unless it already is: the server reads it as a stream, + * and a large export (hundreds of MB of JSON) shrinks about tenfold. No request + * timeout, because a file of that size takes longer to check and store than + * the default allows. The body is built here, per call, so a retry after a + * re-proof sends the file again rather than a spent stream. + */ +async function uploadOnce(file: File) { + const body = await uploadBody(file); + return apiFetch<{ + id: string; + valid: true; + summary: { + measurements: number; + medications: number; + intakeEvents: number; + moodEntries: number; + // Optional for the same reason as the keys below: a file written + // before side effects rode the wire carries no key at all. + medicationSideEffects?: number; + cycles?: number; + cycleDayLogs?: number; + // 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, + headers: { "Content-Type": "application/gzip" }, + signal: null, + }); +} + +export type UploadSummary = Awaited>["summary"]; + +/** How many records an accepted upload carries, every section counted. */ +export function uploadedRecordCount(summary: UploadSummary): number { + return ( + summary.measurements + + summary.medications + + summary.intakeEvents + + summary.moodEntries + + (summary.medicationSideEffects ?? 0) + + (summary.cycles ?? 0) + + (summary.cycleDayLogs ?? 0) + + (summary.labResults ?? 0) + + (summary.biomarkers ?? 0) + + (summary.illnessEpisodes ?? 0) + + (summary.illnessDayLogs ?? 0) + + (summary.allergies ?? 0) + + (summary.familyHistory ?? 0) + + (summary.workouts ?? 0) + + (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. + (summary.nutrientDays ?? 0) + + (summary.healthProfile ?? 0) + + (summary.healthProfileFactRevisions ?? 0) + + (summary.customMetrics ?? 0) + + (summary.customMetricEntries ?? 0) + + (summary.intradayProfiles ?? 0) + ); +} + +/** + * The single-file upload flow: a fresh proof first, then the file, and the + * file input reset after either outcome. + */ +export function useBackupUpload( + recentProof: ReturnType, +) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + // Upload state — single-file flow, drives the file input + button. + const fileInputRef = useRef(null); + const upload = useMutation({ + mutationFn: async (file: File) => { + // Ask first, so a large file is not sent once only to be refused. + await recentProof.run(() => apiGet("/api/auth/reproof")); + return uploadOnce(file); + }, + onSuccess: (data) => { + const total = uploadedRecordCount(data.summary); + toast.success( + t("admin.section.backups.uploadSuccess", { count: String(total) }), + ); + queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() }); + }, + onError: (err) => { + if (err instanceof ReproofCancelledError) return; + showErrorToast( + err instanceof Error && err.message + ? err.message + : t("admin.section.backups.uploadFailed"), + { cause: err }, + ); + }, + 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); + } + + return { upload, fileInputRef, handleFileChange }; +}