mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
The weekly pass can stop and leave no trace where anyone looks. The handler's own error never runs when the job is killed rather than thrown; `recordError()` increments an unnamed counter; and the admin status page's failing-queue panel looks back 72 hours, which for a weekly queue means a permanent failure is on screen three days in seven. The backups page itself showed an ordinary row with an ordinary timestamp, because a copy made six weeks ago reads exactly like Sunday's until somebody subtracts the dates. The listing now carries two facts that fail at different times: the age of the newest scheduled copy, which never leaves any retention window, and the terminal state of the last scheduled run, which is the only place the reason is written down. `readLastQueueRun` takes the newest run rather than a window, so a weekly queue's outcome stays readable until the next one replaces it, and it unwraps the value pg-boss stores a thrown error under, so the page says "job timed out" instead of raw JSON. The failing-queue reader gets the same unwrap. Manual uploads are excluded from the cadence judgement by the shape of the input, so an ad-hoc upload cannot make a dead cron look alive, and a fresh instance is not called overdue for having no copy yet. The listing also stops reading every blob into the web process to measure it; `octet_length` answers that in the database, which on an instance with large records was its own way of running out of memory.
84 lines
3.1 KiB
TypeScript
84 lines
3.1 KiB
TypeScript
/**
|
|
* 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.
|
|
*
|
|
* Alongside the rows, the response says whether the SCHEDULE is still alive:
|
|
* how old the newest scheduled copy is and how the last scheduled run ended.
|
|
* Without those two the page cannot tell a working backup from one that
|
|
* stopped six weeks ago — every row it lists has a perfectly ordinary
|
|
* timestamp either way.
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
|
import { apiSuccess } from "@/lib/api-response";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy";
|
|
import { readLastQueueRun } from "@/lib/jobs/job-failures";
|
|
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
|
|
// v1.4.41 W-ORG — `BackupRow` / `BackupsList` moved to `src/types/backups.ts`
|
|
// so callers (in particular `components/admin/backups-section.tsx`) don't
|
|
// have to reach across the component → route-handler layer boundary.
|
|
import type { BackupRow, BackupsList } from "@/types/backups";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
/** The type the weekly worker writes; anything else was uploaded by hand. */
|
|
const SCHEDULED_BACKUP_TYPE = "WEEKLY_AUTO";
|
|
|
|
interface BackupMetaRow {
|
|
id: string;
|
|
user_id: string;
|
|
username: string;
|
|
type: string;
|
|
size_bytes: number;
|
|
created_at: Date;
|
|
}
|
|
|
|
export const GET = apiHandler(async () => {
|
|
await requireAdmin();
|
|
annotate({ action: { name: "admin.backups.list" } });
|
|
|
|
// Metadata only, and the size read as `octet_length` in the database rather
|
|
// than by pulling every blob into this process to measure it. The listing
|
|
// needs a number, not the ciphertext, and on an instance with large records
|
|
// fetching them all was its own way of running out of memory. Base64 is
|
|
// ASCII, so the column's byte length is exactly what the wire reports.
|
|
const rows = await prisma.$queryRaw<BackupMetaRow[]>`
|
|
SELECT b.id, b.user_id, u.username, b.type,
|
|
octet_length(b.data) AS size_bytes,
|
|
b.created_at
|
|
FROM data_backups b
|
|
JOIN users u ON u.id = b.user_id
|
|
ORDER BY b.created_at DESC
|
|
`;
|
|
|
|
const list: BackupRow[] = rows.map((row) => ({
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
username: row.username,
|
|
type: row.type,
|
|
sizeBytes: Number(row.size_bytes),
|
|
createdAt: row.created_at.toISOString(),
|
|
}));
|
|
|
|
const payload: BackupsList = {
|
|
rows: list,
|
|
// Matches the retention window the backup-prune job enforces so the
|
|
// backups page states the same number the worker acts on.
|
|
retentionDays: 90,
|
|
schedule: summariseBackupSchedule({
|
|
scheduledCreatedAt: rows
|
|
.filter((row) => row.type === SCHEDULED_BACKUP_TYPE)
|
|
.map((row) => row.created_at),
|
|
lastRun: await readLastQueueRun(DATA_BACKUP_QUEUE),
|
|
now: new Date(),
|
|
}),
|
|
};
|
|
|
|
return apiSuccess(payload);
|
|
});
|