feat(export): make the doctor report carry only what was chosen

The report resolved an absent key to "included". A caller that sent no
sections object, or an empty group inside one, received the full record at
server-chosen defaults: roughly fifty metrics plus the medication list, labs,
allergies and family history. The web panel sent every key explicitly so it
looked fine from the browser, but the share link, the FHIR REST face, the MCP
doctor-visit resource and the cycle-only export card all went through paths
where absence meant yes. No arrangement of booleans fixes a default that means
yes, so the shape goes.

A selection is now an inclusion list of leaf ids. A leaf is the smallest thing
that can be released or withheld: every measurement type, and thirteen
structured sections — identity, insurer, the glucose panel, labs, the drug
list, the administration ledger, adherence, the GLP-1 block, allergies,
illness episodes, family history, mood, cycle. Membership is inclusion,
absence is exclusion, and there is no defaults constant anywhere to drift.

Three locks keep a metric added later off a report chosen earlier. A stored
selection is a list of what was chosen, so a leaf that did not exist then is
not in it. The catalogue is keyed over the measurement enum, so adding a
member without deciding its group fails typecheck in the same commit. And the
aggregator's filter inverts: a type the catalogue does not know is excluded and
annotated, where it used to be passed through.

The aggregator's third parameter is now required and brand-typed, which turned
the three surfaces that had no scope at all into compile errors. Each one had
to name a source, and each now replays the owner's saved selection — the same
object reviewed in Settings. An account that never saved one gets nothing
there rather than a guess.

Identity and insurance become selectable, and a share link refuses the
insurance leaf outright. The share view never decrypted the number; that is
structural now instead of incidental. The KVNR is not even read unless the
leaf was chosen.

Two surfaces gain a record they always implied. A share link serves its frozen
selection as a FHIR bundle and as the PDF, behind the same unlock cookie the
document route uses, with its own rate bucket, its own audit row and the same
blunt 404 for every miss. That is what the removed FHIR toggle promised, in
the form a practice actually uses.

The AI summary leaves the report. Its text came from the cached briefing with
no relation to the selection, so its scope could not be proven, and prose whose
scope cannot be proven has no place on a document whose whole point is a proven
scope.

The PDF table stops being a nine-type whitelist. It prints every selected leaf
that has data, sub-headed by group, in the same order the panel shows — so
selecting resting heart rate no longer means seeing it in the bundle and not in
the PDF. The clinician view is grouped the same way and reads real labels
instead of "Phq9 score".

Migration 0273 adds the saved-selection column, drops the orphaned prefs column
and the two retired share-link scope columns, and revokes every live record
share link minted before this model. Their frozen scope was "everything except
mood and cycle", which is a scope nobody chose; narrowing it would still be a
scope nobody chose. Documents-only shares are untouched. The sharing list shows
each closed link in a pick-again state with its label, window and expiry kept.

The retired allowFhirApi and resourceTypes stay on the create and list
responses as the constant false and empty list. The request refuses them. They
come off the response once the native client confirms it does not decode them.

Six files are split at seams this change already touched: the aggregator into
its gate, its dense-bucket query, its series, its glucose panel, its medication
ledger and its clinical records; the export route's three emitters into their
own module; the clinician view into its sections, its document list and its
downloads.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 00:45:08 +02:00
1 parent f90219784b
commit 5e490f31ec
46 files changed
+3734 -3083

No files matched your search

+131 -157
View File
@@ -1,25 +1,25 @@
/**
* POST /api/export/health-record — v1.7.0 flagship health-record export.
* POST /api/export/health-record — the doctor-handover artefact.
*
* One entry point that produces the doctor-handover artefact in one of three
* formats, driven by a strict Zod selection payload:
* - `pdf` → `application/pdf` (enhanced clinical report)
* One entry point producing one of three formats from one aggregated payload,
* driven by a strict Zod selection:
* - `pdf` → `application/pdf`
* - `fhir` → `application/fhir+json` (HL7 FHIR R4 document Bundle)
* - `package` → `application/zip` (PDF + FHIR + README in one download)
* - `package` → `application/zip` (PDF + Bundle + README)
*
* Auth: cookie session OR Bearer token (`requireAuth`).
* Rate-limit: shared `export:<userId>` bucket (10/h) — so structured + PDF
* + CSV exports cannot be parallelised past the cap.
* Audit: `health-record.export` (records format/days/sections/charts — never
* the values).
* Rate-limit: shared `export:<userId>` bucket (10/h).
* Audit: `health-record.export` — format, window, and the chosen leaf ids,
* never the values behind them.
*
* Strict validation: unlike the legacy doctor-report route (which tolerates
* drift), this route fails loudly — `.strict()` + `returnAllZodIssues` returns
* a 422 multi-issue envelope on any unknown key (including any attempt to
* smuggle a `userId`, which is always narrowed from `requireAuth()`).
* The `selection` field is required and carries the leaf inclusion list. An
* unknown leaf id is a 422 that names it; the old grouped `sections` shape is
* a 422 too, because accepting it would mean choosing a scope the caller did
* not express. The rendering itself lives in
* `src/lib/export/health-record-artefacts.ts`; this route keeps the HTTP
* concerns.
*/
import { NextRequest, NextResponse } from "next/server";
import { zipSync, strToU8 } from "fflate";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -32,7 +32,7 @@ import {
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { prisma, toJson } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { toAllergyDTO, toFamilyHistoryEntryDTO } from "@/lib/records/dto";
import {
@@ -40,39 +40,46 @@ import {
normaliseDateRange,
sanitisePracticeName,
} from "@/lib/doctor-report-data";
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/build-bundle";
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
import { selectionFromRequest } from "@/lib/report-selection/selection";
import {
buildFhirDocumentBundle,
GERMAN_ATC_DEFAULT_LOCALES,
} from "@/lib/fhir/build-bundle";
import {
exportSelectionSchema,
toDoctorReportPrefs,
} from "@/lib/validations/health-record-export";
buildBundleJson,
buildPackageZip,
buildPdfBytes,
type ArtefactInputs,
} from "@/lib/export/health-record-artefacts";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { locales, type Locale } from "@/lib/i18n/config";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/**
* Remember the practice / clinic name for the next report, so the user does
* not retype it every time (`User.lastReportPracticeName`, the preference the
* doctor-report contract has always promised).
* Remember what the owner chose, so the panel opens carrying it next time.
*
* Called only once the requested artefact has actually been produced: a
* generation that throws must not overwrite the remembered name with a
* half-typed one. `null` is a legitimate value — the user cleared the line and
* the next cover should stay cleared. The value is the already-sanitised one
* (`sanitisePracticeName`, 120-char cap), and the update names its single
* field explicitly rather than spreading the parsed selection.
* generation that throws must not overwrite the remembered scope or a
* half-typed practice name. `null` is a legitimate practice name — the user
* cleared the line and the next cover should stay cleared. Both fields are
* named explicitly rather than spread from the parsed body.
*/
async function rememberPracticeName(
async function rememberChoices(
userId: string,
practiceName: string | null,
profile: {
v: 2;
leaves: string[];
format: "pdf" | "fhir" | "package";
rangeDays: number;
includeCharts: boolean;
},
) {
await prisma.user.update({
where: { id: userId },
data: { lastReportPracticeName: practiceName },
data: {
lastReportPracticeName: practiceName,
reportSelectionJson: toJson(profile),
},
});
}
@@ -80,10 +87,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "export.health-record.build" } });
// v1.18.0 B3 — the whole doctor-report / health-record surface is the
// `doctorReport` module. Refuse with a 403 `module.disabled` envelope when
// the account turned it off — even with a valid Bearer token. The settings
// entry-point already hides client-side; this is the hard enforcement.
// The whole doctor-report surface is the `doctorReport` module. Refuse with
// a 403 `module.disabled` envelope when the account turned it off — even
// with a valid Bearer token.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
@@ -92,11 +98,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Maximum 10 exports per hour", 429);
}
// The selection payload is small and bounded — format, range,
// section toggles, a few flags, an optional practice-name string.
// 64 KB is far above any legitimate selection while still rejecting
// a multi-megabyte body before it reaches `JSON.parse`. A DoS
// ceiling, not a tight bound.
// The selection payload is small and bounded. 64 KB is far above any
// legitimate one while still rejecting a multi-megabyte body before it
// reaches `JSON.parse`. A DoS ceiling, not a tight bound.
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
@@ -106,18 +110,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (!parsed.success) {
return returnAllZodIssues(parsed.error);
}
const selection = parsed.data;
const input = parsed.data;
const range = normaliseDateRange(selection.range ?? undefined);
const practiceName = sanitisePracticeName(selection.practiceName);
const sections = toDoctorReportPrefs(selection.sections);
const includeCharts = selection.includeCharts ?? true;
const minted = selectionFromRequest(input.selection);
if (!minted.ok) {
annotate({ meta: { unknownLeaves: minted.error.unknownLeaves } });
return apiError(
`Unknown selection leaf: ${minted.error.unknownLeaves.join(", ")}`,
422,
{ errorCode: "export.selection.unknown_leaf" },
);
}
const selection = minted.selection;
// Locale resolution, most-specific first: the explicit selection wins, then
// the in-app `healthlog-locale` cookie (what the user actually chose in the
// UI), and only then the browser's Accept-Language header. The header is the
// weakest signal — a German user on an English-default browser would
// otherwise get an English report.
const range = normaliseDateRange(input.range ?? undefined);
const practiceName = sanitisePracticeName(input.practiceName);
const includeCharts = input.includeCharts ?? true;
// Locale, most-specific first: the explicit selection wins, then the in-app
// cookie (what the user actually chose in the UI), and only then the
// browser's Accept-Language header — the weakest signal.
const cookieValue = request.cookies.get("healthlog-locale")?.value;
const cookieLocale = (locales as readonly string[]).includes(
cookieValue ?? "",
@@ -125,45 +137,34 @@ export const POST = apiHandler(async (request: NextRequest) => {
? (cookieValue as Locale)
: null;
const locale: Locale =
selection.locale ??
input.locale ??
cookieLocale ??
parseLocaleFromAcceptLanguage(request.headers.get("accept-language") ?? "");
// BfArM ATC: an explicit selection flag wins; otherwise derive it from a
// German-region locale. The WHO ATC coding is unaffected either way.
const germanAtc =
selection.germanAtc ??
input.germanAtc ??
(GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(locale);
const [data, userTz, userRow, allergyRows, familyHistoryRows] =
await Promise.all([
collectDoctorReportData(user.id, range, { practiceName, sections }),
collectDoctorReportData(user.id, range, selection, { practiceName }),
resolveUserTimezone(user.id),
prisma.user.findUnique({
where: { id: user.id },
select: {
insuranceNumberEncrypted: true,
insightsCachedText: true,
timeFormat: true,
},
select: { insuranceNumberEncrypted: true, timeFormat: true },
}),
// v1.25 (W-RECORDS) — structured records are always-available reference
// data (not time-windowed), so they ride alongside the report rather
// than through `collectDoctorReportData`. Owner-scoped, live rows only.
//
// Gated on the section toggles. These two reads fed ONLY the
// FHIR bundle, and they ran unconditionally: `sections.allergies = false`
// produced a PDF without an allergy section and a bundle carrying every
// AllergyIntolerance, and in the `package` format both shipped in the
// same zip. Zero DB read when off, matching how the aggregator treats
// mood — deselected data should not be loaded, not merely dropped later.
sections.allergies
// Structured records are always-available reference data (not
// time-windowed), so they ride alongside the report rather than through
// the aggregator. Owner-scoped, live rows only — and each read is gated
// on its own leaf, so a deselected section is not loaded, not merely
// dropped later.
selection.has("ALLERGIES")
? prisma.allergy.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { createdAt: "desc" },
})
: Promise.resolve([]),
sections.familyHistory
selection.has("FAMILY_HISTORY")
? prisma.familyHistoryEntry.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { createdAt: "desc" },
@@ -171,16 +172,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
: Promise.resolve([]),
]);
// FHIR records folded into every bundle format (fhir + package).
const fhirRecords = {
allergies: allergyRows.map(toAllergyDTO),
familyHistory: familyHistoryRows.map(toFamilyHistoryEntryDTO),
};
// Decrypt the KVNR fail-soft: a key-rotation gap on one row should
// never 500 the export — the cover/identifier just omits the value.
// The KVNR is encrypted at rest and is its own leaf. It is neither read nor
// decrypted unless the insurance leaf was chosen. The decrypt is fail-soft:
// a key-rotation gap on one row omits the value rather than 500-ing the
// export.
let insuranceNumber: string | null = null;
if (userRow?.insuranceNumberEncrypted) {
if (selection.has("INSURANCE") && userRow?.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(userRow.insuranceNumberEncrypted);
} catch {
@@ -188,43 +190,53 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
// AI summary is OUT of the clinical PDF by default — only included when
// the user explicitly toggled `includeAiSummary` AND a cached briefing
// text exists. Never reaches the FHIR bundle (not a clinical observation).
const aiSummary =
selection.includeAiSummary && userRow?.insightsCachedText
? extractAiSummaryText(userRow.insightsCachedText)
: null;
const stamp = new Date().toISOString().slice(0, 10);
const { t } = getServerTranslator(locale);
const artefact: ArtefactInputs = {
data,
insuranceNumber,
records: fhirRecords,
germanAtc,
includeCharts,
locale,
userTz,
timeFormat: userRow?.timeFormat ?? "AUTO",
t,
};
const profile = {
v: 2 as const,
leaves: [...selection.leaves],
format: input.format,
rangeDays: Math.min(365, Math.max(1, range.days)),
includeCharts,
};
await auditLog("health-record.export", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
format: selection.format,
format: input.format,
days: range.days,
startDate: range.start.toISOString(),
endDate: range.end.toISOString(),
charts: includeCharts,
aiSummary: aiSummary !== null,
// Section toggles only (booleans) — never the underlying values.
sections,
// The chosen leaf ids — never the values behind them.
leaves: profile.leaves,
},
});
if (selection.format === "fhir") {
const bundle = buildFhirDocumentBundle(
data,
{ insuranceNumber },
undefined,
{ germanAtc },
fhirRecords,
);
const json = JSON.stringify(bundle);
await rememberPracticeName(user.id, practiceName);
if (input.format === "fhir") {
const json = buildBundleJson(artefact);
await rememberChoices(user.id, practiceName, profile);
annotate({
meta: { format: "fhir", bytes: json.length, days: range.days },
meta: {
format: "fhir",
bytes: json.length,
days: range.days,
leafCount: profile.leaves.length,
},
});
return new NextResponse(json, {
status: 200,
@@ -236,23 +248,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
const { t } = getServerTranslator(locale);
const pdfBytes = renderDoctorReportPdfBytes(data, {
t,
locale,
userTz,
timeFormat: userRow?.timeFormat ?? "AUTO",
insuranceNumber,
includeCharts,
aiSummary,
});
// Both remaining formats (`pdf`, `package`) carry the rendered PDF, so the
// artefact exists from here on.
await rememberPracticeName(user.id, practiceName);
const pdfBytes = buildPdfBytes(artefact);
// Both remaining formats carry the rendered PDF, so the artefact exists from
// here on.
await rememberChoices(user.id, practiceName, profile);
if (selection.format === "pdf") {
if (input.format === "pdf") {
annotate({
meta: { format: "pdf", bytes: pdfBytes.byteLength, days: range.days },
meta: {
format: "pdf",
bytes: pdfBytes.byteLength,
days: range.days,
leafCount: profile.leaves.length,
},
});
const buffer = pdfBytes.slice().buffer;
return new NextResponse(buffer, {
@@ -266,25 +274,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
// format === "package": one zip holding the PDF + FHIR Bundle + a README.
const bundle = buildFhirDocumentBundle(
data,
{ insuranceNumber },
undefined,
{ germanAtc },
fhirRecords,
);
const readme = t("doctorReport.packageReadme");
const zipped = zipSync(
{
"report.pdf": pdfBytes,
"bundle.json": strToU8(JSON.stringify(bundle, null, 2)),
"README.txt": strToU8(readme),
},
{ level: 6 },
);
const zipped = buildPackageZip(artefact, pdfBytes);
annotate({
meta: { format: "package", bytes: zipped.byteLength, days: range.days },
meta: {
format: "package",
bytes: zipped.byteLength,
days: range.days,
leafCount: profile.leaves.length,
},
});
const zipBuffer = zipped.slice().buffer;
return new NextResponse(zipBuffer, {
@@ -297,26 +294,3 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
});
/**
* Extract plain summary text from the cached insights JSON blob. The cache
* is provider-shaped JSON; we pull the human-readable summary field if
* present, else fall back to a trimmed stringification. Always plain text
* (no markdown rendering — the PDF prints React-free string children).
*/
function extractAiSummaryText(raw: string): string | null {
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const candidate =
(typeof parsed.summary === "string" && parsed.summary) ||
(typeof parsed.text === "string" && parsed.text) ||
(typeof parsed.briefing === "string" && parsed.briefing) ||
null;
if (candidate) return candidate.trim().slice(0, 4000);
return null;
} catch {
// Not JSON — treat as plain text.
const trimmed = raw.trim();
return trimmed.length > 0 ? trimmed.slice(0, 4000) : null;
}
}