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