Files
HealthLog/src/app/api/export/full-backup/route.ts
T
Marc-André Bombeck 7c5ff89def feat(lint): refuse UTC day keys outside the time zone module
`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.
2026-10-01 09:59:57 +02:00

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