mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC midnight are the idioms behind every day-boundary fix this year: the evening west of UTC and the small hours east of it land on the neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in src/ outside src/lib/tz and tests, and refuses a disable directive for itself that is not next-line or gives no reason. The time zone module now names every legitimate form: userDayKey for a person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey / isCalendarDateKey / statedDateKey for key arithmetic and parsing. Of the 133 sites the rule found, 84 are gone. Most moved onto a named helper with no change in behaviour; these changed behaviour: - cycle calendar, insights, Coach snapshot and prediction refresh start their lookbacks at the first instant of the user's day - a model-transcribed date like "June 10, 2026" no longer lands a day early in a process east of UTC (documents and lab scans) - a custom report range covers exactly the picked days in the profile zone instead of starting the evening before west of UTC - date-of-birth pickers cap at the user's today, not the UTC one - an allergy onset date is stored at noon UTC like other date-only values - the report's cycle summary, the Coach's usual BP range and the intake projector's rollup recompute use the user's day - vaccination dates render as dates instead of the previous evening 49 remain, each with its reason on the line above: 39 UTC by design (15 file name stamps, rollup and object keys, the UTC-day score engines) and 10 marked baseline. A guard test requires a classified reason on every directive and holds the baseline to a ceiling that only goes down.
105 lines
4.6 KiB
TypeScript
105 lines
4.6 KiB
TypeScript
/**
|
|
* GET /api/export/full-backup
|
|
*
|
|
* v1.4.16 phase B7. Single-file user-scoped JSON dump that matches the
|
|
* canonical `backupPayloadSchema` (see `src/lib/validations/backup.ts`)
|
|
* — same shape the pg-boss `data-backup` worker writes weekly, so a
|
|
* user can hand this file to an admin and `POST /api/admin/backups/upload`
|
|
* accepts it without further conversion.
|
|
*
|
|
* Response is `application/json` with an attachment filename ending in
|
|
* `.json` so the browser writes a `.json` file even though the route
|
|
* segment doesn't carry the extension.
|
|
*
|
|
* Backup completeness — what this file carries, and what it does not, is
|
|
* declared in `src/lib/export/backup-plan.ts` rather than described here. A
|
|
* sentence beside the code is how the previous version of this comment came
|
|
* to be wrong: it said lab results, biomarkers, illness episodes, allergies,
|
|
* family history and workouts were EXPORT-ONLY and could not be restored.
|
|
* The restore route re-creates all of them, and since v1.33.1 nutrient day
|
|
* totals as well.
|
|
*
|
|
* The gap that IS real: roughly 31 of the models the plan marks `BACKED_UP`
|
|
* have neither a reader here nor a restore branch yet. The plan names each
|
|
* one. Document original files and workout GPS/sample time series are never
|
|
* included; the payload's `manifest` field discloses both, and the export UI
|
|
* states this too.
|
|
*
|
|
* Auth: a fresh proof (`requireRecentProof`). The file is the whole record in
|
|
* plain text, so a live session alone is not enough: a cookie session needs a
|
|
* recent sign-in or re-proof (a second factor on an account that has one), a
|
|
* Bearer caller an `X-Step-Up` elevation. No shipped client calls this route
|
|
* on Bearer.
|
|
* Rate-limit: shared `export:<userId>` bucket (10/h).
|
|
* Audit: `user.export.full-backup` with the row counts.
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
import { apiHandler, requireRecentProof } from "@/lib/api-handler";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { apiError, getClientIp } from "@/lib/api-response";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import type { FullBackupCounts } from "@/lib/export/full-backup-payload";
|
|
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
|
import { streamToResponseBody } from "@/lib/export/response-stream";
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
|
|
export const GET = apiHandler(async (request: NextRequest) => {
|
|
const auth = await requireRecentProof({ bearer: "elevation" });
|
|
const { user } = auth;
|
|
annotate({ action: { name: "user.export.full-backup" } });
|
|
|
|
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
|
if (!rl.allowed) {
|
|
return apiError("Maximum 10 exports per hour", 429);
|
|
}
|
|
await auth.commitElevation();
|
|
|
|
// Written into the response as it is produced (`streamFullBackupJson`), not
|
|
// built and stringified first: for an account of 1.25 million measurements
|
|
// the built payload and its JSON string did not fit a 1 GB container, and
|
|
// the export took the whole app down (#1031). The file is byte for byte
|
|
// what `JSON.stringify` of the payload builder's object gives; the passphrase
|
|
// export beside this route writes the same bytes, encrypted.
|
|
//
|
|
// The counts are only known once the last row has gone out, so the audit
|
|
// row is written then. A file that stops early is audited as a failure.
|
|
const ipAddress = getClientIp(request);
|
|
let counts: FullBackupCounts | undefined;
|
|
const body = streamToResponseBody(
|
|
async (write) => {
|
|
counts = await streamFullBackupJson(prisma, user.id, write);
|
|
},
|
|
{
|
|
onComplete: () =>
|
|
auditLog("user.export.full-backup", {
|
|
userId: user.id,
|
|
ipAddress,
|
|
details: { counts },
|
|
}),
|
|
onError: (err) =>
|
|
auditLog("user.export.full-backup.failed", {
|
|
userId: user.id,
|
|
ipAddress,
|
|
details: {
|
|
reason: err instanceof Error ? err.message : String(err),
|
|
},
|
|
}),
|
|
},
|
|
);
|
|
|
|
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
|
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
// Stream the JSON directly (NOT wrapped in the apiSuccess envelope) so
|
|
// the file is a self-contained backup — admin upload + restore expect
|
|
// the raw payload, not `{ data: { ... } }`.
|
|
return new NextResponse(body, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
"Content-Disposition": `attachment; filename="healthlog-backup-${user.id}-${stamp}.json"`,
|
|
"Cache-Control": "no-store",
|
|
},
|
|
});
|
|
});
|