mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +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,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user