mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
12b280bb55
commit
69c5225cea
3 files changed
+284
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user