From 69c5225ceaced2eb96d3845eb0497a2a2d4ced16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 16:59:38 +0200 Subject: [PATCH] feat(admin): backups view + manual backup trigger (T2.6 deferred) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds in the v1.4.6 deferred backlog item: * `GET /api/admin/backups` — returns metadata-only listing of every DataBackup row (size in bytes, no payload). Encrypted blobs stay server-side. * `POST /api/admin/backups/run` — enqueues an ad-hoc data-backup pg-boss job. Returns 503 if the worker isn't running; the existing weekly cron schedule is unchanged. * `` — admin route at /admin/backups with the rows table + "Backup now" CTA. No new dependencies; uses the global pg-boss instance already wired in `src/lib/jobs/boss-instance.ts`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/admin/backups/route.ts | 69 +++++++++ src/app/api/admin/backups/run/route.ts | 42 ++++++ src/components/admin/backups-section.tsx | 173 +++++++++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 src/app/api/admin/backups/route.ts create mode 100644 src/app/api/admin/backups/run/route.ts create mode 100644 src/components/admin/backups-section.tsx diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts new file mode 100644 index 000000000..689c7a442 --- /dev/null +++ b/src/app/api/admin/backups/route.ts @@ -0,0 +1,69 @@ +/** + * GET /api/admin/backups — admin-only listing of `DataBackup` rows. + * + * Returns one row per (userId, type) pair. The `data` payload is NOT + * shipped — only metadata (id, userId, username, type, size in bytes, + * createdAt). The encrypted blob remains server-side; admins can trigger + * a re-snapshot but not download another user's payload from the UI. + */ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export interface BackupRow { + id: string; + userId: string; + username: string; + type: string; + /** + * Size of the encrypted blob in bytes — useful for capacity planning. + * The blob itself is never returned. + */ + sizeBytes: number; + createdAt: string; +} + +export interface BackupsList { + rows: BackupRow[]; + /** + * Soft retention hint — the worker is configured for weekly backups + * (see `DATA_BACKUP_CRON` in `src/lib/jobs/reminder-worker.ts`), and + * the model upserts in-place per (userId, type), so each user has + * exactly one current snapshot. The frontend uses this to label the + * grid; no server-side enforcement. + */ + retentionDays: number; +} + +export const GET = apiHandler(async () => { + await requireAdmin(); + annotate({ action: { name: "admin.backups.list" } }); + + // Pull metadata only — `data` would balloon the response and reveal + // ciphertext we don't need on the listing page. + const backups = await prisma.dataBackup.findMany({ + include: { user: { select: { id: true, username: true } } }, + orderBy: { createdAt: "desc" }, + }); + + const rows: BackupRow[] = backups.map((b) => ({ + id: b.id, + userId: b.userId, + username: b.user.username, + type: b.type, + sizeBytes: Buffer.byteLength(b.data, "utf8"), + createdAt: b.createdAt.toISOString(), + })); + + const payload: BackupsList = { + rows, + // Mirrors the value surfaced by `/api/admin/status-overview` so the + // backups page and the status card grid agree. + retentionDays: 90, + }; + + return apiSuccess(payload); +}); diff --git a/src/app/api/admin/backups/run/route.ts b/src/app/api/admin/backups/run/route.ts new file mode 100644 index 000000000..ad1f10164 --- /dev/null +++ b/src/app/api/admin/backups/run/route.ts @@ -0,0 +1,42 @@ +/** + * POST /api/admin/backups/run — enqueue a manual data-backup pg-boss job. + * + * The pg-boss `data-backup` queue is also driven by a weekly cron in the + * worker (Sundays 03:00 Europe/Berlin); this route lets an admin force a + * snapshot now. Returns the boss job id so the UI can show feedback. + * + * If the worker isn't running (no global boss instance) the route returns + * 503 — the caller can then route the user to the system-status page. + */ +import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; + +export const dynamic = "force-dynamic"; + +const DATA_BACKUP_QUEUE = "data-backup"; + +export const POST = apiHandler(async () => { + await requireAdmin(); + annotate({ action: { name: "admin.backups.run" } }); + + const boss = getGlobalBoss(); + if (!boss) { + // 503 instead of 500 — the request is well-formed; the worker just + // isn't reachable from this process. Same response shape as other + // worker-required endpoints. + throw new HttpError(503, "Background worker is not running"); + } + + const jobId = await boss.send(DATA_BACKUP_QUEUE, { + triggeredAt: new Date().toISOString(), + }); + + annotate({ meta: { job_id: jobId ?? null } }); + + return apiSuccess({ + jobId, + queue: DATA_BACKUP_QUEUE, + }); +}); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx new file mode 100644 index 000000000..af37f3ea4 --- /dev/null +++ b/src/components/admin/backups-section.tsx @@ -0,0 +1,173 @@ +"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 { Database, Loader2, PlayCircle } from "lucide-react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { formatDateTime } from "@/lib/format"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { BackupRow, BackupsList } from "@/app/api/admin/backups/route"; +import { getApiErrorMessage } from "./_shared"; + +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`; +} + +export function BackupsSection({ id }: { id?: string } = {}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const queryClient = useQueryClient(); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["admin", "backups"], + queryFn: async () => { + const res = await fetch("/api/admin/backups"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as BackupsList; + }, + }); + + const runBackup = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/admin/backups/run", { method: "POST" }); + if (!res.ok) { + throw new Error(await getApiErrorMessage(res)); + } + return (await res.json()).data as { jobId: string | null }; + }, + 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: ["admin", "backups"] }); + }, 2000); + }, + onError: (err) => { + toast.error( + err instanceof Error && err.message + ? err.message + : t("admin.section.backups.runFailed"), + ); + }, + }); + + const rows: BackupRow[] = data?.rows ?? []; + + return ( +
+
+
+ +

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

+ {data && ( + + {rows.length} + + )} +
+ +
+

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

+ + {isLoading ? ( +
+ + + {t("admin.section.backups.loading")} + +
+ ) : isError ? ( +
+ {t("admin.section.backups.loadError")} +
+ ) : rows.length === 0 ? ( +
+ {t("admin.section.backups.empty")} +
+ ) : ( +
+ + + + + + + + + + + {rows.map((row, i) => ( + + + + + + + ))} + +
+ {t("admin.section.backups.colUser")} + + {t("admin.section.backups.colType")} + + {t("admin.section.backups.colSize")} + + {t("admin.section.backups.colCreatedAt")} +
{row.username} + + {row.type} + + + {formatBytes(row.sizeBytes, fmt)} + + {formatDateTime(row.createdAt)} +
+

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

+
+ )} +
+ ); +}