feat(admin): backups view + manual backup trigger (T2.6 deferred)

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.
* `<BackupsSection>` — 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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 16:59:38 +02:00
1 parent 12b280bb55
commit 69c5225cea
3 files changed
+284

No files matched your search

+69
View File
@@ -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);
});
+42
View File
@@ -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,
});
});
+173
View File
@@ -0,0 +1,173 @@
"use client";
/**
* `<BackupsSection>` — 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<typeof useFormatters>) {
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 (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Database className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("admin.section.backups.title")}
</h2>
{data && (
<Badge variant="secondary" className="text-xs">
{rows.length}
</Badge>
)}
</div>
<Button
size="sm"
disabled={runBackup.isPending}
onClick={() => runBackup.mutate()}
>
{runBackup.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<PlayCircle className="mr-1 h-3.5 w-3.5" />
)}
{t("admin.section.backups.runNow")}
</Button>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.section.backups.description")}
</p>
{isLoading ? (
<div className="mt-4 flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm">
{t("admin.section.backups.loading")}
</span>
</div>
) : isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 rounded-md border px-3 py-2 text-sm"
>
{t("admin.section.backups.loadError")}
</div>
) : rows.length === 0 ? (
<div className="text-muted-foreground mt-4 text-sm">
{t("admin.section.backups.empty")}
</div>
) : (
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.section.backups.colUser")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.section.backups.colType")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.section.backups.colSize")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.section.backups.colCreatedAt")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{rows.map((row, i) => (
<tr key={row.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
<td className="px-3 py-2 font-medium">{row.username}</td>
<td className="px-3 py-2">
<Badge variant="secondary" className="text-xs">
{row.type}
</Badge>
</td>
<td className="px-3 py-2 text-right font-mono text-xs tabular-nums">
{formatBytes(row.sizeBytes, fmt)}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{formatDateTime(row.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
<p className="text-muted-foreground mt-2 text-xs">
{t("admin.section.backups.retentionLabel", {
days: data!.retentionDays,
})}
</p>
</div>
)}
</div>
);
}