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

@@ -0,0 +1,29 @@
-- The owner's saved report selection: the leaf-id inclusion list plus the
-- format / range / charts choices the export panel restores.
ALTER TABLE "users" ADD COLUMN "report_selection_json" JSONB;
-- Retire the orphaned flat prefs column. No code outside its own route ever
-- read it — the route's docblock claimed the aggregator consulted it, which
-- was not true — and the route is removed in the same release.
ALTER TABLE "users" DROP COLUMN "doctor_report_prefs_json";
-- Retire the share-link FHIR scope columns. The face they described was never
-- built and their controls are gone. An inert column with a plausible name is
-- precisely what a later contributor rewires by mistake.
ALTER TABLE "clinician_share_links" DROP COLUMN "resource_types";
ALTER TABLE "clinician_share_links" DROP COLUMN "allow_fhir_api";
-- Revoke every live RECORD share link minted before the selection model. Their
-- frozen scope is `{}` or a flat prefs blob, both of which resolved to
-- "everything except mood and cycle" — a scope nobody chose. Downgrading them
-- to something narrower would still be a scope nobody chose, so they close and
-- the owner re-mints from the sharing list, which shows each one in a
-- pick-again state with its label, window and expiry preserved.
--
-- Documents-only shares are untouched: they carry an explicit empty report
-- scope and serve no health metric.
UPDATE "clinician_share_links"
SET "revoked_at" = NOW()
WHERE "revoked_at" IS NULL
AND "document_only" = false
AND "expires_at" > NOW();
+19 -24
View File
@@ -491,19 +491,22 @@ model User {
// `MoodReminderDispatch` table (one row per user + local date).
moodReminderEnabled Boolean @default(false) @map("mood_reminder_enabled")
// v1.4.25 W6c — Doctor-report section toggles.
// Shape:
// { "bp": boolean,
// "weight": boolean,
// "pulse": boolean,
// "bmi": boolean,
// "mood": boolean, // privacy-sensitive; default OFF
// "compliance": boolean,
// "sleep": boolean }
// Null = documented defaults (every section ON except mood). Mood is
// opt-in by default — the API layer drops mood entirely from the
// payload when the toggle is off so the data never leaves the DB row.
doctorReportPrefsJson Json? @map("doctor_report_prefs_json")
// The owner's saved report selection: the leaf-id inclusion list plus the
// format / range / charts choices the export panel restores. Shape:
// { "v": 2,
// "leaves": ["WEIGHT", "BLOOD_PRESSURE_SYS", "LAB_RESULTS", …],
// "format": "pdf" | "fhir" | "package",
// "rangeDays": number,
// "includeCharts": boolean }
// Null = never saved, which resolves to the EMPTY selection everywhere.
// Read by the export panel, the share-link create form, the FHIR REST face
// and the MCP doctor-visit surfaces.
//
// Replaces `doctorReportPrefsJson`, whose flat opt-out shape could not
// express "not chosen": an absent key resolved to included, so a caller who
// said nothing received the whole record. Membership is the whole contract
// here — absence is exclusion, and there is no defaults constant to drift.
reportSelectionJson Json? @map("report_selection_json")
// v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob.
//
@@ -5378,18 +5381,10 @@ model ClinicianShareLink {
rangeStart DateTime @map("range_start")
/// Window end. Null = rolling ("up to now"); set = frozen snapshot window.
rangeEnd DateTime? @map("range_end")
/// Section toggles (the `DoctorReportPrefs` shape the export uses). JSON.
/// The frozen report scope: `{ "v": 2, "leaves": [...] }`. Write-once at
/// mint, never widened. A blob this build cannot read resolves to the empty
/// selection, so a drifted row serves nothing rather than defaulting open.
sectionsJson Json @map("sections_json")
/// RETIRED. These two columns described a scoped FHIR face for a
/// share link that was never built: no route ever served FHIR (or any
/// machine-readable format) against an `hls_` token, so the create form's
/// toggle, its resource-type picker, the list badge and the API description
/// all promised reachability that did not exist. The controls are gone, the
/// create path no longer writes anything but the defaults, and no read path
/// touches either column — the values still on rows minted earlier are inert.
/// Kept as columns so no data is destroyed to undo a promise.
resourceTypes String[] @map("resource_types")
allowFhirApi Boolean @default(false) @map("allow_fhir_api")
/// v1.28.16 — the authoritative "documents-only" flag, frozen write-once at
/// create. When true the public view serves ONLY the attached documents and
/// the aggregator is never called, INDEPENDENT of `sectionsJson`. Decoupling
@@ -1,210 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
toJson: <T>(v: T) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPut(body: unknown): Request {
return new Request("http://localhost/api/auth/me/doctor-report-prefs", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/doctor-report-prefs", () => {
it("returns defaults when the column is null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/doctor-report-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: typeof DEFAULT_DOCTOR_REPORT_PREFS;
};
expect(env.data).toEqual(DEFAULT_DOCTOR_REPORT_PREFS);
expect(env.data.mood).toBe(false); // privacy default per the maintainer
});
});
describe("PUT /api/auth/me/doctor-report-prefs", () => {
// ── Case 1 — happy path ─────────────────────────────────────────────
it("persists the full shape and returns the canonical form", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = {
bp: true,
weight: true,
pulse: false,
bmi: true,
mood: true,
compliance: true,
sleep: false,
glucose: true,
cycle: false,
labs: true,
allergies: true,
familyHistory: false,
};
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
const env = (await res.json()) as { data: typeof body };
// The canonical shape is the full prefs object: keys the body omits fill
// from the defaults rather than being dropped.
const canonical = { ...DEFAULT_DOCTOR_REPORT_PREFS, ...body };
expect(env.data).toEqual(canonical);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { doctorReportPrefsJson: canonical },
});
});
// ── Case 2 — partial update layers over current persisted row ───────
it("merges a partial update over the persisted row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const current = {
bp: false,
weight: false,
pulse: false,
bmi: false,
mood: true,
compliance: false,
sleep: false,
glucose: false,
cycle: false,
labs: false,
allergies: false,
familyHistory: false,
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: current,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
// User only flips mood off — every other key keeps its persisted value.
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ mood: false }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: typeof current };
const merged = {
...DEFAULT_DOCTOR_REPORT_PREFS,
...current,
mood: false,
};
expect(env.data).toEqual(merged);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { doctorReportPrefsJson: merged },
});
});
// ── Case 3 — invalid shape ─────────────────────────────────────────
it("rejects a non-boolean value with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ bp: "nope" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// ── Case 4 — unauthenticated cross-user request ────────────────────
// the maintainer's spec mentions "cross-user 404" — the route layer returns 401
// when there is no session at all, which is the only "you are not the
// right user" surface available before requireAuth() resolves an
// identity. `prisma.user.update` must NOT fire in this case.
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ mood: false }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// ── Case 5 — audit trail (v1.4.25 W10 reconcile security M-3) ──────
it("writes an audit-log entry with the previous and new pref shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const current = {
bp: true,
weight: true,
pulse: true,
bmi: true,
mood: false,
compliance: true,
sleep: true,
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: current,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ mood: true }),
);
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledWith(
"user.doctor-report-prefs.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: current,
next: expect.objectContaining({ mood: true }),
}),
}),
);
});
});
@@ -1,109 +0,0 @@
/**
* v1.4.25 W6c — per-user Doctor-Report section toggles.
*
* GET /api/auth/me/doctor-report-prefs — returns the resolved prefs
* (defaults when the row is null).
* PUT /api/auth/me/doctor-report-prefs — merges the supplied partial
* shape over the current row
* and persists the canonical
* fully-resolved object so a
* future schema migration
* doesn't need to back-fill
* missing keys.
*
* Bearer-auth + cookie-auth both work via the shared `requireAuth()`
* helper. The doctor-report aggregator + PDF renderer both read this
* row at generation time so mood data is dropped at the aggregator
* layer when `mood = false` — the data never leaves the DB.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
import {
doctorReportPrefsSchema,
parseDoctorReportPrefs,
resolveDoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.doctor-report-prefs.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { doctorReportPrefsJson: true },
});
return apiSuccess(parseDoctorReportPrefs(row?.doctorReportPrefsJson));
});
export const PUT = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "doctor-report-prefs.body.invalid_json");
}
const parsed = doctorReportPrefsSchema.safeParse(body ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.doctor-report-prefs.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "doctor-report-prefs.body.invalid_shape");
}
// Merge the partial input over the current row (or defaults) so a
// dialog that only toggles `mood` doesn't have to re-state every other
// flag. Persist the canonical fully-resolved object so the column
// shape stays stable across future schema additions.
const current = await prisma.user.findUnique({
where: { id: user.id },
select: { doctorReportPrefsJson: true },
});
const merged = resolveDoctorReportPrefs(
current?.doctorReportPrefsJson,
parsed.data,
);
await prisma.user.update({
where: { id: user.id },
data: {
doctorReportPrefsJson: toJson(merged),
},
});
// v1.4.25 W10 reconcile (security M-3): record the previous + new
// pref shape in the audit log. Doctor-Report pref toggles
// (especially `mood: true`) widen the PDF surface; without an
// audit trail a silent compromise (or unintended client write)
// is invisible. Mirrors the timezone-route audit pattern.
await auditLog("user.doctor-report-prefs.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: current?.doctorReportPrefsJson ?? null,
next: merged,
},
});
annotate({
action: { name: "auth.me.doctor-report-prefs.put" },
meta: {
bp: merged.bp,
weight: merged.weight,
pulse: merged.pulse,
bmi: merged.bmi,
mood: merged.mood,
compliance: merged.compliance,
sleep: merged.sleep,
},
});
return apiSuccess(merged);
});
@@ -0,0 +1,109 @@
/**
* The owner's saved report selection.
*
* GET /api/auth/me/report-selection — the saved profile, or `null` when the
* account has never saved one.
* PUT /api/auth/me/report-selection — replace it.
*
* Replace, not merge. A selection is a list of what was chosen, so merging a
* partial over a stored one would re-introduce exactly the failure this shape
* removes: a leaf staying in because the caller did not mention it. The body
* states the whole scope or it is not a scope.
*
* Unknown leaf ids are refused with a 422 that names them. A client sending an
* id this build does not know is a contract mismatch the client has to see.
*
* Replaces `GET`/`PUT /api/auth/me/doctor-report-prefs`, whose column nothing
* outside that route ever read.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
import { savedReportProfileSchema } from "@/lib/report-selection/saved-profile";
import { selectionFromRequest } from "@/lib/report-selection/selection";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.report-selection.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { reportSelectionJson: true },
});
const parsed = savedReportProfileSchema.safeParse(row?.reportSelectionJson);
// A drifted or absent blob answers `null`, not a default profile: the panel
// then applies the named template and shows it, which is a visible act
// rather than a silent server choice.
return apiSuccess({ profile: parsed.success ? parsed.data : null });
});
export const PUT = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "report-selection.body.invalid_json");
}
const parsed = savedReportProfileSchema.safeParse(body ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.report-selection.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "report-selection.body.invalid_shape");
}
const minted = selectionFromRequest(parsed.data);
if (!minted.ok) {
annotate({
action: { name: "auth.me.report-selection.put.invalid" },
meta: { unknownLeaves: minted.error.unknownLeaves },
});
throw new HttpError(422, "report-selection.leaves.unknown");
}
// Persist the canonical ordering rather than the caller's, so two clients
// that chose the same scope store the same bytes.
const profile = {
v: parsed.data.v,
leaves: [...minted.selection.leaves],
format: parsed.data.format,
rangeDays: parsed.data.rangeDays,
includeCharts: parsed.data.includeCharts,
};
const current = await prisma.user.findUnique({
where: { id: user.id },
select: { reportSelectionJson: true },
});
await prisma.user.update({
where: { id: user.id },
data: { reportSelectionJson: toJson(profile) },
});
// The saved selection is what the FHIR REST face and the MCP doctor-visit
// surfaces replay for a caller that cannot ask a human, so widening it
// widens those too. That belongs in the audit trail.
await auditLog("user.report-selection.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: current?.reportSelectionJson ?? null,
next: profile,
},
});
annotate({
action: { name: "auth.me.report-selection.put" },
meta: { leafCount: profile.leaves.length, format: profile.format },
});
return apiSuccess({ profile });
});
+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;
}
}
+17 -9
View File
@@ -47,7 +47,10 @@ import {
FHIR_SEARCH_PARAMS,
} from "@/lib/fhir/rest";
import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link";
import { exportSectionsSchema } from "@/lib/validations/health-record-export";
import {
ALL_LEAF_IDS,
REPORT_GROUP_ORDER,
} from "@/lib/report-selection/catalogue";
export const dynamic = "force-dynamic";
@@ -113,17 +116,22 @@ export const GET = apiHandler(async () => {
operations: [FHIR_EVERYTHING_OPERATION],
searchParams: FHIR_SEARCH_PARAMS,
},
// Clinician share-link surface (v1.11): a scoped, time-boxed, revocable
// read-only link to the owner's record. Descriptor sourced from the
// canonical share validation + export-section constants. The share serves
// the rendered record view only; no `/api/fhir/*` route honours a share
// token yet (they require an authenticated `fhir:read` Bearer), so the
// share→FHIR face is not advertised as live.
// Clinician share-link surface: a scoped, time-boxed, revocable read-only
// link to the owner's record, sourced from the canonical share validation
// and the selection catalogue.
//
// `reportDownload` replaces the old `fhirApi: false`, which described a
// face that was never built. The record IS reachable in machine form now:
// as a download under the token, behind the same unlock the page uses,
// serving exactly the link's frozen selection. No bearer face, no new
// scope — `/api/fhir/*` still refuses a share token.
share: {
supported: true,
maxDays: SHARE_LINK_MAX_DAYS,
fhirApi: false,
sections: Object.keys(exportSectionsSchema.shape),
reportDownload: ["fhir", "pdf"],
selectionVersion: 2,
groups: REPORT_GROUP_ORDER,
leaves: ALL_LEAF_IDS,
},
});
});
+80 -12
View File
@@ -28,8 +28,16 @@ import {
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
import {
createShareLinkSchema,
SHARE_LINK_FORBIDDEN_LEAVES,
} from "@/lib/validations/clinician-share-link";
import {
EMPTY_REPORT_SELECTION,
reportSelectionSchema,
selectionFromRequest,
selectionToBlob,
} from "@/lib/report-selection/selection";
import {
generatePassphrase,
hashPassphrase,
@@ -66,6 +74,10 @@ const shareLinkSummarySelect = {
rangeStart: true,
rangeEnd: true,
documentOnly: true,
// Read to answer one owner-facing question: does this link's frozen scope
// predate the selection model? Never returned — only the boolean derived
// from it is.
sectionsJson: true,
passphraseHash: true,
expiresAt: true,
createdAt: true,
@@ -75,6 +87,22 @@ const shareLinkSummarySelect = {
_count: { select: { documents: true } },
} as const;
/**
* RETIRED, kept on the response only.
*
* These two fields described a scoped FHIR face for a share link that was
* never built. The request schema refuses them — sending either is a 422 —
* their columns are dropped, and nothing reads or honours them. They stay in
* the create and list responses, serving the constant `false` and `[]`,
* because the native client was given this response shape and a missing
* non-optional key fails a whole Swift decode. They come out once #70 in the
* native repository confirms the client does not decode them.
*/
const RETIRED_FHIR_SCOPE_FIELDS = {
allowFhirApi: false,
resourceTypes: [] as string[],
} as const;
/** Project a stored row to the safe owner-facing shape (never the token). */
function toSummary(row: {
id: string;
@@ -82,6 +110,7 @@ function toSummary(row: {
rangeStart: Date;
rangeEnd: Date | null;
documentOnly: boolean;
sectionsJson: unknown;
passphraseHash: string | null;
expiresAt: Date;
createdAt: Date;
@@ -98,6 +127,14 @@ function toSummary(row: {
// v1.28.16 — the frozen documents-only flag (owner-facing; the serve path
// gates on the column, not on "are all sections off?").
documentOnly: row.documentOnly,
// A record link whose frozen scope predates the selection model. The
// v1.32.37 migration revoked every one of them, because their scope was
// "everything except mood and cycle" — a scope nobody chose. The sharing
// list surfaces this so the owner sees why the link closed and can re-mint
// with the same label, window and expiry, stopped at the selection step.
needsReselection:
!row.documentOnly &&
!reportSelectionSchema.safeParse(row.sectionsJson).success,
// v1.18.7 — surface only WHETHER a passphrase guards the link, never the
// hash itself. Legacy (null-hash) links read `false` and stay ungated.
protected: row.passphraseHash !== null,
@@ -134,16 +171,41 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const input = parsed.data;
// v1.28.13 — a documents-only share carries NO report scope. Force the scope
// column to empty here (not just at the client) so a document link can never
// serve a single health metric, whatever the body tried to smuggle: an
// explicit all-OFF prefs blob, distinct from the `{}` that means "full record
// defaults". "Share this document" means the document, not the whole record.
// v1.28.13 — a documents-only share carries NO report scope. Force it empty
// here (not just at the client) so a document link can never serve a single
// health metric, whatever the body tried to smuggle. "Share this document"
// means the document, not the whole record.
const documentOnly = input.documentOnly === true;
const sectionsJson = documentOnly
? EMPTY_DOCTOR_REPORT_PREFS
: (input.sections ?? {});
annotate({ meta: { documentOnly } });
let selection = EMPTY_REPORT_SELECTION;
if (!documentOnly && input.selection) {
const minted = selectionFromRequest(input.selection);
if (!minted.ok) {
return apiError(
`Unknown selection leaf: ${minted.error.unknownLeaves.join(", ")}`,
422,
{ errorCode: "share-link.selection.unknown_leaf" },
);
}
selection = minted.selection;
}
// The insurance leaf is refused on a share link. The share view has never
// decrypted the insurance number, and refusing it here makes that structural
// rather than incidental: there is no path by which a link could carry it.
const forbidden = SHARE_LINK_FORBIDDEN_LEAVES.filter((leaf) =>
selection.has(leaf),
);
if (forbidden.length > 0) {
return apiError(`A share link cannot carry: ${forbidden.join(", ")}`, 422, {
errorCode: "share-link.selection.forbidden_leaf",
});
}
const sectionsJson = selectionToBlob(selection);
annotate({
meta: { documentOnly, leafCount: selection.leaves.length },
});
// Mint the 192-bit raw token, store only its HMAC hash.
const rawToken = `hls_${randomBytes(24).toString("hex")}`;
@@ -217,6 +279,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiSuccess(
{
...toSummary(created),
...RETIRED_FHIR_SCOPE_FIELDS,
token: rawToken,
passphrase: rawPassphrase,
shareUrl,
@@ -236,5 +299,10 @@ export const GET = apiHandler(async () => {
select: shareLinkSummarySelect,
});
return apiSuccess({ shareLinks: rows.map(toSummary) });
return apiSuccess({
shareLinks: rows.map((row) => ({
...toSummary(row),
...RETIRED_FHIR_SCOPE_FIELDS,
})),
});
});
+46
View File
@@ -0,0 +1,46 @@
/**
* GET /c/{token}/fhir — the share link's record as an HL7 FHIR R4 document
* Bundle, as a download.
*
* Behind the same unlock cookie the page and the document route use, serving
* exactly the link's frozen selection. The insurance number is never carried:
* the insurance leaf is refused at share-link creation, so there is nothing to
* decrypt here and the identity block is built without it.
*
* The gate itself lives in `@/lib/clinician-share/report-download` so this
* route and the PDF beside it cannot drift apart.
*/
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { resolveShareReportDownload } from "@/lib/clinician-share/report-download";
import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
type RouteParams = { params: Promise<{ token: string }> };
export const dynamic = "force-dynamic";
export const GET = apiHandler(
async (request: Request, { params }: RouteParams) => {
const { token } = await params;
const resolved = await resolveShareReportDownload(request, token, "fhir");
if (!resolved.ok) return resolved.response;
// No structured records ride a share bundle: allergies and family history
// reach the owner's own export through a separate read that this surface
// does not perform, so the bundle carries exactly the aggregated payload
// the page renders and nothing beside it.
const bundle = buildFhirDocumentBundle(resolved.report, {
insuranceNumber: null,
});
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(JSON.stringify(bundle), {
status: 200,
headers: {
"Content-Type": "application/fhir+json; charset=utf-8",
"Content-Disposition": `attachment; filename="healthlog-fhir-${stamp}.json"`,
"Cache-Control": "no-store",
},
});
},
);
+13 -25
View File
@@ -16,7 +16,7 @@
* string renders as escaped React text children (XSS posture).
*/
import type { Metadata } from "next";
import { cookies, headers } from "next/headers";
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import {
@@ -29,8 +29,7 @@ import {
verifyUnlockValue,
} from "@/lib/clinician-share/unlock-cookie";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { resolveShareViewLocale } from "@/lib/clinician-share/request-locale";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { ClinicianView } from "@/components/clinician/clinician-view";
import { ShareUnlockGate } from "@/components/clinician/share-unlock-gate";
@@ -45,20 +44,6 @@ export const metadata: Metadata = {
robots: { index: false, follow: false },
};
async function resolveLocale(): Promise<Locale> {
try {
const cookieStore = await cookies();
const cookieLocale = cookieStore.get("healthlog-locale")?.value;
if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {
return cookieLocale as Locale;
}
const headerList = await headers();
return parseLocaleFromAcceptLanguage(headerList.get("accept-language"));
} catch {
return defaultLocale;
}
}
export default async function ClinicianSharePage({
params,
}: {
@@ -81,7 +66,7 @@ export default async function ClinicianSharePage({
unlockCookieName(gate.tokenHash),
)?.value;
if (!verifyUnlockValue(unlockValue, gate.tokenHash)) {
const locale = await resolveLocale();
const locale = await resolveShareViewLocale();
const { t } = getServerTranslator(locale);
// Resolve the gate copy on the server (it owns the locale) into a plain,
// serializable object. A `t` function cannot cross the RSC boundary into
@@ -107,12 +92,15 @@ export default async function ClinicianSharePage({
// Issue #490 — period/expiry dates render in the share OWNER's (patient's)
// profile timezone so they agree with the patient-tz aggregation behind the
// stats and with the doctor-report PDF (never the container's zone).
const [{ report, sections, documents, documentOnly }, locale, ownerTimezone] =
await Promise.all([
loadShareViewData(context),
resolveLocale(),
resolveUserTimezone(context.ownerUserId),
]);
const [
{ report, selection, documents, documentOnly },
locale,
ownerTimezone,
] = await Promise.all([
loadShareViewData(context),
resolveShareViewLocale(),
resolveUserTimezone(context.ownerUserId),
]);
const { t } = getServerTranslator(locale);
return (
@@ -121,7 +109,7 @@ export default async function ClinicianSharePage({
label={context.label}
expiresAt={context.expiresAt.toISOString()}
report={report}
sections={sections}
selection={selection}
documents={documents}
documentOnly={documentOnly}
token={token}
+51
View File
@@ -0,0 +1,51 @@
/**
* GET /c/{token}/report.pdf — the share link's record as the clinical PDF.
*
* Behind the same unlock cookie the page and the document route use, serving
* exactly the link's frozen selection. Charts are embedded, matching the
* owner's own default; the practice-name cover line is whatever the owner
* froze onto the report, which for a share link is nothing.
*
* The gate itself lives in `@/lib/clinician-share/report-download` so this
* route and the bundle beside it cannot drift apart.
*/
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { resolveShareReportDownload } from "@/lib/clinician-share/report-download";
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveShareViewLocale } from "@/lib/clinician-share/request-locale";
type RouteParams = { params: Promise<{ token: string }> };
export const dynamic = "force-dynamic";
export const GET = apiHandler(
async (request: Request, { params }: RouteParams) => {
const { token } = await params;
const resolved = await resolveShareReportDownload(request, token, "pdf");
if (!resolved.ok) return resolved.response;
// The recipient's locale, not the owner's: this document is being read by
// the practice, and the page above it already renders in the same one.
const locale = await resolveShareViewLocale();
const { t } = getServerTranslator(locale);
const bytes = renderDoctorReportPdfBytes(resolved.report, {
t,
locale,
insuranceNumber: null,
includeCharts: true,
});
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(bytes.slice().buffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="healthlog-report-${stamp}.pdf"`,
"Content-Length": String(bytes.byteLength),
"Cache-Control": "no-store",
},
});
},
);
+51 -282
View File
@@ -1,26 +1,34 @@
/**
* v1.11.0 — clinician-view presentation (Epic C, C5).
* The public clinician view.
*
* A pure server component: it receives an already-resolved, owner-scoped
* {@link DoctorReportData} plus a server-side translator and renders a
* read-only clinical summary. NO client hooks, NO session, NO AI/coach, NO
* read-only clinical summary. NO client hooks, NO session, NO AI or coach, NO
* markdown — every value renders as escaped React text.
*
* Layout: provenance header → clinical vitals/labs → medications + adherence →
* a FENCED, muted wellness card carrying the load-bearing "descriptive, not a
* clinical assessment / not a diagnosis" disclaimer.
* Layout: provenance header (with the two machine-format downloads) → the
* measurement groups in selection order → glucose → medications and adherence
* → a FENCED, muted wellness card carrying the load-bearing "descriptive, not
* a clinical assessment" disclaimer → the attached documents.
*
* The section components live in `./report-sections`, the document list in
* `./documents-list`, the downloads in `./download-actions`.
*/
import { Download } from "lucide-react";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
import { formatBytes } from "@/components/documents/vault-utils";
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report/type-label-keys";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { makeFormatters } from "@/lib/format-locale";
import type { Locale } from "@/lib/i18n/config";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
import type { ReportSelection } from "@/lib/report-selection/selection";
import { DocumentEntry } from "./documents-list";
import { ShareDownloadActions } from "./download-actions";
import {
GlucoseSection,
MeasurementGroups,
MedicationsSection,
Section,
StatRow,
WellnessSection,
} from "./report-sections";
type Translate = (
key: string,
@@ -34,22 +42,22 @@ interface ClinicianViewProps {
/** ISO expiry instant — surfaced so the clinician knows the link lifetime. */
expiresAt: string;
/**
* The owner-scoped report payload, or `null` for a documents-only share
* (no report section enabled). When `null` NO health metric is rendered —
* only the header, disclaimer, and the attached documents.
* The owner-scoped report payload, or `null` for a documents-only share.
* When `null` NO health metric is rendered — only the header, the
* disclaimer, and the attached documents.
*/
report: DoctorReportData | null;
sections: DoctorReportPrefs;
/** The link's frozen selection, resolved. */
selection: ReportSelection;
/**
* v1.28.13 — a documents-only link. Hides the reporting-period line (there is
* no report) and, together with a `null` report, keeps every health section
* off the page. "Share this document" means the document, not the record.
* A documents-only link. Hides the reporting-period line (there is no
* report) and, together with a `null` report, keeps every health section off
* the page.
*/
documentOnly?: boolean;
/**
* The frozen document set on this link (metadata only — never bytes). Each
* entry points at the token-scoped serve route (`/c/<token>/d/<id>`), the
* one decrypt path. Empty when the owner shared no documents.
* entry points at the token-scoped serve route, the one decrypt path.
*/
documents?: ShareViewDocument[];
/** The raw share token from the path — used to build serve-route URLs. */
@@ -57,154 +65,19 @@ interface ClinicianViewProps {
/** Viewer locale (byte formatting only). Defaults to English. */
locale?: Locale;
/**
* Issue #490 — the share OWNER's (patient's) profile timezone. Period
* start/end and the expiry date render in this zone so the dates agree
* with the patient-tz aggregation behind the stats (and with the
* doctor-report PDF). Pre-fix the dates rendered via a bare
* `toLocaleDateString()` — the server CONTAINER's zone (≈ UTC), neither
* the patient's nor the viewer's, with US-English field order regardless
* of the resolved page locale.
* Issue #490 — the share OWNER's profile timezone. Period start/end and the
* expiry date render in this zone so the dates agree with the patient-tz
* aggregation behind the stats (and with the doctor-report PDF).
*/
timezone?: string;
}
/** Human-readable display per persisted wellness-score type (i18n key suffix). */
const WELLNESS_KEY: Record<string, string> = {
RECOVERY_SCORE: "recovery",
STRESS_SCORE: "stress",
STRAIN_SCORE: "strain",
};
/**
* Localised label for a measurement-type enum, reusing the doctor-report type
* key map so the clinician view reads in the viewer's locale alongside the rest
* of the page. A type with no key falls back to a humanised enum form
* (`BLOOD_PRESSURE_SYS` → "Blood pressure sys").
*/
function typeLabel(type: string, t: Translate): string {
const key = DOCTOR_REPORT_TYPE_LABEL_KEYS[type];
if (key) return t(key);
const lower = type.replace(/_/g, " ").toLowerCase();
return lower.charAt(0).toUpperCase() + lower.slice(1);
}
/** Render a single labelled stat row. */
function StatRow({ label, value }: { label: string; value: string }) {
return (
<div className="border-border/40 flex items-baseline justify-between gap-4 border-b py-2 last:border-0">
<span className="text-muted-foreground text-sm">{label}</span>
<span className="text-sm font-medium tabular-nums">{value}</span>
</div>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section className="border-border bg-card rounded-lg border p-4 md:p-6">
<h2 className="mb-3 text-base font-semibold">{title}</h2>
{children}
</section>
);
}
/**
* One shared document. Class A (inline) renders a preview straight at the
* token-scoped serve route — an `<img>` for raster images, a framed PDF
* otherwise; the proxy's share-serve CSP (`default-src 'none';
* frame-ancestors 'self'`) fences the framed context. Class B (attachment)
* offers a plain download link — the serve route answers it as an opaque
* `application/octet-stream` attachment. No bytes reach this component; only
* the metadata and the URL.
*/
function DocumentEntry({
t,
doc,
token,
locale,
}: {
t: Translate;
doc: ShareViewDocument;
token: string;
locale: string;
}) {
const serveUrl = `/c/${encodeURIComponent(token)}/d/${encodeURIComponent(doc.id)}`;
const title = doc.title ?? t("clinicianView.documents.untitled");
const Icon =
DOCUMENT_KIND_ICONS[doc.kind as InboundDocumentKindValue] ?? undefined;
const meta = [doc.documentDate, formatBytes(doc.byteSize, locale)].filter(
Boolean,
) as string[];
const isImage = doc.mimeType.startsWith("image/");
const isInline = doc.servingClass === "inline";
return (
<li className="border-border bg-card rounded-lg border p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
{Icon ? (
<Icon className="text-foreground mt-0.5 size-5 shrink-0" />
) : null}
<div className="min-w-0">
<p className="text-sm font-medium break-words">{title}</p>
{meta.length > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
{meta.join(" · ")}
</p>
) : null}
</div>
</div>
{/* Download is always available (inline or attachment) so the recipient
can keep a copy; the anchor hits the same token-scoped serve route. */}
<a
href={serveUrl}
download
className="border-border hover:bg-muted focus-visible:ring-ring/50 inline-flex min-h-9 shrink-0 items-center gap-1.5 rounded-md border px-3 text-xs font-medium focus-visible:ring-[3px] focus-visible:outline-none"
>
<Download className="size-3.5" aria-hidden />
{t("clinicianView.documents.download")}
</a>
</div>
{isInline ? (
<div className="mt-3">
{isImage ? (
// The served image is the frozen original with EXIF/GPS stripped on
// egress; it renders inline within the Class A carve-out.
// eslint-disable-next-line @next/next/no-img-element
<img
src={serveUrl}
alt={title}
className="border-border mx-auto max-h-[70vh] w-auto max-w-full rounded-md border"
/>
) : (
<iframe
src={serveUrl}
title={title}
className="border-border h-[70vh] max-h-[80vh] w-full rounded-md border"
/>
)}
</div>
) : (
<p className="text-muted-foreground mt-2 text-xs">
{t("clinicianView.documents.attachmentHint")}
</p>
)}
</li>
);
}
export function ClinicianView({
t,
label,
expiresAt,
report,
sections,
selection,
documents = [],
documentOnly = false,
token = "",
@@ -217,22 +90,6 @@ export function ClinicianView({
const fmtDate = (iso: string) => fmt.date(new Date(iso));
const fmtNum = (n: number) => Math.round(n * 100) / 100;
// A documents-only share carries no report — the aggregator is never called
// server-side, so `report` is null. Every health-derived list below stays
// empty by construction and only the attached documents render.
const measurementEntries = report
? Object.entries(report.stats).filter(([, s]) => s.count > 0)
: [];
const glucoseEntries = report
? Object.entries(report.glucoseStats).filter(([, s]) => s.count > 0)
: [];
const complianceEntries =
report && sections.compliance
? Object.entries(report.compliance).filter(([, c]) => c.total > 0)
: [];
const wellness = report?.wellnessScores?.filter((s) => s.count > 0) ?? [];
const medications = report?.medications ?? [];
return (
<main
id="main-content"
@@ -257,118 +114,30 @@ export function ClinicianView({
<p className="text-muted-foreground mt-1 text-xs">
{t("clinicianView.expires", { date: fmtDate(expiresAt) })}
</p>
{report && !documentOnly && token ? (
<ShareDownloadActions t={t} token={token} />
) : null}
<p className="border-border bg-muted/40 text-muted-foreground mt-3 rounded-md border p-3 text-xs">
{t("clinicianView.provenance")}
</p>
</header>
<div className="space-y-4">
{/* ── Clinical vitals ─────────────────────────────────────── */}
{measurementEntries.length > 0 ? (
<Section title={t("clinicianView.vitals")}>
{measurementEntries.map(([type, s]) => (
<StatRow
key={type}
label={typeLabel(type, t)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
})}
/>
))}
{report &&
report.bmi !== null &&
report.bmi !== undefined &&
sections.bmi ? (
<StatRow
label={t("clinicianView.bmi")}
value={String(fmtNum(report.bmi))}
/>
{report ? (
<>
<MeasurementGroups t={t} report={report} fmtNum={fmtNum} />
{report.bmi !== null && report.bmi !== undefined ? (
<Section title={t("clinicianView.bmiSection")}>
<StatRow
label={t("clinicianView.bmi")}
value={String(fmtNum(report.bmi))}
/>
</Section>
) : null}
</Section>
) : null}
{/* ── Labs (glucose) ──────────────────────────────────────── */}
{glucoseEntries.length > 0 ? (
<Section title={t("clinicianView.labs")}>
{glucoseEntries.map(([ctx, s]) => (
<StatRow
key={ctx}
label={t(`clinicianView.glucose.${ctx}`)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
})}
/>
))}
</Section>
) : null}
{/* ── Medications + adherence ─────────────────────────────── */}
{medications.length > 0 ? (
<Section title={t("clinicianView.medications")}>
{medications.map((med) => {
const comp = report?.compliance[med.name];
const rate =
sections.compliance && comp && comp.total > 0
? `${Math.round((comp.taken / comp.total) * 100)}%`
: null;
return (
<StatRow
key={med.name}
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
value={
rate
? t("clinicianView.adherence", { rate })
: t("clinicianView.noAdherence")
}
/>
);
})}
{complianceEntries
.filter(([name]) => !medications.some((m) => m.name === name))
.map(([name, c]) => (
<StatRow
key={name}
label={name}
value={t("clinicianView.adherence", {
rate: `${Math.round((c.taken / c.total) * 100)}%`,
})}
/>
))}
</Section>
) : null}
{/* ── Fenced wellness card (descriptive, NOT clinical) ────── */}
{wellness.length > 0 ? (
<section className="border-warning/50 bg-warning/5 rounded-lg border border-dashed p-4 md:p-6">
<h2 className="text-muted-foreground mb-1 text-base font-semibold">
{t("clinicianView.wellness.title")}
</h2>
<p className="text-muted-foreground mb-3 text-xs">
{t("clinicianView.wellness.disclaimer")}
</p>
<div>
{wellness.map((s) => (
<StatRow
key={s.type}
label={t(
`clinicianView.wellness.${WELLNESS_KEY[s.type] ?? "score"}`,
)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
})}
/>
))}
</div>
</section>
<GlucoseSection t={t} report={report} fmtNum={fmtNum} />
<MedicationsSection t={t} report={report} selection={selection} />
<WellnessSection t={t} report={report} fmtNum={fmtNum} />
</>
) : null}
{/* ── Shared documents ────────────────────────────────────── */}
@@ -0,0 +1,98 @@
/**
* The frozen document set on a share link, as a list of entries.
*
* No bytes reach this component: each entry points at the token-scoped serve
* route, which is the one decrypt path. Class A (inline) renders a preview
* straight at that route — an `<img>` for raster images, a framed PDF
* otherwise, fenced by the proxy's share-serve CSP. Class B is download-only.
*
* Split out of `clinician-view.tsx` with the selection rework.
*/
import { Download } from "lucide-react";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
import { formatBytes } from "@/components/documents/vault-utils";
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
type Translate = (
key: string,
vars?: Record<string, string | number>,
) => string;
export function DocumentEntry({
t,
doc,
token,
locale,
}: {
t: Translate;
doc: ShareViewDocument;
token: string;
locale: string;
}) {
const serveUrl = `/c/${encodeURIComponent(token)}/d/${encodeURIComponent(doc.id)}`;
const title = doc.title ?? t("clinicianView.documents.untitled");
const Icon =
DOCUMENT_KIND_ICONS[doc.kind as InboundDocumentKindValue] ?? undefined;
const meta = [doc.documentDate, formatBytes(doc.byteSize, locale)].filter(
Boolean,
) as string[];
const isImage = doc.mimeType.startsWith("image/");
const isInline = doc.servingClass === "inline";
return (
<li className="border-border bg-card rounded-lg border p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
{Icon ? (
<Icon className="text-foreground mt-0.5 size-5 shrink-0" />
) : null}
<div className="min-w-0">
<p className="text-sm font-medium break-words">{title}</p>
{meta.length > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
{meta.join(" · ")}
</p>
) : null}
</div>
</div>
{/* Download is always available (inline or attachment) so the recipient
can keep a copy; the anchor hits the same token-scoped serve route. */}
<a
href={serveUrl}
download
className="border-border hover:bg-muted focus-visible:ring-ring/50 inline-flex min-h-9 shrink-0 items-center gap-1.5 rounded-md border px-3 text-xs font-medium focus-visible:ring-[3px] focus-visible:outline-none"
>
<Download className="size-3.5" aria-hidden />
{t("clinicianView.documents.download")}
</a>
</div>
{isInline ? (
<div className="mt-3">
{isImage ? (
// The served image is the frozen original with EXIF/GPS stripped on
// egress; it renders inline within the Class A carve-out.
// eslint-disable-next-line @next/next/no-img-element
<img
src={serveUrl}
alt={title}
className="border-border mx-auto max-h-[70vh] w-auto max-w-full rounded-md border"
/>
) : (
<iframe
src={serveUrl}
title={title}
className="border-border h-[70vh] max-h-[80vh] w-full rounded-md border"
/>
)}
</div>
) : (
<p className="text-muted-foreground mt-2 text-xs">
{t("clinicianView.documents.attachmentHint")}
</p>
)}
</li>
);
}
@@ -0,0 +1,46 @@
/**
* The two machine-format downloads on a share link.
*
* A practice opens the link, unlocks it, and saves the file into their system.
* These serve exactly the link's frozen selection — the same object the page
* above renders from — behind the same unlock cookie the document route
* enforces. There is no bearer face and no new scope.
*
* Rendered only for a link that actually carries a record; a documents-only
* share has nothing to download here.
*/
import { Download } from "lucide-react";
type Translate = (
key: string,
vars?: Record<string, string | number>,
) => string;
export function ShareDownloadActions({
t,
token,
}: {
t: Translate;
token: string;
}) {
const base = `/c/${encodeURIComponent(token)}`;
const actions = [
{ href: `${base}/report.pdf`, label: t("clinicianView.downloadPdf") },
{ href: `${base}/fhir`, label: t("clinicianView.downloadFhir") },
];
return (
<div className="mt-3 flex flex-wrap gap-2" data-testid="share-downloads">
{actions.map((action) => (
<a
key={action.href}
href={action.href}
download
className="border-border hover:bg-muted focus-visible:ring-ring/50 inline-flex min-h-11 shrink-0 items-center gap-1.5 rounded-md border px-3 text-xs font-medium focus-visible:ring-[3px] focus-visible:outline-none sm:min-h-9"
>
<Download className="size-3.5" aria-hidden />
{action.label}
</a>
))}
</div>
);
}
@@ -0,0 +1,232 @@
/**
* The health sections of the clinician view, grouped the way the owner chose
* them.
*
* The measurement list used to be a flat `Object.entries(report.stats)` with a
* humanised-enum fallback for any type the ten-entry label map missed, so a
* clinician read rows called "Phq9 score" and "Audio exposure headphone" in
* one undifferentiated column. It now renders in the same twelve groups the
* selection panel shows, with the real localised labels, so the page and the
* PDF describe the record in the same order.
*
* A pure server component: no client hooks, no session, no markdown — every
* value renders as escaped React text.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import {
isStructuredLeafId,
REPORT_GROUPS,
} from "@/lib/report-selection/catalogue";
import type { ReportSelection } from "@/lib/report-selection/selection";
type Translate = (
key: string,
vars?: Record<string, string | number>,
) => string;
/** Human-readable display per persisted wellness-score type (i18n key suffix). */
const WELLNESS_KEY: Record<string, string> = {
RECOVERY_SCORE: "recovery",
STRESS_SCORE: "stress",
STRAIN_SCORE: "strain",
};
/** Render a single labelled stat row. */
export function StatRow({ label, value }: { label: string; value: string }) {
return (
<div className="border-border/40 flex items-baseline justify-between gap-4 border-b py-2 last:border-0">
<span className="text-muted-foreground text-sm">{label}</span>
<span className="text-sm font-medium tabular-nums">{value}</span>
</div>
);
}
export function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section className="border-border bg-card rounded-lg border p-4 md:p-6">
<h2 className="mb-3 text-base font-semibold">{title}</h2>
{children}
</section>
);
}
/**
* The measurement groups that carry data, in selection order. The aggregator
* has already applied the selection, so a type missing from `report.stats` is
* one that was withheld or has no reading.
*/
export function MeasurementGroups({
t,
report,
fmtNum,
}: {
t: Translate;
report: DoctorReportData;
fmtNum: (n: number) => number;
}) {
const groups = REPORT_GROUPS.map((group) => ({
labelKey: group.labelKey,
rows: group.leaves
.filter((leaf): leaf is MeasurementType => !isStructuredLeafId(leaf))
.map((type) => ({ type, stat: report.stats[type] }))
.filter((row) => row.stat !== undefined && row.stat.count > 0),
})).filter((group) => group.rows.length > 0);
if (groups.length === 0) return null;
return (
<>
{groups.map((group) => (
<Section key={group.labelKey} title={t(group.labelKey)}>
{group.rows.map(({ type, stat }) => (
<StatRow
key={type}
label={t(MEASUREMENT_TYPE_LABEL_KEYS[type])}
value={t("clinicianView.statSummary", {
latest: fmtNum(stat!.latest),
avg: fmtNum(stat!.avg),
min: fmtNum(stat!.min),
max: fmtNum(stat!.max),
})}
/>
))}
</Section>
))}
</>
);
}
/** Per-context glucose stats. Present only when the glucose panel was chosen. */
export function GlucoseSection({
t,
report,
fmtNum,
}: {
t: Translate;
report: DoctorReportData;
fmtNum: (n: number) => number;
}) {
const entries = Object.entries(report.glucoseStats).filter(
([, s]) => s.count > 0,
);
if (entries.length === 0) return null;
return (
<Section title={t("clinicianView.labs")}>
{entries.map(([ctx, s]) => (
<StatRow
key={ctx}
label={t(`clinicianView.glucose.${ctx}`)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
})}
/>
))}
</Section>
);
}
/** The medication list with the adherence rate beside each drug. */
export function MedicationsSection({
t,
report,
selection,
}: {
t: Translate;
report: DoctorReportData;
selection: ReportSelection;
}) {
const medications = report.medications ?? [];
const complianceOn = selection.has("MEDICATION_COMPLIANCE");
const complianceEntries = complianceOn
? Object.entries(report.compliance).filter(([, c]) => c.total > 0)
: [];
if (medications.length === 0 && complianceEntries.length === 0) return null;
return (
<Section title={t("clinicianView.medications")}>
{medications.map((med) => {
const comp = report.compliance[med.name];
const rate =
complianceOn && comp && comp.total > 0
? `${Math.round((comp.taken / comp.total) * 100)}%`
: null;
return (
<StatRow
key={med.name}
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
value={
rate
? t("clinicianView.adherence", { rate })
: t("clinicianView.noAdherence")
}
/>
);
})}
{complianceEntries
.filter(([name]) => !medications.some((m) => m.name === name))
.map(([name, c]) => (
<StatRow
key={name}
label={name}
value={t("clinicianView.adherence", {
rate: `${Math.round((c.taken / c.total) * 100)}%`,
})}
/>
))}
</Section>
);
}
/**
* The fenced wellness card. Descriptive composites, not clinical assessments —
* the disclaimer is load-bearing and sits above the numbers, not under them.
*/
export function WellnessSection({
t,
report,
fmtNum,
}: {
t: Translate;
report: DoctorReportData;
fmtNum: (n: number) => number;
}) {
const wellness = report.wellnessScores?.filter((s) => s.count > 0) ?? [];
if (wellness.length === 0) return null;
return (
<section className="border-warning/50 bg-warning/5 rounded-lg border border-dashed p-4 md:p-6">
<h2 className="text-muted-foreground mb-1 text-base font-semibold">
{t("clinicianView.wellness.title")}
</h2>
<p className="text-muted-foreground mb-3 text-xs">
{t("clinicianView.wellness.disclaimer")}
</p>
<div>
{wellness.map((s) => (
<StatRow
key={s.type}
label={t(
`clinicianView.wellness.${WELLNESS_KEY[s.type] ?? "score"}`,
)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
})}
/>
))}
</div>
</section>
);
}
+132
View File
@@ -0,0 +1,132 @@
/**
* The shared gate behind a share link's machine-format downloads.
*
* A practice opens the link, unlocks it, and saves the file into their system.
* That is the thing a bearer-authenticated API was supposed to serve and never
* did, so this serves it as a download instead: no new credential type, no new
* scope, no mint path that does not exist.
*
* The gate is the document route's, step for step — the same live check, the
* same token-scoped unlock cookie, a rate limit before any aggregation, the
* same counter bump, the same blunt 404 for every miss class. It differs in
* one number: generating a bundle is far more expensive than serving a stored
* blob, so it gets its own smaller bucket rather than sharing the document
* route's budget.
*
* Scope cannot exceed the page's: both resolve the SAME frozen `sectionsJson`
* through `selectionFromStoredBlob`, and the insurance leaf is refused at
* share-link creation, so the insurance number cannot reach this path at all.
*/
import { cookies } from "next/headers";
import { apiError } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { getClientIp } from "@/lib/api-response";
import {
resolveShareGateState,
resolveShareToken,
} from "@/lib/clinician-share/resolve-share-token";
import {
unlockCookieName,
verifyUnlockValue,
} from "@/lib/clinician-share/unlock-cookie";
import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import type { ReportSelection } from "@/lib/report-selection/selection";
/**
* 20 per hour per link. A practice saves the record once, maybe twice; a
* leaked token cannot turn the aggregator into a grinder. Deliberately a
* separate bucket from the document route's 300/h and the unlock route's
* 10/10min, per the `<surface>:${identifier}` convention.
*/
const REPORT_DOWNLOAD_LIMIT_PER_HOUR = 20;
const REPORT_DOWNLOAD_WINDOW_MS = 60 * 60 * 1000;
/** One blunt 404 for every miss class — a probe distinguishes nothing. */
function notFound() {
return apiError("Not found", 404, {
errorCode: "share-link.report.notFound",
});
}
export type ReportDownloadResult =
| { ok: true; report: DoctorReportData; selection: ReportSelection }
| { ok: false; response: Response };
/**
* Run the gate and produce the link's report payload, or the response to
* return instead. A documents-only link — or one whose frozen scope this build
* cannot read — has no record to download and gets the same flat 404.
*/
export async function resolveShareReportDownload(
request: Request,
token: string,
format: "fhir" | "pdf",
): Promise<ReportDownloadResult> {
annotate({ action: { name: "share.report.download" } });
// 1. Live-gate check first (no counter bump). Malformed / unknown / revoked
// / expired all collapse to the same flat 404.
const gate = await resolveShareGateState(token);
if (!gate) return { ok: false, response: notFound() };
// 2. Passphrase gate: a protected link serves nothing until the recipient
// holds the short-lived, token-scoped unlock cookie. This route lives
// under `/c/<token>` so the browser sends that cookie automatically.
if (gate.passphraseHash !== null) {
const cookieStore = await cookies();
const unlockValue = cookieStore.get(
unlockCookieName(gate.tokenHash),
)?.value;
if (!verifyUnlockValue(unlockValue, gate.tokenHash)) {
return { ok: false, response: notFound() };
}
}
// 3. Per-link rate limit BEFORE any aggregation.
const rl = await checkRateLimit(
`report-download:${gate.tokenHash}`,
REPORT_DOWNLOAD_LIMIT_PER_HOUR,
REPORT_DOWNLOAD_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests. Try again later.", 429, {
errorCode: "share-link.report.rateLimited",
});
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return { ok: false, response };
}
// 4. Resolve the full share context. Revocation and expiry are re-enforced
// here and the access counters are bumped by the same path that bumps
// them for the page.
const context = await resolveShareToken(token);
if (!context) return { ok: false, response: notFound() };
// 5. The link's own frozen scope, resolved exactly as the page resolves it,
// so the download can never be wider than what the page renders.
const view = await loadShareViewData(context);
if (view.documentOnly || !view.report) {
return { ok: false, response: notFound() };
}
await auditLog("share-link.report.download", {
userId: context.ownerUserId,
ipAddress: getClientIp(request),
details: {
shareLinkId: context.shareLinkId,
format,
leafCount: view.selection.leaves.length,
},
});
annotate({
meta: { format, leafCount: view.selection.leaves.length },
});
return { ok: true, report: view.report, selection: view.selection };
}
+26
View File
@@ -0,0 +1,26 @@
/**
* The viewer's locale on a share surface.
*
* A share link has no session, so the locale comes from the in-app cookie the
* recipient's own browser may carry, then from Accept-Language, then from the
* default. Extracted from the share page so the PDF download beside it renders
* in the same language as the page it was downloaded from.
*/
import { cookies, headers } from "next/headers";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
export async function resolveShareViewLocale(): Promise<Locale> {
try {
const cookieStore = await cookies();
const cookieLocale = cookieStore.get("healthlog-locale")?.value;
if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {
return cookieLocale as Locale;
}
const headerList = await headers();
return parseLocaleFromAcceptLanguage(headerList.get("accept-language"));
} catch {
return defaultLocale;
}
}
+14 -17
View File
@@ -22,10 +22,10 @@ import { isModuleEnabled } from "@/lib/modules/gate";
import { servingClassFor } from "@/lib/documents/upload-policy";
import type { DocumentServingClass } from "@/lib/documents/upload-policy";
import {
hasAnyReportSection,
type DoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
import { resolveStoredReportSections } from "@/lib/validations/health-record-export";
isEmptySelection,
selectionFromStoredBlob,
type ReportSelection,
} from "@/lib/report-selection/selection";
import type { ShareContext } from "@/lib/clinician-share/resolve-share-token";
/**
@@ -59,8 +59,8 @@ export interface ShareViewData {
* the recipient sees only the attached documents.
*/
report: DoctorReportData | null;
/** The resolved section toggles (mood opt-in, defaults otherwise). */
sections: DoctorReportPrefs;
/** The link's frozen selection, resolved. Empty when it carries no scope. */
selection: ReportSelection;
/** v1.28 — the hand-picked documents on this link (metadata only). */
documents: ShareViewDocument[];
/**
@@ -94,12 +94,11 @@ function frozenRange(context: ShareContext): DoctorReportRange {
export async function loadShareViewData(
context: ShareContext,
): Promise<ShareViewData> {
// The stored `sectionsJson` may be the GROUPED export shape (what the create
// schema accepts) or the FLAT doctor-report shape (documents-only / legacy).
// `resolveStoredReportSections` folds either down correctly — reading a
// grouped blob through the flat parser would silently drop every grouped
// toggle and default the section back ON, re-widening a scope the owner froze.
const sections = resolveStoredReportSections(context.sectionsJson);
// The link's frozen scope. A blob this build cannot read — a legacy shape, a
// hand-edited row — resolves to the EMPTY selection, so the link serves
// nothing rather than defaulting open. Every such link was revoked by the
// v1.32.37 migration; this is the second lock behind that.
const selection = selectionFromStoredBlob(context.sectionsJson);
const range = frozenRange(context);
// A documents-only share never aggregates — no health metric is read from the
@@ -128,18 +127,16 @@ export async function loadShareViewData(
"doctorReport",
);
const documentOnly =
context.documentOnly ||
!hasAnyReportSection(sections) ||
!reportModuleEnabled;
context.documentOnly || isEmptySelection(selection) || !reportModuleEnabled;
const [report, documents] = await Promise.all([
documentOnly
? Promise.resolve(null)
: collectDoctorReportData(context.ownerUserId, range, { sections }),
: collectDoctorReportData(context.ownerUserId, range, selection),
loadShareDocuments(context),
]);
return { report, sections, documents, documentOnly };
return { report, selection, documents, documentOnly };
}
/**
File diff suppressed because it is too large. Load diff
+36 -58
View File
@@ -10,7 +10,13 @@
* `%PDF-` byte stream in both environments.
*/
import { jsPDF } from "jspdf";
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "./doctor-report/type-label-keys";
import type { MeasurementType } from "@/generated/prisma/client";
import {
isStructuredLeafId,
REPORT_GROUPS,
} from "./report-selection/catalogue";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "./measurements/type-label-keys";
import { getUnitForType } from "./validations/measurement";
import {
makeFormatters,
DISPLAY_TIMEZONE,
@@ -154,20 +160,13 @@ export interface DoctorReportRenderOptions {
* Defaults to `true`. Off produces a compact text-only report.
*/
includeCharts?: boolean;
/**
* v1.7.0 — optional AI summary text. OUT of the clinical PDF by
* default; rendered ONLY when the user explicitly opts in, under a
* clearly-labelled "AI summary — not clinically validated" heading.
* Null/undefined/empty omits the section entirely.
*/
aiSummary?: string | null;
}
// Per-vital label keys live in a jsPDF-free module so leaf consumers (the
// public clinician share view) can import the map without dragging the PDF
// renderer into their graph. Re-exported here so the PDF core stays the
// single import surface its existing callers already use.
export { DOCTOR_REPORT_TYPE_LABEL_KEYS };
// Per-type label keys live in a lucide-free module so leaf consumers — the
// public clinician share view, this renderer — can import the map without
// dragging icon components into their graph. Re-exported here so the PDF core
// stays the single import surface its existing callers already use.
export { MEASUREMENT_TYPE_LABEL_KEYS };
export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
WEIGHT: "kg",
@@ -183,50 +182,26 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
};
/**
* Vital types rendered in the main vitals table. Body composition
* (TOTAL_BODY_WATER, BONE_MASS) ships alongside body fat — Withings
* smart scales report all three together. SpO2 (Withings ScanWatch type
* 54, HealthKit, n8n / Health Connect) is rendered last in the same
* table for clinical readability. Glucose ships separately via
* per-context `glucoseStats`. SLEEP_DURATION rides this table too: the
* `sleep` section toggle is default-ON and the data layer reconstructs a
* per-night time-asleep total (in minutes) for exactly this row — the
* render loop converts it to hours so value and unit (`h`) agree, matching
* the FHIR sleep Observation. Activity (steps) stays excluded from a
* clinical-focused report.
* The measurement table is driven by the selection, not by a whitelist.
*
* v1.10.0 — computed scores (WX-C). The server-derived `*_SCORE` types
* (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE) are NOT clinical vitals:
* they are 0–100 composites recomputed nightly from the underlying signals
* and carry a "descriptive, not a clinical assessment" disclaimer. They stay
* out of this table by design (paired with `PDF_VITAL_EXCLUSIONS` in
* `measurement-type-enum-coverage.test.ts`).
* It used to be a fixed nine types, which meant a user could select resting
* heart rate, see it in the FHIR bundle and in the share view, and not find it
* in the PDF — a per-format divergence on a document whose whole point is that
* every format describes the same record. The table now prints every selected
* measurement leaf that has data, sub-headed by the same twelve groups the
* selection panel shows, so the panel reads as a table of contents for the
* artefact.
*
* v1.17.1 — Oura coverage completion. `SLEEP_SCORE` (a nightly derived
* composite) and `BODY_TEMPERATURE_DEVIATION` (a signed baseline offset, not an
* absolute reading) are excluded for the same reason as the other derived /
* lifestyle signals — they are descriptive, not measured clinical vitals.
*
* v1.19.0 — Oura resilience. `RESILIENCE` is an ordinal-encoded categorical
* band (limited=1 … exceptional=5), a derived recovery composite — descriptive,
* not a measured clinical vital — so it is excluded too.
*
* v1.27.9 — WHO-5 / SCI screening totals. `WHO5_SCORE` + `SCI_SCORE` follow
* the PHQ-9 / GAD-7 precedent: opt-in, derived from an in-app questionnaire,
* excluded from the vitals table (they ride the module-gated mental-health
* export section instead, see `doctor-report-data.ts`).
* The aggregator has already applied the selection, so `data.stats` carries
* exactly the admitted leaves and nothing here can over-serve.
*/
export const DOCTOR_REPORT_VITAL_TYPES = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"TOTAL_BODY_WATER",
"BONE_MASS",
"OXYGEN_SATURATION",
"SLEEP_DURATION",
] as const;
export const DOCTOR_REPORT_VITAL_GROUPS = REPORT_GROUPS.map((group) => ({
id: group.id,
labelKey: group.labelKey,
types: group.leaves.filter(
(leaf): leaf is MeasurementType => !isStructuredLeafId(leaf),
),
})).filter((group) => group.types.length > 0);
/**
* Render the doctor report into a `jsPDF` instance.
@@ -247,7 +222,6 @@ export function buildDoctorReportPdfDocument(
timeFormat = "AUTO",
insuranceNumber = null,
includeCharts = true,
aiSummary = null,
} = options;
const formatters = makeFormatters(locale, userTz, timeFormat);
const num = (value: number, decimals = 1) =>
@@ -261,7 +235,12 @@ export function buildDoctorReportPdfDocument(
if (staticUnit === null && type === "ACTIVITY_STEPS") {
return t("doctorReport.unitSteps");
}
return staticUnit ?? "";
if (staticUnit !== undefined && staticUnit !== null) return staticUnit;
// Every other type falls back to the canonical unit the measurement
// validation layer already records. A type with no recorded unit prints
// none — an absent unit is absent, not a guessed symbol.
const canonical = getUnitForType(type);
return canonical === "unknown" ? "" : canonical;
};
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
@@ -298,14 +277,13 @@ export function buildDoctorReportPdfDocument(
now,
insuranceNumber,
includeCharts,
aiSummary,
footerTz,
margin,
pageWidth,
pageHeight,
contentMaxY,
tableBottomMargin,
vitalTypes: DOCTOR_REPORT_VITAL_TYPES,
vitalGroups: DOCTOR_REPORT_VITAL_GROUPS,
unitFor,
ensureSpace,
};
@@ -14,19 +14,8 @@ export function buildClinicalRecordsNotesSection(
context: DoctorReportPdfRenderContext,
state: DoctorReportPdfCursorState,
): DoctorReportPdfCursorState {
const {
doc,
data,
t,
num,
fmtDate,
aiSummary,
margin,
pageWidth,
contentMaxY,
tableBottomMargin,
ensureSpace,
} = context;
const { doc, data, t, num, fmtDate, margin, tableBottomMargin, ensureSpace } =
context;
let y = state.y;
// v1.17.1 — structured lab-results section. Populated when the `labs`
@@ -298,49 +287,5 @@ export function buildClinicalRecordsNotesSection(
.finalY + 8;
}
// v1.7.0 — optional AI summary. OUT of the clinical PDF by default;
// rendered ONLY when the user explicitly opted in. Clearly labelled and
// flagged as not clinically validated so a physician never mistakes it
// for a machine-generated diagnosis.
const aiText = typeof aiSummary === "string" ? aiSummary.trim() : "";
if (aiText.length > 0) {
// Keep the heading + the first disclaimer line together.
y = ensureSpace(y, 5 + 4 + 4.5);
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.setTextColor(120, 80, 0);
doc.text(t("doctorReport.aiSummaryTitle"), margin, y);
y += 5;
doc.setFontSize(8);
doc.setFont("helvetica", "italic");
doc.setTextColor(150, 110, 40);
const disclaimer = doc.splitTextToSize(
t("doctorReport.aiSummaryDisclaimer"),
pageWidth - 2 * margin,
);
for (const line of disclaimer) {
if (y + 4 > contentMaxY) {
doc.addPage();
y = margin;
}
doc.text(line, margin, y);
y += 4;
}
y += 2;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.setTextColor(60, 60, 60);
const wrapped = doc.splitTextToSize(aiText, pageWidth - 2 * margin);
for (const line of wrapped) {
if (y + 4.5 > contentMaxY) {
doc.addPage();
y = margin;
}
doc.text(line, margin, y);
y += 4.5;
}
y += 4;
}
return pdfCursorState(doc, y);
}
@@ -2,7 +2,8 @@ import {
adherenceRatePercent,
type DoctorReportData,
} from "../doctor-report-data";
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "../doctor-report/type-label-keys";
import type { MeasurementType } from "@/generated/prisma/client";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
import type {
DoctorReportNumberFormatter,
DoctorReportTranslator,
@@ -76,7 +77,7 @@ export function buildClinicalSummaryLines(
const arrow = trendArrow(series.map((point) => point.value));
lines.push(
t("doctorReport.summaryTrend", {
label: t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? ""),
label: t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
latest: num(stat.latest, 1),
arrow,
}),
@@ -1,7 +1,8 @@
import type { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
import { convertGlucose, resolveGlucoseUnit } from "../glucose";
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "../doctor-report/type-label-keys";
import type { MeasurementType } from "@/generated/prisma/client";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
import {
getBmiClassificationKey,
getBpClassificationKey,
@@ -148,6 +149,30 @@ function drawSparkline(
return chartBottom + axisLabelHeight + 2;
}
/** A body row: either six cells, or a full-width group sub-heading. */
type VitalTableRow =
| string[]
| [{ content: string; colSpan: number; styles: Record<string, unknown> }];
/**
* A full-width sub-heading row naming the group the rows under it belong to.
* Bold on a light fill, no colour beyond the table's own greys — the heading
* carries meaning, not decoration.
*/
function groupSubHeadRow(label: string): VitalTableRow {
return [
{
content: label,
colSpan: 6,
styles: {
fontStyle: "bold",
fillColor: [242, 242, 242],
textColor: [30, 30, 30],
},
},
];
}
export function buildMeasurementsChartsSection(
context: DoctorReportPdfRenderContext,
state: DoctorReportPdfCursorState,
@@ -165,7 +190,7 @@ export function buildMeasurementsChartsSection(
tableBottomMargin,
ensureSpace,
unitFor,
vitalTypes,
vitalGroups,
} = context;
let y = state.y;
@@ -177,37 +202,22 @@ export function buildMeasurementsChartsSection(
doc.text(t("doctorReport.vitalsTitle"), margin, y);
y += 6;
const vitalRows: string[][] = [];
for (const type of vitalTypes) {
const s = data.stats[type];
if (!s) continue;
const unit = unitFor(type);
// SLEEP_DURATION stats are per-night asleep totals in MINUTES (the data
// layer reconstructs them for exactly this row); the vitals unit is hours
// (`h`), so convert to keep value and unit consistent — the same hours
// value the FHIR sleep Observation emits.
const conv =
type === "SLEEP_DURATION" ? (v: number) => v / 60 : (v: number) => v;
vitalRows.push([
t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? ""),
`${num(conv(s.latest))} ${unit}`.trim(),
`${num(conv(s.avg))} ${unit}`.trim(),
num(conv(s.min)),
num(conv(s.max)),
String(s.count),
]);
}
// One sub-headed block per selection group, in the same order the panel
// shows them, carrying every selected leaf that has data. The aggregator has
// already applied the selection, so a leaf missing from `data.stats` is a
// leaf that was withheld or has no reading — the table cannot over-serve.
const vitalRows: VitalTableRow[] = [];
// Per-context glucose rows. Values stored canonically in mg/dL — convert to
// the user's display unit. Reference ranges come from the server-side
// `getEffectiveRange()` (already baked into `data.glucoseRanges`).
// the user's display unit. They belong under the glucose group heading with
// the raw series, not appended after the whole table.
const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
const glucoseContextRows: string[][] = [];
for (const ctx of GLUCOSE_CONTEXTS) {
const s = data.glucoseStats?.[ctx];
if (!s) continue;
const conv = (v: number) => convertGlucose(v, glucoseUnit);
vitalRows.push([
glucoseContextRows.push([
t(GLUCOSE_LABEL_KEYS[ctx]),
`${num(conv(s.latest))} ${glucoseUnit}`.trim(),
`${num(conv(s.avg))} ${glucoseUnit}`.trim(),
@@ -217,6 +227,33 @@ export function buildMeasurementsChartsSection(
]);
}
for (const group of vitalGroups) {
const groupRows: string[][] = [];
for (const type of group.types) {
const s = data.stats[type];
if (!s) continue;
const unit = unitFor(type);
// SLEEP_DURATION stats are per-night asleep totals in MINUTES (the data
// layer reconstructs them for exactly this row); the vitals unit is
// hours (`h`), so convert to keep value and unit consistent — the same
// hours value the FHIR sleep Observation emits.
const conv =
type === "SLEEP_DURATION" ? (v: number) => v / 60 : (v: number) => v;
groupRows.push([
t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
`${num(conv(s.latest))} ${unit}`.trim(),
`${num(conv(s.avg))} ${unit}`.trim(),
num(conv(s.min)),
num(conv(s.max)),
String(s.count),
]);
}
if (group.id === "glucose") groupRows.push(...glucoseContextRows);
if (groupRows.length === 0) continue;
vitalRows.push(groupSubHeadRow(t(group.labelKey)));
vitalRows.push(...groupRows);
}
if (vitalRows.length > 0) {
autoTable(doc, {
startY: y,
@@ -394,7 +431,9 @@ export function buildMeasurementsChartsSection(
// A chart never tears across a page boundary — break before drawing
// it whole.
y = ensureSpace(y, SPARKLINE_HEIGHT + 4);
const label = t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? "");
const label = t(
MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? "",
);
y = drawSparkline(doc, {
x: margin,
y,
+10 -2
View File
@@ -27,14 +27,22 @@ export interface DoctorReportPdfRenderContext {
now: Date;
insuranceNumber: string | null;
includeCharts: boolean;
aiSummary: string | null;
footerTz: string;
margin: number;
pageWidth: number;
pageHeight: number;
contentMaxY: number;
tableBottomMargin: number;
vitalTypes: readonly string[];
/**
* The measurement table's groups, in selection order. Each carries the
* group's label key and its measurement types; a group whose types produced
* no rows is skipped at render time.
*/
vitalGroups: ReadonlyArray<{
id: string;
labelKey: string;
types: readonly string[];
}>;
unitFor: (type: string) => string;
ensureSpace: (current: number, needed: number) => number;
}
-177
View File
@@ -1,177 +0,0 @@
/**
* Which measurement types a doctor-report selection admits.
*
* Extracted from `doctor-report-data.ts` alongside the gating fix that made
* the export panel's controls real. The aggregator is a long file that reads
* a dozen data domains; the question "does this selection admit this
* measurement type?" is a separate job with a separate shape, it is the one
* the export panel's controls resolve to, and it is the piece the grouped
* selection work will grow. It answers to two independent gates, and both
* must pass:
*
* - the per-report SECTION toggle the user chose for this artefact, and
* - the app-wide MODULE switch that decides whether the domain exists here
* at all.
*
* Both maps and the filter that reads them live together so a type can never
* be gated in the query and ungated in the output, or the reverse.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type { ModuleKey } from "@/lib/modules/gate";
import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
/**
* Per-measurement-type → section-toggle mapping. This is the one enforcement
* point for "the user deselected this data": `filterMeasurementKeys` strips a
* gated type from `measurements` + `stats`, and PDF, FHIR bundle, package,
* clinician share view and MCP all read those two maps — so one entry here
* gates every format at once.
*
* The map used to hold five types, which is why the export panel
* could render controls for SpO₂, body fat, body composition, resting heart
* rate, HRV, VO₂max, steps and distance and change nothing at all. Every
* control the panel renders now resolves to a section key that appears here (or
* to a structured section gated by name in the aggregator body), and the
* control-gating guard test fails the build if that stops being true.
*
* Glucose DOES carry a `glucose` section toggle, but `BLOOD_GLUCOSE` is gated
* at query-exclusion time (mirroring sleep) and its panel collapses via
* `glucoseEnabled`, so it does not ride this map.
*/
export const MEASUREMENT_TYPE_SECTION: Record<string, keyof DoctorReportPrefs> =
{
BLOOD_PRESSURE_SYS: "bp",
BLOOD_PRESSURE_DIA: "bp",
WEIGHT: "weight",
PULSE: "pulse",
SLEEP_DURATION: "sleep",
OXYGEN_SATURATION: "oxygenSaturation",
BODY_FAT: "bodyFat",
// The scale-derived composition family: everything a body-composition scale
// reports beyond the plain weight and fat percentage.
TOTAL_BODY_WATER: "bodyComposition",
BONE_MASS: "bodyComposition",
FAT_MASS: "bodyComposition",
FAT_FREE_MASS: "bodyComposition",
MUSCLE_MASS: "bodyComposition",
LEAN_BODY_MASS: "bodyComposition",
VISCERAL_FAT: "bodyComposition",
RESTING_HEART_RATE: "restingHeartRate",
// Both HRV estimators ride one control: SDNN and RMSSD are the same signal to
// a reader deciding whether to hand over heart-rate variability at all.
HEART_RATE_VARIABILITY: "hrv",
HRV_RMSSD: "hrv",
VO2_MAX: "vo2max",
ACTIVITY_STEPS: "steps",
WALKING_RUNNING_DISTANCE: "distance",
// Screening totals. The `mentalHealth` module gate below still applies; this
// is the per-artefact choice that gate stopped standing in for.
PHQ9_SCORE: "mentalHealthScreeners",
GAD7_SCORE: "mentalHealthScreeners",
WHO5_SCORE: "mentalHealthScreeners",
SCI_SCORE: "mentalHealthScreeners",
};
/**
* v1.18.0 — per-measurement-type → owning module mapping for the types that
* carry no `sections` toggle but DO belong to a toggleable module. When the
* module is disabled, the type is stripped from `measurements` + `stats` so the
* series never reaches the PDF tables or a FHIR Observation.
*
* - `workouts` owns the activity / movement series (steps, active energy,
* distance, flights, the walking-gait + stair metrics) and the
* training-load `STRAIN_SCORE`.
* - `recovery` owns the readiness signals `RECOVERY_SCORE` + `STRESS_SCORE`.
* - `glucose` owns the raw `BLOOD_GLUCOSE` series (the glucose panel itself is
* gated where it is assembled; this strips the raw rows too).
*
* Core clinical types (BP / weight / pulse / body-composition / vitals) carry
* no module and are never gated here. `sleep` / `mood` / `cycle` / `labs` ride
* the `sections` mechanism instead.
*/
export const MEASUREMENT_TYPE_MODULE: Record<string, ModuleKey> = {
ACTIVITY_STEPS: "workouts",
ACTIVE_ENERGY_BURNED: "workouts",
FLIGHTS_CLIMBED: "workouts",
WALKING_RUNNING_DISTANCE: "workouts",
WALKING_SPEED: "workouts",
WALKING_ASYMMETRY: "workouts",
WALKING_STEP_LENGTH: "workouts",
WALKING_DOUBLE_SUPPORT: "workouts",
SIX_MINUTE_WALK_DISTANCE: "workouts",
STAIR_ASCENT_SPEED: "workouts",
STAIR_DESCENT_SPEED: "workouts",
STRAIN_SCORE: "workouts",
RECOVERY_SCORE: "recovery",
STRESS_SCORE: "recovery",
BLOOD_GLUCOSE: "glucose",
// The PHQ-9 / GAD-7 / WHO-5 / SCI screener TOTALS ride the mental-health
// module as well as the `mentalHealthScreeners` section key above. Item-level
// answers are never a Measurement, so they can never leak through this path
// regardless; the total is the intended clinical artefact (total + band per
// clinical-instruments.md §4).
//
// The module gate used to be the ONLY gate, on the argument that
// the module was opt-in and therefore default-OFF. It has been default-ON
// since v1.29.1, so that argument stopped holding and depression / anxiety
// screening totals reached FHIR bundles, the MCP doctor-visit resource and
// clinician share links on accounts that never chose to send them. The
// section key restores the choice; the module gate stays as the second lock.
PHQ9_SCORE: "mentalHealth",
GAD7_SCORE: "mentalHealth",
WHO5_SCORE: "mentalHealth",
SCI_SCORE: "mentalHealth",
};
export function filterMeasurementKeys<T>(
map: Record<string, T>,
sections: DoctorReportPrefs,
moduleMap: Record<ModuleKey, boolean>,
): Record<string, T> {
const out: Record<string, T> = {};
for (const [type, value] of Object.entries(map)) {
const sectionKey = MEASUREMENT_TYPE_SECTION[type];
if (sectionKey && sections[sectionKey] === false) continue;
const moduleKey = MEASUREMENT_TYPE_MODULE[type];
if (moduleKey && moduleMap[moduleKey] === false) continue;
out[type] = value;
}
return out;
}
/**
* The measurement types to exclude at QUERY time for a resolved selection.
*
* v1.28.25 pushed the gates into the SQL because rows of a deselected section
* were fetched only to be dropped by {@link filterMeasurementKeys} — pure dead
* weight, and the heaviest series (minute-grain CGM) is exactly the one that
* runs to six figures over a long window. This is output-identical by
* construction because it reads the same two maps the filter reads.
*
* WEIGHT is deliberately NEVER excluded: the BMI figure (gated by
* `sections.bmi`, not `sections.weight`) and the GLP-1 weight delta both read
* the PRE-filter maps, so weight rows must be present even when the weight
* section itself is off. No other gated type is read before the filter — each
* remaining pre-filter read (sleep nights, the glucose panel, the recovery
* summary) sits behind the same gate that drives its exclusion here.
*/
export function excludedMeasurementTypes(
sections: DoctorReportPrefs,
moduleMap: Record<ModuleKey, boolean>,
): MeasurementType[] {
const excluded: MeasurementType[] = [];
for (const [type, sectionKey] of Object.entries(MEASUREMENT_TYPE_SECTION)) {
if (type === WEIGHT_PREFILTER_EXCEPTION) continue;
if (sections[sectionKey] === false) excluded.push(type as MeasurementType);
}
// Glucose carries a section toggle but no entry in the type→section map: the
// raw series is gated here and the panel it feeds collapses separately.
if (sections.glucose === false) excluded.push("BLOOD_GLUCOSE");
for (const [type, moduleKey] of Object.entries(MEASUREMENT_TYPE_MODULE)) {
if (moduleMap[moduleKey] === false) excluded.push(type as MeasurementType);
}
return excluded;
}
/** The one type the query gate must keep even when its section is off. */
const WEIGHT_PREFILTER_EXCEPTION = "WEIGHT";
+8 -18
View File
@@ -13,7 +13,6 @@
import type { GlucoseUnit } from "@/lib/glucose";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { MeasurementSource } from "@/generated/prisma/client";
import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
import type { ModuleKey } from "@/lib/modules/gate";
import type {
ComplianceSchedule,
@@ -344,26 +343,17 @@ export interface DoctorReportRange {
export interface CollectDoctorReportOptions {
/** Free-text practice/clinic name to print on the cover. Optional. */
practiceName?: string | null;
/**
* v1.4.25 W6c — per-section visibility toggles. When omitted the
* documented defaults apply (every section ON except mood). When
* `sections.mood = false` the aggregator does NOT query
* `MoodEntry` at all — the mood data never leaves the DB row
* (privacy-by-default). Other sections are stripped from the
* returned payload so downstream renderers (PDF + JSON consumer)
* don't accidentally print a section the user opted out of.
*/
sections?: Partial<DoctorReportPrefs>;
/**
* v1.18.0 — resolved per-user module enable/disable map. When omitted the
* aggregator resolves it itself via `resolveModuleMap(userId)`. A disabled
* module forces its report section / FHIR resources out of the payload,
* regardless of the user's per-report `sections` toggles: a user who turned
* off the Sleep module exports no sleep series, mood Observations, glucose
* panel, recovery/strain scores, cycle summary, or lab results for the
* modules they no longer keep. Core clinical sections (weight / BP / pulse /
* medications) carry no module key and are always included. Injectable for
* tests so the module ↔ section mapping can be asserted without a DB.
* module forces its leaves out of the payload regardless of the selection: a
* user who turned off the Sleep module exports no sleep series, whatever the
* report asked for. Injectable for tests so the module ↔ leaf mapping can be
* asserted without a DB.
*
* The report SCOPE is not an option — it is the required third parameter of
* `collectDoctorReportData`, so a new egress path cannot be written without
* naming where its selection came from.
*/
moduleMap?: Record<ModuleKey, boolean>;
}
+157
View File
@@ -0,0 +1,157 @@
/**
* The structured clinical records on the doctor report: labs, illness
* episodes, allergies and family history.
*
* Each one is its own leaf and its own read. A refused leaf is not queried at
* all — the payload reflects "this was never fetched", not "this was fetched
* and then dropped". No free-text note column is ever selected on this path;
* the only decrypted free text is the allergy reaction, which is what an
* allergy record is for.
*
* Split out of the aggregator with the selection rework.
*/
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { decryptAllergyReaction } from "@/lib/doctor-report-helpers";
import type { DoctorReportData } from "@/lib/doctor-report-types";
/**
* Structured lab results over the window, reduced to the latest reading per
* analyte with a count, so the report is a concise panel rather than a raw
* dump. Analyte keys fold case-insensitively.
*/
export async function loadLabResults(
userId: string,
start: Date,
end: Date,
): Promise<DoctorReportData["labResults"]> {
const rows = await prisma.labResult.findMany({
where: { userId, takenAt: { gte: start, lte: end }, deletedAt: null },
orderBy: { takenAt: "asc" },
select: {
panel: true,
analyte: true,
value: true,
valueText: true,
unit: true,
referenceLow: true,
referenceHigh: true,
takenAt: true,
},
});
const byAnalyte = new Map<
string,
NonNullable<DoctorReportData["labResults"]>[number]
>();
for (const r of rows) {
const key = r.analyte.toLowerCase();
const prev = byAnalyte.get(key);
byAnalyte.set(key, {
panel: r.panel,
analyte: r.analyte,
value: r.value,
valueText: r.valueText,
unit: r.unit,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
takenAt: r.takenAt.toISOString(),
count: (prev?.count ?? 0) + 1,
});
}
const collapsed = Array.from(byAnalyte.values());
return collapsed.length > 0 ? collapsed : null;
}
/**
* Illness / condition episodes overlapping the window. An episode overlaps
* when it began on or before the window end AND is either still ongoing or
* resolved on or after the window start. Labels, lifecycle and dates only.
*/
export async function loadIllnessEpisodes(
userId: string,
start: Date,
end: Date,
): Promise<DoctorReportData["illnessEpisodes"]> {
const rows = await prisma.illnessEpisode.findMany({
where: {
userId,
deletedAt: null,
onsetAt: { lte: end },
OR: [{ resolvedAt: null }, { resolvedAt: { gte: start } }],
},
orderBy: { onsetAt: "asc" },
select: {
label: true,
type: true,
lifecycle: true,
onsetAt: true,
resolvedAt: true,
},
});
const mapped = rows.map((e) => ({
label: e.label,
type: e.type,
lifecycle: e.lifecycle,
onsetAt: e.onsetAt.toISOString(),
resolvedAt: e.resolvedAt ? e.resolvedAt.toISOString() : null,
}));
return mapped.length > 0 ? mapped : null;
}
/**
* Structured allergy records. Reference data, not time-windowed — a
* penicillin allergy does not expire with the report window.
*
* The reaction description decrypts fail-soft per row. A reaction that WAS
* recorded but cannot be read is flagged rather than silently blanked, so the
* renderer prints an honest marker instead of a clinician-facing export
* reading as "no reaction recorded".
*/
export async function loadAllergies(
userId: string,
): Promise<DoctorReportData["allergies"]> {
const rows = await prisma.allergy.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "asc" },
select: {
substance: true,
category: true,
type: true,
severity: true,
status: true,
reactionEncrypted: true,
},
});
const mapped = rows.map((r) => {
const { reaction, reactionUnreadable } = decryptAllergyReaction(
r.reactionEncrypted,
);
if (reactionUnreadable) {
getEvent()?.addWarning(
`doctor-report: allergy reaction decrypt failed for ${userId} (substance=${r.substance})`,
);
}
return {
substance: r.substance,
category: r.category,
type: r.type,
severity: r.severity,
status: r.status,
reaction,
reactionUnreadable,
};
});
return mapped.length > 0 ? mapped : null;
}
/** Structured family history. Same stance as allergies: reference data. */
export async function loadFamilyHistory(
userId: string,
): Promise<DoctorReportData["familyHistory"]> {
const rows = await prisma.familyHistoryEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "asc" },
select: { relationship: true, condition: true, ageAtOnset: true },
});
return rows.length > 0 ? rows : null;
}
+577
View File
@@ -0,0 +1,577 @@
/**
* Server-side aggregator for doctor-report data.
*
* Single source of truth for the payload every output path reads: the PDF, the
* FHIR document bundle, the package zip, the clinician share view, the FHIR
* REST face and the MCP doctor-visit surfaces. A leaf excluded here is absent
* from `measurements`, from `stats` and from the structured blocks, so every
* renderer describes the same record by construction rather than by each one
* remembering to apply the same filter.
*
* The third parameter is a REQUIRED, brand-typed {@link ReportSelection}. It
* used to be optional and default to everything-on, which meant a new egress
* path could be written without anyone answering "whose selection?". Now it
* cannot compile without an answer.
*
* Pure data assembly — no auth, no rate limit, no audit. Idempotent.
*/
import { prisma } from "@/lib/db";
import {
getEffectiveRange,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type {
GlucoseContext,
MeasurementType,
} from "@/generated/prisma/client";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { buildCycleExportSummary } from "@/lib/cycle/export-data";
import { resolveModuleMap } from "@/lib/modules/gate";
import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance";
import { MEASUREMENT_LEAF_IDS } from "@/lib/report-selection/catalogue";
import type { ReportSelection } from "@/lib/report-selection/selection";
import {
WELLNESS_SCORE_REPORT_TYPES,
type CollectDoctorReportOptions,
type DoctorReportData,
type DoctorReportMood,
type DoctorReportRange,
type DoctorReportStats,
} from "@/lib/doctor-report-types";
import {
buildLedgerCompliance,
collapseMeasurementsToCanonical,
minMaxOf,
sanitisePracticeName,
summariseCanonicalRecovery,
} from "@/lib/doctor-report-helpers";
import {
excludedMeasurementTypes,
filterMeasurementKeys,
resolveReportGate,
} from "./selection-gate";
import { loadDenseMeasurementBuckets } from "./dense-buckets";
import { summariseDenseBuckets } from "./measurement-series";
import { emptyGlucoseClinical } from "./glucose-panel";
import { buildAdministrationLedger, buildGlp1Block } from "./medications";
import {
loadAllergies,
loadFamilyHistory,
loadIllnessEpisodes,
loadLabResults,
} from "./clinical-records";
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
];
const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
/** No medication row is read unless at least one medication leaf is admitted. */
const MEDICATION_LEAVES = [
"MEDICATION_LIST",
"MEDICATION_ADMINISTRATIONS",
"MEDICATION_COMPLIANCE",
"GLP1_THERAPY",
] as const;
export async function collectDoctorReportData(
userId: string,
range: DoctorReportRange,
selection: ReportSelection,
options: CollectDoctorReportOptions = {},
): Promise<DoctorReportData> {
const { start, end, days } = range;
// Resolve the per-user module map once. A disabled module wins over a
// selected leaf; a deselected leaf wins over an enabled module. Injectable
// for tests.
const moduleMap = options.moduleMap ?? (await resolveModuleMap(userId));
// v1.30.22 — fail-closed backstop on the aggregate's OWN module key.
//
// Everything below gates individual leaves, but nothing ever gated
// `doctorReport` itself — the key that decides whether the whole-record
// aggregate may be assembled at all. REFUSE, not omit: this is the
// whole-record aggregate, so there is no honest partial answer. It throws
// rather than returning an envelope because reaching here means a caller is
// missing its gate — a bug to surface, not a flow to serve. Callers that can
// degrade gracefully gate themselves BEFORE calling.
if (moduleMap.doctorReport === false) {
throw new Error(
'collectDoctorReportData called with the "doctorReport" module ' +
"disabled — the calling surface is missing its module gate",
);
}
const gate = resolveReportGate(selection, moduleMap);
// Types read BEFORE the output filter because something else is derived from
// them. Weight feeds the BMI figure and the GLP-1 weight delta; the raw
// glucose series feeds the clinical panel. Each derived block is gated by its
// own leaf, so keeping the rows here widens nothing that leaves the function.
const keepForDerived = new Set<MeasurementType>();
if (gate.admits("BODY_MASS_INDEX") || gate.admits("GLP1_THERAPY")) {
keepForDerived.add("WEIGHT");
}
if (gate.admits("GLUCOSE_PANEL")) keepForDerived.add("BLOOD_GLUCOSE");
const excluded = excludedMeasurementTypes(
gate,
MEASUREMENT_LEAF_IDS,
keepForDerived,
);
const userProfile = await prisma.user.findUnique({
where: { id: userId },
select: {
username: true,
dateOfBirth: true,
gender: true,
heightCm: true,
glucoseUnit: true,
thresholdsJson: true,
timezone: true,
sourcePriorityJson: true,
fullName: true,
insurerName: true,
insurerIkNumber: true,
},
});
const reportTz = userProfile?.timezone ?? "Europe/Berlin";
const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS;
const densePulse = aggregateDenseTypes && !excluded.includes("PULSE");
const denseGlucose =
aggregateDenseTypes && !excluded.includes("BLOOD_GLUCOSE");
const rawExcluded = [...excluded];
if (densePulse) rawExcluded.push("PULSE");
if (denseGlucose) rawExcluded.push("BLOOD_GLUCOSE");
const wantsMedications = MEDICATION_LEAVES.some((leaf) => gate.admits(leaf));
const wantsIntakeEvents =
gate.admits("MEDICATION_ADMINISTRATIONS") ||
gate.admits("MEDICATION_COMPLIANCE") ||
gate.admits("GLP1_THERAPY");
const [measurements, medications, intakeEvents, moodEntries, denseBuckets] =
await Promise.all([
prisma.measurement.findMany({
// Soft-deleted rows never reach the report.
where: {
userId,
measuredAt: { gte: start, lte: end },
deletedAt: null,
...(rawExcluded.length > 0 ? { type: { notIn: rawExcluded } } : {}),
},
orderBy: { measuredAt: "asc" },
// Narrow select: the collector reads exactly these seven fields.
// The full-width read pulled every column — including the encrypted
// notes — across up to 730 days of rows.
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
sleepStage: true,
glucoseContext: true,
},
}),
wantsMedications
? prisma.medication.findMany({
where: { userId, active: true },
include: {
schedules: {
select: { ...SCHEDULE_COMPLIANCE_SELECT, label: true },
},
scheduleRevisions: {
orderBy: { validFrom: "asc" },
select: {
id: true,
validFrom: true,
validUntil: true,
payload: true,
supersededByRevisionId: true,
},
},
pauseEras: { select: { pausedAt: true, resumedAt: true } },
doseChanges: { orderBy: { effectiveFrom: "asc" } },
intakeEvents: {
where: { takenAt: { not: null } },
orderBy: { takenAt: "desc" },
take: 1,
select: { takenAt: true, injectionSite: true },
},
},
})
: Promise.resolve([]),
wantsIntakeEvents
? prisma.medicationIntakeEvent.findMany({
where: {
userId,
deletedAt: null,
scheduledFor: { gte: start, lte: end },
},
include: {
medication: {
select: {
id: true,
name: true,
dose: true,
atcCode: true,
rxNormCode: true,
deliveryForm: true,
asNeeded: true,
},
},
},
orderBy: { scheduledFor: "asc" },
})
: Promise.resolve([]),
// Zero DB read when the mood leaf was not chosen: the payload reflects
// "mood was never fetched", not "mood was fetched and then dropped".
gate.admits("MOOD")
? prisma.moodEntry.findMany({
where: {
userId,
deletedAt: null,
moodLoggedAt: { gte: start, lte: end },
},
orderBy: { moodLoggedAt: "asc" },
// Two fields: the score (summary + distribution) and the tags
// (GLP-1 side-effect tallies). Never the note columns.
select: { score: true, tags: true },
})
: Promise.resolve([]),
loadDenseMeasurementBuckets({
userId,
start,
end,
reportTz,
densePulse,
denseGlucose,
}),
]);
const denseSummary = summariseDenseBuckets(
denseBuckets,
reportTz,
userProfile?.sourcePriorityJson ?? null,
days,
);
// Collapse each multi-source metric to its CANONICAL source before grouping,
// so the per-type avg/min/max match the dashboard rather than blending
// overlapping sources.
const canonicalMeasurements = collapseMeasurementsToCanonical(
measurements,
reportTz,
userProfile?.sourcePriorityJson ?? null,
);
const byType: Record<
string,
Array<{ value: number; measuredAt: string }>
> = {};
for (const m of canonicalMeasurements) {
if (!byType[m.type]) byType[m.type] = [];
byType[m.type].push({
value: m.value,
measuredAt: m.measuredAt.toISOString(),
});
}
for (const [type, entries] of Object.entries(denseSummary.byType)) {
byType[type] = entries;
}
for (const entries of Object.values(byType)) {
entries.sort((a, b) => a.measuredAt.localeCompare(b.measuredAt));
}
// SLEEP_DURATION enters `byType` as RAW per-stage rows. Every other sleep
// surface shows the per-night reconstructed asleep total, so route the
// report's sleep value through the same engine — one number, one surface.
const sleepRows = measurements.filter(
(m) => m.type === "SLEEP_DURATION",
) as unknown as SleepStageRow[];
if (sleepRows.length > 0) {
const nights = reconstructSleepNights(
sleepRows,
reportTz,
userProfile?.sourcePriorityJson ?? null,
).filter((n) => n.asleepMinutes > 0);
if (nights.length > 0) {
byType.SLEEP_DURATION = nights.map((n) => ({
value: n.asleepMinutes,
measuredAt: n.measuredAt.toISOString(),
}));
} else {
delete byType.SLEEP_DURATION;
}
}
const stats: Record<string, DoctorReportStats> = {};
for (const [type, entries] of Object.entries(byType)) {
const values = entries.map((e) => e.value);
stats[type] = {
avg: values.reduce((a, b) => a + b, 0) / values.length,
...minMaxOf(values),
count: values.length,
latest: values[values.length - 1],
};
}
Object.assign(stats, denseSummary.stats);
// Medication compliance through the dose-ledger authority — the same engine
// the detail page uses, not a raw-row tally. As-needed medications are
// excluded: no schedule, no expected dose, no fabricated 100 % on a
// clinical report. The medication itself stays on the list.
const compliance = buildLedgerCompliance(
medications.map((m) => ({
id: m.id,
name: m.name,
asNeeded: m.asNeeded,
startsOn: m.startsOn,
endsOn: m.endsOn,
oneShot: m.oneShot,
createdAt: m.createdAt,
schedules: m.schedules,
scheduleRevisions: m.scheduleRevisions,
pauseEras: m.pauseEras,
})),
intakeEvents.map((e) => ({
medicationId: e.medicationId,
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed ?? false,
attributionSource: e.attributionSource ?? undefined,
})),
reportTz,
start,
end,
end,
);
const ledger = buildAdministrationLedger(medications, intakeEvents);
const moodScores = moodEntries.map((e) => e.score);
const mood: DoctorReportMood | null =
moodScores.length > 0
? {
avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length,
min: minMaxOf(moodScores).min,
max: minMaxOf(moodScores).max,
count: moodScores.length,
distribution: {
1: moodScores.filter((s) => s === 1).length,
2: moodScores.filter((s) => s === 2).length,
3: moodScores.filter((s) => s === 3).length,
4: moodScores.filter((s) => s === 4).length,
5: moodScores.filter((s) => s === 5).length,
},
}
: null;
// BMI from the latest weight and the profile height. Gated by its own leaf,
// which is also the leaf that gates any recorded BODY_MASS_INDEX rows — one
// control for the figure and the series alike.
const weightStats = stats.WEIGHT;
const bmiRaw =
weightStats && userProfile?.heightCm
? weightStats.latest / (userProfile.heightCm / 100) ** 2
: null;
const bmi = bmiRaw !== null ? Math.round(bmiRaw * 10) / 10 : null;
// Per-context glucose stats + effective ranges (canonical mg/dL). The whole
// panel rides the GLUCOSE_PANEL leaf: stats, ranges and clinical metrics
// collapse together, so the artefact never carries half of it.
const glucosePanelOn = gate.admits("GLUCOSE_PANEL");
const glucoseStats: Record<string, DoctorReportStats> =
glucosePanelOn && denseGlucose ? { ...denseSummary.glucoseStats } : {};
const glucoseRanges: Record<string, { min: number; max: number }> = {};
const glucoseRows = glucosePanelOn
? measurements.filter((m) => m.type === "BLOOD_GLUCOSE")
: [];
const overrides = (userProfile?.thresholdsJson ??
null) as ThresholdOverridesJson | null;
const profileForRange = {
heightCm: userProfile?.heightCm ?? null,
dateOfBirth: userProfile?.dateOfBirth ?? null,
gender: userProfile?.gender ?? null,
};
if (glucosePanelOn) {
for (const ctx of GLUCOSE_CONTEXTS) {
if (!denseGlucose) {
const rows = glucoseRows.filter((m) => m.glucoseContext === ctx);
if (rows.length === 0) continue;
const values = rows.map((r) => r.value);
glucoseStats[ctx] = {
avg: values.reduce((a, b) => a + b, 0) / values.length,
...minMaxOf(values),
count: values.length,
latest: values[values.length - 1],
};
} else if (!glucoseStats[ctx]) {
continue;
}
const eff = getEffectiveRange(
thresholdMetricForContext(ctx),
profileForRange,
overrides,
);
if (eff.range) {
glucoseRanges[ctx] = {
min: eff.range.greenMin,
max: eff.range.greenMax,
};
}
}
}
// The clinical panel over the whole report period, all contexts pooled.
// `windowDays` is the report's own period so TIR / GMI / eA1C / CV% reflect
// exactly the readings the rest of the report tabulates.
const glucoseClinical = !glucosePanelOn
? emptyGlucoseClinical(days)
: denseGlucose
? denseSummary.glucoseClinical
: computeGlucoseClinicalMetrics(
glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
{ windowDays: days, now: end },
);
const practiceName = sanitisePracticeName(options.practiceName);
// The output half of the query gate, reading the same gate object.
const filteredByType = filterMeasurementKeys(byType, gate);
const filteredStats = filterMeasurementKeys(stats, gate);
const glp1 = gate.admits("GLP1_THERAPY")
? buildGlp1Block({
medications,
compliance,
weightSeries: byType.WEIGHT ?? [],
moodTagRows: moodEntries,
})
: null;
// The wellness summary is filtered to the same score leaves the stats carry,
// so it cannot disagree with them. RECOVERY_SCORE carries both the native row
// and the computed proxy for one day; resolve to the canonical row so the PDF
// reads the SAME value the tile shows.
const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => {
if (!gate.admits(type)) return [];
if (type === "RECOVERY_SCORE") {
const summary = summariseCanonicalRecovery(measurements, reportTz);
return summary ? [summary] : [];
}
const s = stats[type];
const rows = byType[type];
if (!s || !rows || rows.length === 0) return [];
return [
{
type,
latest: Math.round(s.latest),
avg: Math.round(s.avg),
min: Math.round(s.min),
max: Math.round(s.max),
count: s.count,
latestAt: rows[rows.length - 1].measuredAt,
},
];
});
const wellnessScores =
wellnessScoreSummaries.length > 0 ? wellnessScoreSummaries : null;
// Each remaining structured block: read only when its leaf was chosen.
const [cycle, labResults, illnessEpisodes, allergies, familyHistory] =
await Promise.all([
gate.admits("CYCLE")
? buildCycleExportSummary(userId, end.toISOString().slice(0, 10))
: Promise.resolve(null),
gate.admits("LAB_RESULTS")
? loadLabResults(userId, start, end)
: Promise.resolve(null),
gate.admits("ILLNESS_EPISODES")
? loadIllnessEpisodes(userId, start, end)
: Promise.resolve(null),
gate.admits("ALLERGIES") ? loadAllergies(userId) : Promise.resolve(null),
gate.admits("FAMILY_HISTORY")
? loadFamilyHistory(userId)
: Promise.resolve(null),
]);
const identityOn = gate.admits("PATIENT_IDENTITY");
const insuranceOn = gate.admits("INSURANCE");
return {
period: {
days,
// `since` mirrors `start`, preserved for in-flight clients.
since: start.toISOString(),
start: start.toISOString(),
end: end.toISOString(),
},
patient: {
username: identityOn ? (userProfile?.username ?? null) : null,
dateOfBirth:
identityOn && userProfile?.dateOfBirth
? userProfile.dateOfBirth.toISOString()
: null,
gender: identityOn ? (userProfile?.gender ?? null) : null,
heightCm: identityOn ? (userProfile?.heightCm ?? null) : null,
fullName: identityOn ? (userProfile?.fullName ?? null) : null,
insurerName: insuranceOn ? (userProfile?.insurerName ?? null) : null,
insurerIkNumber: insuranceOn
? (userProfile?.insurerIkNumber ?? null)
: null,
},
practiceName,
measurements: filteredByType,
stats: filteredStats,
glucoseStats,
glucoseRanges,
glucoseClinical,
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
bmi: gate.admits("BODY_MASS_INDEX") ? bmi : null,
compliance: gate.admits("MEDICATION_COMPLIANCE") ? compliance : {},
medications: gate.admits("MEDICATION_LIST")
? medications.map((m) => ({
name: m.name,
dose: m.dose,
atcCode: m.atcCode,
rxNormCode: m.rxNormCode,
schedules: m.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
})),
}))
: [],
medicationAdministrations: gate.admits("MEDICATION_ADMINISTRATIONS")
? ledger.administrations
: [],
medicationAdministrationsTruncation: gate.admits(
"MEDICATION_ADMINISTRATIONS",
)
? ledger.truncation
: null,
mood,
glp1,
wellnessScores,
cycle,
labResults,
illnessEpisodes,
allergies,
familyHistory,
};
}
+211
View File
@@ -0,0 +1,211 @@
/**
* The per-local-day pre-aggregation for the two sample-dense measurement
* types.
*
* On a long window a CGM or a per-sample heart-rate account produces six
* figures of rows, so pulse and glucose collapse to one row per local
* day/source/device/context in SQL and the canonical-source picker then works
* over that bounded intermediate set. Every other type keeps the raw-row path.
*
* Extracted from the aggregator with the selection rework: the query is a job
* of its own, it is the one raw-SQL surface on this path, and keeping it in a
* thousand-line function is how a parameter stops being reviewed.
*/
import { prisma } from "@/lib/db";
import type {
GlucoseContext,
MeasurementSource,
MeasurementType,
} from "@/generated/prisma/client";
/** One pre-aggregated bucket: a local day for one type/source/device/context. */
export interface DenseMeasurementBucket {
bucketStart: Date;
type: MeasurementType;
source: MeasurementSource;
deviceType: string | null;
glucoseContext: GlucoseContext | null;
count: number;
sumValue: number;
minValue: number;
maxValue: number;
latestAt: Date;
latestValue: number;
clinicalCount: number;
clinicalSum: number | null;
clinicalSumSquares: number | null;
clinicalFirstAt: Date | null;
clinicalLastAt: Date | null;
clinicalTirCount: number;
clinicalTbr1Count: number;
clinicalTbr2Count: number;
clinicalTar1Count: number;
clinicalTar2Count: number;
clinicalLowRiskSum: number | null;
clinicalHighRiskSum: number | null;
}
/**
* Load the dense buckets for whichever of the two types the caller wants. Both
* flags false is not a call: the caller skips it entirely.
*
* Every value is bound as a tagged-template parameter — no splice, no
* interpolation of anything that came off the wire.
*/
export async function loadDenseMeasurementBuckets(params: {
userId: string;
start: Date;
end: Date;
reportTz: string;
densePulse: boolean;
denseGlucose: boolean;
}): Promise<DenseMeasurementBucket[]> {
const { userId, start, end, reportTz, densePulse, denseGlucose } = params;
if (!densePulse && !denseGlucose) return [];
return prisma.$queryRaw<DenseMeasurementBucket[]>`
WITH localized AS (
SELECT
m."id",
m."type",
m."source",
m."device_type",
m."glucose_context",
m."value",
m."measured_at",
date_trunc(
'day',
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${reportTz}
) AS local_day
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${start}
AND m."measured_at" <= ${end}
AND m."deleted_at" IS NULL
AND (
(${densePulse} AND m."type" = 'PULSE'::"measurement_type")
OR
(${denseGlucose} AND m."type" = 'BLOOD_GLUCOSE'::"measurement_type")
)
),
scored AS (
SELECT
localized.*,
CASE
WHEN "value" > 0
THEN 1.509 * (power(ln("value"), 1.084) - 5.381)
ELSE NULL
END AS risk_transform
FROM localized
),
latest_rows AS (
SELECT DISTINCT ON (
local_day,
"type",
"source",
"device_type",
"glucose_context"
)
local_day,
"type",
"source",
"device_type",
"glucose_context",
"measured_at" AS latest_at,
"value" AS latest_value
FROM localized
ORDER BY
local_day,
"type",
"source",
"device_type",
"glucose_context",
"measured_at" DESC,
"id" DESC
),
grouped AS (
SELECT
local_day,
"type",
"source",
"device_type",
"glucose_context",
COUNT(*)::int AS sample_count,
SUM("value")::double precision AS sum_value,
MIN("value")::double precision AS min_value,
MAX("value")::double precision AS max_value,
COUNT(*) FILTER (WHERE "value" > 0)::int AS clinical_count,
SUM("value") FILTER (WHERE "value" > 0)::double precision
AS clinical_sum,
SUM("value" * "value") FILTER (WHERE "value" > 0)::double precision
AS clinical_sum_squares,
MIN("measured_at") FILTER (WHERE "value" > 0) AS clinical_first_at,
MAX("measured_at") FILTER (WHERE "value" > 0) AS clinical_last_at,
COUNT(*) FILTER (WHERE "value" BETWEEN 70 AND 180)::int
AS clinical_tir_count,
COUNT(*) FILTER (WHERE "value" > 0 AND "value" < 70)::int
AS clinical_tbr1_count,
COUNT(*) FILTER (WHERE "value" > 0 AND "value" < 54)::int
AS clinical_tbr2_count,
COUNT(*) FILTER (WHERE "value" > 180)::int
AS clinical_tar1_count,
COUNT(*) FILTER (WHERE "value" > 250)::int
AS clinical_tar2_count,
SUM(
CASE
WHEN risk_transform < 0
THEN 10 * risk_transform * risk_transform
ELSE 0
END
) FILTER (WHERE risk_transform IS NOT NULL)::double precision
AS clinical_low_risk_sum,
SUM(
CASE
WHEN risk_transform > 0
THEN 10 * risk_transform * risk_transform
ELSE 0
END
) FILTER (WHERE risk_transform IS NOT NULL)::double precision
AS clinical_high_risk_sum
FROM scored
GROUP BY
local_day,
"type",
"source",
"device_type",
"glucose_context"
)
SELECT
grouped.local_day AT TIME ZONE ${reportTz} AS "bucketStart",
grouped."type",
grouped."source",
grouped."device_type" AS "deviceType",
grouped."glucose_context" AS "glucoseContext",
grouped.sample_count AS "count",
grouped.sum_value AS "sumValue",
grouped.min_value AS "minValue",
grouped.max_value AS "maxValue",
latest_rows.latest_at AS "latestAt",
latest_rows.latest_value::double precision AS "latestValue",
grouped.clinical_count AS "clinicalCount",
grouped.clinical_sum AS "clinicalSum",
grouped.clinical_sum_squares AS "clinicalSumSquares",
grouped.clinical_first_at AS "clinicalFirstAt",
grouped.clinical_last_at AS "clinicalLastAt",
grouped.clinical_tir_count AS "clinicalTirCount",
grouped.clinical_tbr1_count AS "clinicalTbr1Count",
grouped.clinical_tbr2_count AS "clinicalTbr2Count",
grouped.clinical_tar1_count AS "clinicalTar1Count",
grouped.clinical_tar2_count AS "clinicalTar2Count",
grouped.clinical_low_risk_sum AS "clinicalLowRiskSum",
grouped.clinical_high_risk_sum AS "clinicalHighRiskSum"
FROM grouped
INNER JOIN latest_rows
ON latest_rows.local_day = grouped.local_day
AND latest_rows."type" = grouped."type"
AND latest_rows."source" = grouped."source"
AND latest_rows."device_type" IS NOT DISTINCT FROM grouped."device_type"
AND latest_rows."glucose_context" IS NOT DISTINCT FROM grouped."glucose_context"
ORDER BY "bucketStart" ASC, grouped."type" ASC
`;
}
+146
View File
@@ -0,0 +1,146 @@
/**
* The glucose clinical panel derived from pre-aggregated buckets.
*
* TIR / GMI / eA1C / CV% over the whole report period, computed by the same
* literature-locked engine the insights panel and the coach consume. The
* learning gate keeps a thin period from asserting a clinical picture off spot
* data: too few readings, or too short a span, and the panel says so rather
* than producing a number that reads as authoritative.
*
* Split out of the aggregator with the selection rework — it is a self-
* contained numeric job with its own literature behind it.
*/
import {
CV_INSTABILITY_THRESHOLD,
estimatedA1c,
gmi,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
import type { DenseMeasurementBucket } from "./dense-buckets";
export function emptyGlucoseClinical(
windowDays: number,
): GlucoseClinicalMetrics {
return {
stillLearning: true,
stillLearningReason: `No glucose readings in the last ${windowDays} days.`,
windowDays,
actualSpanDays: 0,
readingCount: 0,
meanMgdl: null,
distribution: null,
gmi: null,
estimatedA1c: null,
variability: null,
advanced: null,
isSpotEstimate: true,
};
}
export function glucoseClinicalFromBuckets(
buckets: readonly DenseMeasurementBucket[],
windowDays: number,
): GlucoseClinicalMetrics {
let count = 0;
let sum = 0;
let sumSquares = 0;
let firstAt: Date | null = null;
let lastAt: Date | null = null;
let tir = 0;
let tbr1 = 0;
let tbr2 = 0;
let tar1 = 0;
let tar2 = 0;
let lowRisk = 0;
let highRisk = 0;
for (const bucket of buckets) {
if (bucket.type !== "BLOOD_GLUCOSE" || bucket.clinicalCount === 0) {
continue;
}
count += bucket.clinicalCount;
sum += bucket.clinicalSum ?? 0;
sumSquares += bucket.clinicalSumSquares ?? 0;
tir += bucket.clinicalTirCount;
tbr1 += bucket.clinicalTbr1Count;
tbr2 += bucket.clinicalTbr2Count;
tar1 += bucket.clinicalTar1Count;
tar2 += bucket.clinicalTar2Count;
lowRisk += bucket.clinicalLowRiskSum ?? 0;
highRisk += bucket.clinicalHighRiskSum ?? 0;
if (
bucket.clinicalFirstAt &&
(!firstAt || bucket.clinicalFirstAt < firstAt)
) {
firstAt = bucket.clinicalFirstAt;
}
if (bucket.clinicalLastAt && (!lastAt || bucket.clinicalLastAt > lastAt)) {
lastAt = bucket.clinicalLastAt;
}
}
if (count === 0 || !firstAt || !lastAt) {
return emptyGlucoseClinical(windowDays);
}
const meanMgdl = sum / count;
const actualSpanDays =
(lastAt.getTime() - firstAt.getTime()) / (24 * 60 * 60 * 1000);
const variance =
count >= 2
? Math.max(0, (sumSquares - (sum * sum) / count) / (count - 1))
: null;
const sd = variance === null ? null : Math.sqrt(variance);
const cv = sd === null || meanMgdl === 0 ? null : (sd / meanMgdl) * 100;
const fraction = (n: number) => n / count;
const minutes = (n: number) => fraction(n) * 1440;
const roundedSpan = Math.max(0, Math.round(actualSpanDays));
let stillLearningReason: string | null = null;
if (count < 14) {
stillLearningReason = `Still learning — ${count} reading${
count === 1 ? "" : "s"
} over ${roundedSpan} day${
roundedSpan === 1 ? "" : "s"
}; at least 14 are needed for a meaningful estimate.`;
} else if (actualSpanDays < 7) {
stillLearningReason = `Still learning — readings span only ${roundedSpan} day${
roundedSpan === 1 ? "" : "s"
}; at least 7 days of coverage are needed.`;
}
return {
stillLearning: stillLearningReason !== null,
stillLearningReason,
windowDays,
actualSpanDays,
readingCount: count,
meanMgdl,
distribution: {
tir: fraction(tir),
tbrLevel1: fraction(tbr1),
tbrLevel2: fraction(tbr2),
tarLevel1: fraction(tar1),
tarLevel2: fraction(tar2),
minutesEquivalent: {
tir: minutes(tir),
tbrLevel1: minutes(tbr1),
tbrLevel2: minutes(tbr2),
tarLevel1: minutes(tar1),
tarLevel2: minutes(tar2),
},
},
gmi: gmi(meanMgdl),
estimatedA1c: estimatedA1c(meanMgdl),
variability:
sd === null || cv === null
? null
: { sd, cv, unstable: cv >= CV_INSTABILITY_THRESHOLD },
advanced: {
jIndex: sd === null ? null : 0.001 * (meanMgdl + sd) ** 2,
lbgi: lowRisk / count,
hbgi: highRisk / count,
},
isSpotEstimate: count / Math.max(actualSpanDays, 1) < 24,
};
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Per-type measurement series and their summary statistics.
*
* Folds the pre-aggregated dense buckets down to the same shape the raw-row
* path produces — one entry per local day per type, plus per-type and
* per-glucose-context stats — so the rest of the aggregator never has to know
* which path a type came down.
*
* Split out of the aggregator with the selection rework.
*/
import { collapseMeasurementsToCanonical } from "@/lib/doctor-report-helpers";
import type { DoctorReportStats } from "@/lib/doctor-report-types";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { DenseMeasurementBucket } from "./dense-buckets";
import { glucoseClinicalFromBuckets } from "./glucose-panel";
export interface DenseMeasurementSummary {
byType: Record<string, Array<{ value: number; measuredAt: string }>>;
stats: Record<string, DoctorReportStats>;
glucoseStats: Record<string, DoctorReportStats>;
glucoseClinical: GlucoseClinicalMetrics;
}
interface DenseStatsAccumulator {
count: number;
sum: number;
min: number;
max: number;
latestAt: Date;
latest: number;
}
export function summariseDenseBuckets(
rows: readonly DenseMeasurementBucket[],
timezone: string,
sourcePriorityJson: unknown,
windowDays: number,
): DenseMeasurementSummary {
const canonical = collapseMeasurementsToCanonical(
rows.map((row) => ({
...row,
value: row.sumValue / row.count,
measuredAt: row.bucketStart,
})),
timezone,
sourcePriorityJson,
);
const byType: DenseMeasurementSummary["byType"] = {};
const statsAcc = new Map<string, DenseStatsAccumulator>();
const glucoseStatsAcc = new Map<string, DenseStatsAccumulator>();
const byTypeDay = new Map<
string,
{
type: string;
bucketStart: Date;
count: number;
sum: number;
min: number;
max: number;
latestAt: Date;
latest: number;
}
>();
for (const row of canonical) {
const key = `${row.type}:${row.bucketStart.toISOString()}`;
const current = byTypeDay.get(key);
if (!current) {
byTypeDay.set(key, {
type: row.type,
bucketStart: row.bucketStart,
count: row.count,
sum: row.sumValue,
min: row.minValue,
max: row.maxValue,
latestAt: row.latestAt,
latest: row.latestValue,
});
continue;
}
current.count += row.count;
current.sum += row.sumValue;
current.min = Math.min(current.min, row.minValue);
current.max = Math.max(current.max, row.maxValue);
if (row.latestAt > current.latestAt) {
current.latestAt = row.latestAt;
current.latest = row.latestValue;
}
}
const accumulate = (
target: Map<string, DenseStatsAccumulator>,
key: string,
row: DenseStatsAccumulator,
) => {
const current = target.get(key);
if (!current) {
target.set(key, { ...row });
return;
}
current.count += row.count;
current.sum += row.sum;
current.min = Math.min(current.min, row.min);
current.max = Math.max(current.max, row.max);
if (row.latestAt > current.latestAt) {
current.latestAt = row.latestAt;
current.latest = row.latest;
}
};
for (const row of byTypeDay.values()) {
(byType[row.type] ??= []).push({
value: row.sum / row.count,
measuredAt: row.bucketStart.toISOString(),
});
accumulate(statsAcc, row.type, row);
}
for (const row of canonical) {
if (row.type !== "BLOOD_GLUCOSE" || !row.glucoseContext) continue;
accumulate(glucoseStatsAcc, row.glucoseContext, {
count: row.count,
sum: row.sumValue,
min: row.minValue,
max: row.maxValue,
latestAt: row.latestAt,
latest: row.latestValue,
});
}
for (const entries of Object.values(byType)) {
entries.sort((a, b) => a.measuredAt.localeCompare(b.measuredAt));
}
const stats: DenseMeasurementSummary["stats"] = {};
for (const [key, value] of statsAcc) {
stats[key] = {
count: value.count,
avg: value.sum / value.count,
min: value.min,
max: value.max,
latest: value.latest,
};
}
const glucoseStats: DenseMeasurementSummary["glucoseStats"] = {};
for (const [key, value] of glucoseStatsAcc) {
glucoseStats[key] = {
count: value.count,
avg: value.sum / value.count,
min: value.min,
max: value.max,
latest: value.latest,
};
}
return {
byType,
stats,
glucoseStats,
glucoseClinical: glucoseClinicalFromBuckets(canonical, windowDays),
};
}
+259
View File
@@ -0,0 +1,259 @@
/**
* The medication half of the doctor report: the per-dose administration
* ledger and the GLP-1 therapy block.
*
* Four leaves cover this domain and each one is a separate release decision:
* the drug list, the administration ledger, the adherence figures, and the
* GLP-1 block. A clinician usually wants the list and the adherence rate; the
* ledger is every logged dose and the GLP-1 block carries a dose history and
* side-effect tallies, which is a different conversation.
*
* Split out of the aggregator with the selection rework.
*/
import { readNote } from "@/lib/crypto/note-cipher";
import type {
DoctorReportCompliance,
DoctorReportData,
} from "@/lib/doctor-report-types";
import { resolveMaxMedicationAdministrations } from "@/lib/doctor-report-helpers";
/** The medication columns the ledger and the GLP-1 block read. */
export interface MedicationRowForReport {
id: string;
name: string;
dose: string | null;
treatmentClass: string | null;
doseChanges: Array<{
effectiveFrom: Date;
doseValue: number;
doseUnit: string;
note: string | null;
noteEncrypted: Uint8Array | null;
}>;
intakeEvents: Array<{ takenAt: Date | null; injectionSite: string | null }>;
}
/** The intake columns the administration ledger reads. */
export interface IntakeEventRowForReport {
medicationId: string;
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
injectionSite: string | null;
medication: {
id: string;
name: string;
dose: string | null;
atcCode: string | null;
rxNormCode: string | null;
deliveryForm: string | null;
};
}
export interface AdministrationLedger {
administrations: NonNullable<DoctorReportData["medicationAdministrations"]>;
truncation: DoctorReportData["medicationAdministrationsTruncation"];
}
/**
* One entry per ACTED intake — a taken dose (completed) or an explicit skip
* (not-done). A scheduled-but-unconfirmed slot is not an administration event
* and is omitted entirely, so the export never asserts an administration that
* did not happen.
*
* The structured dose is resolved from the medication's dose-change history —
* the latest change effective at or before the administration instant — when
* one exists. A skip records no dose consumed.
*/
export function buildAdministrationLedger(
medications: readonly MedicationRowForReport[],
intakeEvents: readonly IntakeEventRowForReport[],
): AdministrationLedger {
const doseChangesByMedId = new Map<
string,
Array<{ effectiveFrom: Date; doseValue: number; doseUnit: string }>
>();
for (const m of medications) {
doseChangesByMedId.set(
m.id,
m.doseChanges.map((dc) => ({
effectiveFrom: dc.effectiveFrom,
doseValue: dc.doseValue,
doseUnit: dc.doseUnit,
})),
);
}
const resolveDoseInEffect = (
medicationId: string,
at: Date,
): { value: number; unit: string } | null => {
const changes = doseChangesByMedId.get(medicationId);
if (!changes || changes.length === 0) return null;
// Loaded ordered by `effectiveFrom asc`; take the last one whose
// `effectiveFrom` is at or before the administration instant.
let inEffect: { value: number; unit: string } | null = null;
for (const c of changes) {
if (c.effectiveFrom.getTime() <= at.getTime()) {
inEffect = { value: c.doseValue, unit: c.doseUnit };
} else {
break;
}
}
return inEffect;
};
const all: NonNullable<DoctorReportData["medicationAdministrations"]> = [];
for (const event of intakeEvents) {
const isTaken = event.takenAt !== null;
if (!isTaken && !event.skipped) continue;
const effectiveAt = isTaken ? (event.takenAt as Date) : event.scheduledFor;
all.push({
medicationName: event.medication.name,
effectiveAt: effectiveAt.toISOString(),
status: isTaken ? "completed" : "not-done",
doseText: event.medication.dose || null,
dose: isTaken
? resolveDoseInEffect(event.medication.id, effectiveAt)
: null,
injectionSite: event.injectionSite ?? null,
atcCode: event.medication.atcCode ?? null,
rxNormCode: event.medication.rxNormCode ?? null,
deliveryForm: event.medication.deliveryForm ?? null,
});
}
// `intakeEvents` is ordered `scheduledFor: asc`, so the most-recent acted
// rows are at the tail; keep the last N and flag the trim so the narrative
// can disclose it. The omitted rows are the OLDEST in the window — recent
// adherence is preserved intact. The cap is a coarse safety ceiling against
// a pathological multi-year, many-medication export.
const cap = resolveMaxMedicationAdministrations(
process.env.FHIR_MAX_MEDICATION_ADMINISTRATIONS,
);
const total = all.length;
const truncated = total > cap;
const administrations = truncated ? all.slice(total - cap) : all;
return {
administrations,
truncation: truncated ? { total, included: administrations.length } : null,
};
}
/**
* Mood tags that mark a GLP-1 side effect, in both languages the tag
* vocabulary ships. A tag not on this list is never counted, so a free-text
* tag cannot leak into the block as an invented side effect.
*/
const SIDE_EFFECT_TAGS = new Set([
"nausea",
"constipation",
"diarrhea",
"fatigue",
"appetite-loss",
"heartburn",
"headache",
"übelkeit",
"verstopfung",
"durchfall",
"müdigkeit",
"appetitlosigkeit",
"sodbrennen",
"kopfschmerzen",
]);
function parseTags(raw: string): string[] {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed
.map((v: unknown) => (typeof v === "string" ? v.trim() : ""))
.filter(Boolean)
: [];
} catch {
return raw
.split(",")
.map((p) => p.trim())
.filter(Boolean);
}
}
/**
* The GLP-1 therapy block: current dose, dose history, last injection, the
* weight delta over the window, and side-effect tallies.
*
* `moodTagRows` is empty unless the MOOD leaf was selected — the aggregator
* does not read mood entries otherwise — so the side-effect tally collapses to
* nothing rather than being computed from data the owner withheld.
*/
export function buildGlp1Block(params: {
medications: readonly MedicationRowForReport[];
compliance: Record<string, DoctorReportCompliance>;
weightSeries: ReadonlyArray<{ value: number; measuredAt: string }>;
moodTagRows: ReadonlyArray<{ tags: string | null }>;
}): DoctorReportData["glp1"] {
const glp1Meds = params.medications.filter(
(m) => m.treatmentClass === "GLP1",
);
if (glp1Meds.length === 0) return null;
const weightStartKg = params.weightSeries[0]?.value ?? null;
const weightEndKg =
params.weightSeries[params.weightSeries.length - 1]?.value ?? null;
const weightDeltaKg =
weightStartKg !== null && weightEndKg !== null
? Math.round((weightEndKg - weightStartKg) * 10) / 10
: null;
const sideEffectCounts = new Map<string, number>();
for (const row of params.moodTagRows) {
for (const tag of parseTags(row.tags ?? "")) {
const lower = tag.toLowerCase();
if (!SIDE_EFFECT_TAGS.has(lower)) continue;
sideEffectCounts.set(lower, (sideEffectCounts.get(lower) ?? 0) + 1);
}
}
return {
medications: glp1Meds.map((m) => {
const latest = m.doseChanges[m.doseChanges.length - 1] ?? null;
const lastIntake = m.intakeEvents[0] ?? null;
const comp = params.compliance[m.name] ?? {
taken: 0,
total: 0,
skipped: 0,
missed: 0,
};
return {
name: m.name,
currentDose: latest
? {
value: latest.doseValue,
unit: latest.doseUnit,
since: latest.effectiveFrom.toISOString(),
}
: null,
doseHistory: m.doseChanges.map((dc) => ({
value: dc.doseValue,
unit: dc.doseUnit,
effectiveFrom: dc.effectiveFrom.toISOString(),
note: readNote(dc.noteEncrypted, dc.note),
})),
lastInjection:
lastIntake && lastIntake.takenAt
? {
date: lastIntake.takenAt.toISOString(),
site: lastIntake.injectionSite,
}
: null,
compliance: { taken: comp.taken, total: comp.total },
};
}),
weightStartKg,
weightEndKg,
weightDeltaKg,
sideEffects: Array.from(sideEffectCounts.entries())
.sort((a, b) => b[1] - a[1])
.map(([tag, count]) => ({ tag, count })),
};
}
+103
View File
@@ -0,0 +1,103 @@
/**
* The one place a doctor-report leaf is admitted or refused.
*
* Two independent gates, and both must pass:
*
* - the per-artefact SELECTION the owner made for this report, and
* - the app-wide MODULE switch that decides whether the domain exists here
* at all.
*
* A disabled module wins over a selected leaf; a deselected leaf wins over an
* enabled module. Neither softens the other.
*
* The gate is fail-closed in both directions. A leaf the selection does not
* carry is excluded, because membership is inclusion and there is no default
* to consult. A measurement type the catalogue does not know is also excluded
* — and annotated, so a drifted enum shows up in the wide-event stream instead
* of riding out on a clinical document. The old filter passed such a type
* through, which is what made "we forgot to map it" the same as "the user
* asked for it".
*/
import type { ModuleKey } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import {
LEAF_MODULE,
isReportLeafId,
type ReportLeafId,
} from "@/lib/report-selection/catalogue";
import type { ReportSelection } from "@/lib/report-selection/selection";
import type { MeasurementType } from "@/generated/prisma/client";
/** A resolved gate: one question, asked the same way everywhere. */
export interface ReportGate {
/** Whether this artefact carries the leaf. */
admits(leaf: ReportLeafId): boolean;
/** The leaves that survived both gates, in catalogue order. */
readonly admitted: readonly ReportLeafId[];
}
export function resolveReportGate(
selection: ReportSelection,
moduleMap: Record<ModuleKey, boolean>,
): ReportGate {
const admits = (leaf: ReportLeafId): boolean => {
if (!selection.has(leaf)) return false;
const moduleKey = LEAF_MODULE[leaf];
if (moduleKey && moduleMap[moduleKey] === false) return false;
return true;
};
return { admits, admitted: selection.leaves.filter(admits) };
}
/**
* The measurement types to exclude at QUERY time.
*
* Rows of a refused leaf are not fetched only to be dropped — the heaviest
* series (minute-grain CGM, per-sample heart rate) is exactly the one that
* runs to six figures over a long window, and a leaf the owner withheld should
* not enter this process's memory at all.
*
* `keepForDerived` names the types the aggregator reads BEFORE the output
* filter and therefore must not exclude even when the leaf itself is refused:
* the BMI figure and the GLP-1 weight delta are both computed from weight rows
* and are gated by their own leaves.
*/
export function excludedMeasurementTypes(
gate: ReportGate,
allTypes: readonly MeasurementType[],
keepForDerived: ReadonlySet<MeasurementType>,
): MeasurementType[] {
return allTypes.filter(
(type) => !gate.admits(type) && !keepForDerived.has(type),
);
}
/**
* Strip every measurement type the gate refuses from a type-keyed map.
*
* This is the output half of the same decision the query gate makes, reading
* the same gate object, so the two can never disagree. A type that is not a
* catalogue leaf at all is excluded and annotated rather than passed through.
*/
export function filterMeasurementKeys<T>(
map: Record<string, T>,
gate: ReportGate,
): Record<string, T> {
const out: Record<string, T> = {};
const unmapped = new Set<string>();
for (const [type, value] of Object.entries(map)) {
if (!isReportLeafId(type)) {
// Meta, not `action` — the wide event carries one action name and it
// belongs to the route that is serving the request. The key is stable so
// a dashboard can alert on it.
unmapped.add(type);
continue;
}
if (!gate.admits(type)) continue;
out[type] = value;
}
if (unmapped.size > 0) {
annotate({ meta: { reportUnmappedLeaves: [...unmapped].sort() } });
}
return out;
}
-24
View File
@@ -1,24 +0,0 @@
/**
* Per-vital i18n label keys for the doctor report.
*
* Extracted from `doctor-report-pdf-core` so consumers that only need the
* label map (e.g. the public clinician share view, a pure server component)
* do not pull the jsPDF renderer into their module graph. The PDF core
* re-exports this map so there is a single source of truth.
*
* Exported for coverage tests (issue #109 / phase P0) so a future enum
* addition is caught by a unit test rather than reaching production as a raw
* enum string in the PDF.
*/
export const DOCTOR_REPORT_TYPE_LABEL_KEYS: Record<string, string> = {
WEIGHT: "doctorReport.typeWeight",
BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys",
BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia",
PULSE: "doctorReport.typePulse",
BODY_FAT: "doctorReport.typeBodyFat",
SLEEP_DURATION: "doctorReport.typeSleep",
ACTIVITY_STEPS: "doctorReport.typeSteps",
TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater",
BONE_MASS: "doctorReport.typeBoneMass",
OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation",
};
+76
View File
@@ -0,0 +1,76 @@
/**
* The three health-record artefacts, produced from one aggregated payload.
*
* Moved out of `POST /api/export/health-record` so the route keeps the HTTP
* concerns — auth, module gate, rate limit, validation, audit — and the
* rendering keeps its own. All three read the SAME `DoctorReportData`, which
* is what makes the PDF, the bundle and the zip describe an identical record
* by construction rather than by three renderers each remembering the scope.
*/
import { zipSync, strToU8 } from "fflate";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
import type { FhirRecordInputs } from "@/lib/fhir/build-bundle";
import type { Locale } from "@/lib/i18n/config";
import type { TimeFormatPreference } from "@/lib/format-locale";
export interface ArtefactInputs {
data: DoctorReportData;
/** Decrypted KVNR, or null when the insurance leaf was not selected. */
insuranceNumber: string | null;
records: FhirRecordInputs;
germanAtc: boolean;
includeCharts: boolean;
locale: Locale;
userTz: string;
timeFormat: TimeFormatPreference;
t: (key: string, vars?: Record<string, string | number>) => string;
}
/** The FHIR R4 document Bundle, serialised. */
export function buildBundleJson(input: ArtefactInputs): string {
const bundle = buildFhirDocumentBundle(
input.data,
{ insuranceNumber: input.insuranceNumber },
undefined,
{ germanAtc: input.germanAtc },
input.records,
);
return JSON.stringify(bundle);
}
/** The rendered clinical PDF. */
export function buildPdfBytes(input: ArtefactInputs): Uint8Array {
return renderDoctorReportPdfBytes(input.data, {
t: input.t,
locale: input.locale,
userTz: input.userTz,
timeFormat: input.timeFormat,
insuranceNumber: input.insuranceNumber,
includeCharts: input.includeCharts,
});
}
/** One zip holding the PDF, the Bundle and a README. */
export function buildPackageZip(
input: ArtefactInputs,
pdfBytes: Uint8Array,
): Uint8Array {
const bundle = buildFhirDocumentBundle(
input.data,
{ insuranceNumber: input.insuranceNumber },
undefined,
{ germanAtc: input.germanAtc },
input.records,
);
return zipSync(
{
"report.pdf": pdfBytes,
"bundle.json": strToU8(JSON.stringify(bundle, null, 2)),
"README.txt": strToU8(input.t("doctorReport.packageReadme")),
},
{ level: 6 },
);
}
+12 -5
View File
@@ -23,6 +23,7 @@ import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
import { resolveSavedSelection } from "@/lib/report-selection/saved-profile";
import { isModuleEnabled } from "@/lib/modules/gate";
import { summariseForVisit } from "./doctor-visit-summary";
import { coachScopeWindowSchema } from "@/lib/ai/coach/types";
@@ -305,11 +306,17 @@ export const MCP_PROMPTS: McpPromptDefinition[] = [
const end = new Date();
const start = new Date(end.getTime() - days * MS_PER_DAY);
const data = await collectDoctorReportData(ctx.userId, {
start,
end,
days,
});
// The assistant cannot ask a human, so this replays the owner's SAVED
// selection — the same object reviewed in Settings. An account that never
// saved one resolves to the empty selection and this surface says so,
// rather than inventing a scope. It used to assemble the full record at
// server defaults, on the one wire that egresses to a third party.
const selection = await resolveSavedSelection(ctx.userId);
const data = await collectDoctorReportData(
ctx.userId,
{ start, end, days },
selection,
);
const summary = summariseForVisit(data);
annotate({
+12 -1
View File
@@ -34,6 +34,7 @@ import {
BIOMARKER_PANELS,
} from "@/lib/labs/biomarker-catalog";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
import { resolveSavedSelection } from "@/lib/report-selection/saved-profile";
import { isModuleEnabled } from "@/lib/modules/gate";
import { NUTRIENT_CODES, type NutrientCode } from "@/lib/nutrients/catalog";
import { getNutrients, NUTRIENT_LABELS } from "@/lib/mcp/nutrients-read";
@@ -306,7 +307,17 @@ async function readDoctorVisit(
const days = WINDOW_DAYS[window] ?? 90;
const end = new Date();
const start = new Date(end.getTime() - days * MS_PER_DAY);
const data = await collectDoctorReportData(ctx.userId, { start, end, days });
// The assistant cannot ask a human, so this replays the owner's SAVED
// selection — the same object reviewed in Settings. An account that never
// saved one resolves to the empty selection and this surface says so,
// rather than inventing a scope. It used to assemble the full record at
// server defaults, on the one wire that egresses to a third party.
const selection = await resolveSavedSelection(ctx.userId);
const data = await collectDoctorReportData(
ctx.userId,
{ start, end, days },
selection,
);
annotate({
action: { name: "mcp.resource.read" },
meta: { resource: "doctor-visit-report", days },
+110
View File
@@ -0,0 +1,110 @@
/**
* Per-measurement-type i18n label keys.
*
* Extracted from `measurement-list-meta.ts` so consumers that only need the
* label map — the report selection catalogue, the jsPDF renderer, the public
* clinician share view, any server component — do not pull the lucide icon
* components into their module graph. The original module re-exports this map,
* so there is one source of truth and no import site had to change.
*
* The `Record<MeasurementType, string>` annotation is the guarantee: a new
* enum member without a label fails `pnpm typecheck` rather than reaching a
* surface as a raw enum string.
*/
import type { MeasurementType } from "@/generated/prisma/client";
export const MEASUREMENT_TYPE_LABEL_KEYS: Record<MeasurementType, string> = {
WEIGHT: "measurements.typeWeight",
BLOOD_PRESSURE_SYS: "measurements.typeBpSys",
BLOOD_PRESSURE_DIA: "measurements.typeBpDia",
PULSE: "measurements.typePulse",
BODY_FAT: "measurements.typeBodyFat",
SLEEP_DURATION: "measurements.typeSleep",
ACTIVITY_STEPS: "measurements.typeSteps",
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
TOTAL_BODY_WATER: "measurements.typeTotalBodyWater",
BONE_MASS: "measurements.typeBoneMass",
OXYGEN_SATURATION: "measurements.typeOxygenSaturation",
// ── v1.4.23 Apple Health additions ──
HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability",
RESTING_HEART_RATE: "measurements.typeRestingHeartRate",
ACTIVE_ENERGY_BURNED: "measurements.typeActiveEnergyBurned",
FLIGHTS_CLIMBED: "measurements.typeFlightsClimbed",
WALKING_RUNNING_DISTANCE: "measurements.typeWalkingRunningDistance",
VO2_MAX: "measurements.typeVo2Max",
BODY_TEMPERATURE: "measurements.typeBodyTemperature",
// ── v1.4.25 W5d Withings full coverage ──
FAT_FREE_MASS: "measurements.typeFatFreeMass",
FAT_MASS: "measurements.typeFatMass",
MUSCLE_MASS: "measurements.typeMuscleMass",
SKIN_TEMPERATURE: "measurements.typeSkinTemperature",
PULSE_WAVE_VELOCITY: "measurements.typePulseWaveVelocity",
VASCULAR_AGE: "measurements.typeVascularAge",
VISCERAL_FAT: "measurements.typeVisceralFat",
// ── v1.4.25 W8d Apple Health server-prep ──
AUDIO_EXPOSURE_ENV: "measurements.typeAudioExposureEnv",
AUDIO_EXPOSURE_HEADPHONE: "measurements.typeAudioExposureHeadphone",
TIME_IN_DAYLIGHT: "measurements.typeTimeInDaylight",
// ── v1.4.30 R-F T1.4 + T1.5 ──
WALKING_STEADINESS: "measurements.typeWalkingSteadiness",
AUDIO_EXPOSURE_EVENT: "measurements.typeAudioExposureEvent",
// ── v1.5.5 iOS-coord additions ──
RESPIRATORY_RATE: "measurements.typeRespiratoryRate",
BODY_MASS_INDEX: "measurements.typeBodyMassIndex",
LEAN_BODY_MASS: "measurements.typeLeanBodyMass",
WALKING_HEART_RATE_AVERAGE: "measurements.typeWalkingHeartRateAverage",
WALKING_ASYMMETRY: "measurements.typeWalkingAsymmetry",
WALKING_DOUBLE_SUPPORT: "measurements.typeWalkingDoubleSupport",
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
WALKING_STEP_LENGTH: "measurements.typeWalkingStepLength",
WALKING_SPEED: "measurements.typeWalkingSpeed",
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
CARDIO_RECOVERY: "measurements.typeCardioRecovery",
WRIST_TEMPERATURE: "measurements.typeWristTemperature",
FALL_COUNT: "measurements.typeFallCount",
SIX_MINUTE_WALK_DISTANCE: "measurements.typeSixMinuteWalkDistance",
STAIR_ASCENT_SPEED: "measurements.typeStairAscentSpeed",
STAIR_DESCENT_SPEED: "measurements.typeStairDescentSpeed",
BREATHING_DISTURBANCES: "measurements.typeBreathingDisturbances",
// ── v1.10.0 — categorical events (WX-B) ──
IRREGULAR_RHYTHM_NOTIFICATION: "measurements.typeIrregularRhythmNotification",
HIGH_HEART_RATE_EVENT: "measurements.typeHighHeartRateEvent",
LOW_HEART_RATE_EVENT: "measurements.typeLowHeartRateEvent",
WALKING_STEADINESS_EVENT: "measurements.typeWalkingSteadinessEvent",
BREATHING_DISTURBANCE_EVENT: "measurements.typeBreathingDisturbanceEvent",
// ── v1.10.0 — computed scores (WX-C) ──
RECOVERY_SCORE: "measurements.typeRecoveryScore",
STRESS_SCORE: "measurements.typeStressScore",
STRAIN_SCORE: "measurements.typeStrainScore",
// ── v1.11.0 — WHOOP-native score classes ──
HRV_RMSSD: "measurements.typeHrvRmssd",
DAY_STRAIN: "measurements.typeDayStrain",
WORKOUT_STRAIN: "measurements.typeWorkoutStrain",
SLEEP_PERFORMANCE: "measurements.typeSleepPerformance",
SLEEP_EFFICIENCY: "measurements.typeSleepEfficiency",
SLEEP_CONSISTENCY: "measurements.typeSleepConsistency",
SLEEP_NEED: "measurements.typeSleepNeed",
ENERGY_EXPENDITURE_KJ: "measurements.typeEnergyExpenditureKj",
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
AVERAGE_HEART_RATE: "measurements.typeAverageHeartRate",
MAX_HEART_RATE: "measurements.typeMaxHeartRate",
SLEEP_DISTURBANCE_COUNT: "measurements.typeSleepDisturbanceCount",
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
ANS_CHARGE: "measurements.typeAnsCharge",
CARDIO_LOAD: "measurements.typeCardioLoad",
// ── v1.17.1 — Oura coverage completion ──
SLEEP_SCORE: "measurements.typeSleepScore",
BODY_TEMPERATURE_DEVIATION: "measurements.typeBodyTemperatureDeviation",
// ── v1.19.0 — Oura resilience ──
RESILIENCE: "measurements.typeResilience",
// ── v1.25 — clinical-signals wave ──
PHQ9_SCORE: "measurements.typePhq9Score",
GAD7_SCORE: "measurements.typeGad7Score",
// ── v1.27.9 — screening scores ──
WHO5_SCORE: "measurements.typeWho5Score",
SCI_SCORE: "measurements.typeSciScore",
GRIP_STRENGTH: "measurements.typeGripStrength",
PAIN_NRS: "measurements.typePainNrs",
WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference",
WAIST_TO_HEIGHT: "measurements.typeWaistToHeight",
};
+16 -5
View File
@@ -101,14 +101,25 @@ const capabilitiesResponse = z
.describe(
"Maximum lifetime of a share link, in days. No never-expiring share.",
),
resourceTypes: z
reportDownload: z
.array(z.string())
.describe("FHIR resource types a share link may be scoped to serve."),
sections: z
.describe(
"Machine formats a share link serves as a download under its token, behind the same unlock as the page.",
),
selectionVersion: z
.number()
.int()
.describe("Version of the leaf-selection grammar this build speaks."),
groups: z
.array(z.string())
.describe("Scopeable report sections a share link may toggle."),
.describe("Selection group ids, in presentation order."),
leaves: z
.array(z.string())
.describe(
"Every leaf id a report selection may carry. Membership is inclusion; absence is exclusion.",
),
})
.describe("Clinician share-link surface descriptor (v1.11)."),
.describe("Clinician share-link surface descriptor."),
})
.meta({
id: "CapabilitiesResponse",
+380
View File
@@ -0,0 +1,380 @@
/**
* The doctor-report leaf catalogue: every piece of the record a user can
* release or withhold, and the group it is presented under.
*
* A LEAF is the smallest releasable unit. There are exactly two kinds: every
* `MeasurementType` enum member, and the structured sections of the report
* payload that are not measurement series (identity, insurance, the glucose
* panel, labs, the three medication blocks, GLP-1, allergies, illness
* episodes, family history, mood, cycle).
*
* One catalogue drives the selection panel, the aggregator, the PDF, the
* clinician share view and the guard tests, so a control can never again exist
* without a gating path behind it, and a gate can never exist without a
* control.
*
* Two of the three new-metric locks live here:
*
* - `MEASUREMENT_LEAF_GROUP` is a `Record<MeasurementType, ReportGroupId>`.
* Adding an enum member without deciding its group fails `pnpm typecheck`,
* which runs in the pre-commit hook and in CI. The decision has to be made
* in the same commit as the enum member, and it has no effect on anyone's
* saved selection.
* - `STRUCTURED_LEAF_GROUP` does the same over the closed structured union.
*
* The third lock is set membership: a stored selection is a list of what was
* chosen, so a leaf that did not exist when it was saved is not in it. See
* `./selection.ts`.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type { ModuleKey } from "@/lib/modules/registry";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
/**
* The structured (non-measurement) leaves. A closed union, so
* `STRUCTURED_LEAF_GROUP` below is exhaustive by the compiler rather than by
* review.
*/
export type StructuredLeafId =
| "PATIENT_IDENTITY"
| "INSURANCE"
| "GLUCOSE_PANEL"
| "LAB_RESULTS"
| "MEDICATION_LIST"
| "MEDICATION_ADMINISTRATIONS"
| "MEDICATION_COMPLIANCE"
| "GLP1_THERAPY"
| "ALLERGIES"
| "ILLNESS_EPISODES"
| "FAMILY_HISTORY"
| "MOOD"
| "CYCLE";
/** Every leaf id: the 77 measurement types plus the 13 structured sections. */
export type ReportLeafId = MeasurementType | StructuredLeafId;
/** The presentation groups, in the order the panel and the PDF render them. */
export const REPORT_GROUP_ORDER = [
"identity",
"vitals",
"cardio",
"body",
"sleepRecovery",
"activity",
"mobility",
"environment",
"glucose",
"labs",
"medications",
"history",
"sensitive",
] as const;
export type ReportGroupId = (typeof REPORT_GROUP_ORDER)[number];
/**
* The fenced tier. It renders below a separator with no group checkbox, so no
* single control can turn on more than one of these at a time, and it is never
* in the shipped template. Mental-health screening totals, mood and cycle are
* the user's own sensitive data; family history is third-party information
* about people who consented to nothing.
*/
export const SENSITIVE_GROUP_ID = "sensitive" satisfies ReportGroupId;
/**
* Measurement leaf → group. Exhaustive over the enum by construction.
*
* Two placements carry a stated reason. `PAIN_NRS` sits in vitals because pain
* is read as a vital sign in every intake, not because it is a device reading.
* The four screening totals are fenced because a depression or anxiety
* instrument is not something to hand over as part of a bundle.
*/
export const MEASUREMENT_LEAF_GROUP: Record<MeasurementType, ReportGroupId> = {
// ── vitals ──
BLOOD_PRESSURE_SYS: "vitals",
BLOOD_PRESSURE_DIA: "vitals",
PULSE: "vitals",
OXYGEN_SATURATION: "vitals",
RESPIRATORY_RATE: "vitals",
BODY_TEMPERATURE: "vitals",
PAIN_NRS: "vitals",
// ── cardio ──
RESTING_HEART_RATE: "cardio",
HEART_RATE_VARIABILITY: "cardio",
HRV_RMSSD: "cardio",
VO2_MAX: "cardio",
WALKING_HEART_RATE_AVERAGE: "cardio",
AVERAGE_HEART_RATE: "cardio",
MAX_HEART_RATE: "cardio",
CARDIO_RECOVERY: "cardio",
CARDIO_LOAD: "cardio",
PULSE_WAVE_VELOCITY: "cardio",
VASCULAR_AGE: "cardio",
IRREGULAR_RHYTHM_NOTIFICATION: "cardio",
HIGH_HEART_RATE_EVENT: "cardio",
LOW_HEART_RATE_EVENT: "cardio",
// ── body ──
WEIGHT: "body",
BODY_MASS_INDEX: "body",
BODY_FAT: "body",
FAT_MASS: "body",
FAT_FREE_MASS: "body",
MUSCLE_MASS: "body",
LEAN_BODY_MASS: "body",
TOTAL_BODY_WATER: "body",
BONE_MASS: "body",
VISCERAL_FAT: "body",
WAIST_CIRCUMFERENCE: "body",
WAIST_TO_HEIGHT: "body",
// ── sleep & recovery ──
SLEEP_DURATION: "sleepRecovery",
SLEEP_SCORE: "sleepRecovery",
SLEEP_PERFORMANCE: "sleepRecovery",
SLEEP_EFFICIENCY: "sleepRecovery",
SLEEP_CONSISTENCY: "sleepRecovery",
SLEEP_NEED: "sleepRecovery",
SLEEP_DISTURBANCE_COUNT: "sleepRecovery",
BREATHING_DISTURBANCES: "sleepRecovery",
BREATHING_DISTURBANCE_EVENT: "sleepRecovery",
RECOVERY_SCORE: "sleepRecovery",
STRESS_SCORE: "sleepRecovery",
RESILIENCE: "sleepRecovery",
ANS_CHARGE: "sleepRecovery",
SKIN_TEMPERATURE: "sleepRecovery",
WRIST_TEMPERATURE: "sleepRecovery",
BODY_TEMPERATURE_DEVIATION: "sleepRecovery",
// ── activity ──
ACTIVITY_STEPS: "activity",
WALKING_RUNNING_DISTANCE: "activity",
FLIGHTS_CLIMBED: "activity",
ACTIVE_ENERGY_BURNED: "activity",
ENERGY_EXPENDITURE_KJ: "activity",
STRAIN_SCORE: "activity",
DAY_STRAIN: "activity",
WORKOUT_STRAIN: "activity",
// ── mobility & gait ──
WALKING_SPEED: "mobility",
WALKING_ASYMMETRY: "mobility",
WALKING_DOUBLE_SUPPORT: "mobility",
WALKING_STEP_LENGTH: "mobility",
WALKING_STEADINESS: "mobility",
WALKING_STEADINESS_EVENT: "mobility",
STAIR_ASCENT_SPEED: "mobility",
STAIR_DESCENT_SPEED: "mobility",
SIX_MINUTE_WALK_DISTANCE: "mobility",
FALL_COUNT: "mobility",
GRIP_STRENGTH: "mobility",
// ── environment ──
AUDIO_EXPOSURE_ENV: "environment",
AUDIO_EXPOSURE_HEADPHONE: "environment",
AUDIO_EXPOSURE_EVENT: "environment",
TIME_IN_DAYLIGHT: "environment",
// ── glucose ──
BLOOD_GLUCOSE: "glucose",
// ── fenced ──
PHQ9_SCORE: "sensitive",
GAD7_SCORE: "sensitive",
WHO5_SCORE: "sensitive",
SCI_SCORE: "sensitive",
};
/** Structured leaf → group. Exhaustive over the closed union. */
export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
PATIENT_IDENTITY: "identity",
INSURANCE: "identity",
GLUCOSE_PANEL: "glucose",
LAB_RESULTS: "labs",
MEDICATION_LIST: "medications",
MEDICATION_ADMINISTRATIONS: "medications",
MEDICATION_COMPLIANCE: "medications",
GLP1_THERAPY: "medications",
ALLERGIES: "history",
ILLNESS_EPISODES: "history",
FAMILY_HISTORY: "sensitive",
MOOD: "sensitive",
CYCLE: "sensitive",
};
/** i18n label keys for the 13 structured leaves. */
export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
PATIENT_IDENTITY: "reportSelection.leafPatientIdentity",
INSURANCE: "reportSelection.leafInsurance",
GLUCOSE_PANEL: "reportSelection.leafGlucosePanel",
LAB_RESULTS: "reportSelection.leafLabResults",
MEDICATION_LIST: "reportSelection.leafMedicationList",
MEDICATION_ADMINISTRATIONS: "reportSelection.leafMedicationAdministrations",
MEDICATION_COMPLIANCE: "reportSelection.leafMedicationCompliance",
GLP1_THERAPY: "reportSelection.leafGlp1Therapy",
ALLERGIES: "reportSelection.leafAllergies",
ILLNESS_EPISODES: "reportSelection.leafIllnessEpisodes",
FAMILY_HISTORY: "reportSelection.leafFamilyHistory",
MOOD: "reportSelection.leafMood",
CYCLE: "reportSelection.leafCycle",
};
/** i18n label keys for the groups, including the fenced tier's heading. */
export const REPORT_GROUP_LABEL_KEYS: Record<ReportGroupId, string> = {
identity: "reportSelection.groupIdentity",
vitals: "reportSelection.groupVitals",
cardio: "reportSelection.groupCardio",
body: "reportSelection.groupBody",
sleepRecovery: "reportSelection.groupSleepRecovery",
activity: "reportSelection.groupActivity",
mobility: "reportSelection.groupMobility",
environment: "reportSelection.groupEnvironment",
glucose: "reportSelection.groupGlucose",
labs: "reportSelection.groupLabs",
medications: "reportSelection.groupMedications",
history: "reportSelection.groupHistory",
sensitive: "reportSelection.groupSensitive",
};
/**
* v1.18.0 — leaf → owning module. The module map still ANDs over the
* selection: a disabled module wins over a selected leaf, and a deselected
* leaf wins over an enabled module. Neither softens the other.
*
* Core clinical leaves (blood pressure, weight, pulse, body composition,
* identity) carry no module and are never gated here.
*/
export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
// Activity / movement series and the training-load scores.
ACTIVITY_STEPS: "workouts",
ACTIVE_ENERGY_BURNED: "workouts",
ENERGY_EXPENDITURE_KJ: "workouts",
FLIGHTS_CLIMBED: "workouts",
WALKING_RUNNING_DISTANCE: "workouts",
WALKING_SPEED: "workouts",
WALKING_ASYMMETRY: "workouts",
WALKING_STEP_LENGTH: "workouts",
WALKING_DOUBLE_SUPPORT: "workouts",
WALKING_STEADINESS: "workouts",
WALKING_STEADINESS_EVENT: "workouts",
SIX_MINUTE_WALK_DISTANCE: "workouts",
STAIR_ASCENT_SPEED: "workouts",
STAIR_DESCENT_SPEED: "workouts",
STRAIN_SCORE: "workouts",
DAY_STRAIN: "workouts",
WORKOUT_STRAIN: "workouts",
// Readiness signals.
RECOVERY_SCORE: "recovery",
STRESS_SCORE: "recovery",
RESILIENCE: "recovery",
ANS_CHARGE: "recovery",
// Sleep.
SLEEP_DURATION: "sleep",
SLEEP_SCORE: "sleep",
SLEEP_PERFORMANCE: "sleep",
SLEEP_EFFICIENCY: "sleep",
SLEEP_CONSISTENCY: "sleep",
SLEEP_NEED: "sleep",
SLEEP_DISTURBANCE_COUNT: "sleep",
BREATHING_DISTURBANCES: "sleep",
BREATHING_DISTURBANCE_EVENT: "sleep",
// Environment exposure.
AUDIO_EXPOSURE_ENV: "environment",
AUDIO_EXPOSURE_HEADPHONE: "environment",
AUDIO_EXPOSURE_EVENT: "environment",
TIME_IN_DAYLIGHT: "environment",
// Glucose: the raw series and the derived panel.
BLOOD_GLUCOSE: "glucose",
GLUCOSE_PANEL: "glucose",
// The screening totals ride the mental-health module as well as being
// fenced in the panel. Item-level answers are never a Measurement, so only
// the total — the intended clinical artefact — can reach this path at all.
PHQ9_SCORE: "mentalHealth",
GAD7_SCORE: "mentalHealth",
WHO5_SCORE: "mentalHealth",
SCI_SCORE: "mentalHealth",
// Structured sections with an owning module.
LAB_RESULTS: "labs",
MOOD: "mood",
CYCLE: "cycle",
ILLNESS_EPISODES: "illness",
MEDICATION_LIST: "medications",
MEDICATION_ADMINISTRATIONS: "medications",
MEDICATION_COMPLIANCE: "medications",
GLP1_THERAPY: "medications",
};
/** Every measurement leaf id, in enum order. */
export const MEASUREMENT_LEAF_IDS = Object.keys(
MEASUREMENT_LEAF_GROUP,
) as MeasurementType[];
/** Every structured leaf id. */
export const STRUCTURED_LEAF_IDS = Object.keys(
STRUCTURED_LEAF_GROUP,
) as StructuredLeafId[];
/**
* Every leaf id, grouped and ordered the way the panel renders them: group
* order first, structured leaves before measurement leaves inside a group.
* The PDF and the clinician view read the same order, so the panel is a table
* of contents for the artefact.
*/
export const REPORT_GROUPS: ReadonlyArray<{
id: ReportGroupId;
labelKey: string;
leaves: readonly ReportLeafId[];
}> = REPORT_GROUP_ORDER.map((id) => ({
id,
labelKey: REPORT_GROUP_LABEL_KEYS[id],
leaves: [
...STRUCTURED_LEAF_IDS.filter((leaf) => STRUCTURED_LEAF_GROUP[leaf] === id),
...MEASUREMENT_LEAF_IDS.filter(
(leaf) => MEASUREMENT_LEAF_GROUP[leaf] === id,
),
],
}));
/** The full ordered leaf list — the stable order a stored selection uses. */
export const ALL_LEAF_IDS: readonly ReportLeafId[] = REPORT_GROUPS.flatMap(
(group) => group.leaves,
);
const ALL_LEAF_SET = new Set<string>(ALL_LEAF_IDS);
/** Whether a raw string is a leaf id this build knows. */
export function isReportLeafId(value: unknown): value is ReportLeafId {
return typeof value === "string" && ALL_LEAF_SET.has(value);
}
const STRUCTURED_LEAF_SET = new Set<string>(STRUCTURED_LEAF_IDS);
/** Whether a leaf id is one of the structured sections. */
export function isStructuredLeafId(
leaf: ReportLeafId,
): leaf is StructuredLeafId {
return STRUCTURED_LEAF_SET.has(leaf);
}
/** The fenced leaves, as a set, for the template-purity guard and the panel. */
export const SENSITIVE_LEAF_IDS: readonly ReportLeafId[] = ALL_LEAF_IDS.filter(
(leaf) =>
isStructuredLeafId(leaf)
? STRUCTURED_LEAF_GROUP[leaf] === SENSITIVE_GROUP_ID
: MEASUREMENT_LEAF_GROUP[leaf] === SENSITIVE_GROUP_ID,
);
/** The i18n label key for any leaf. */
export function leafLabelKey(leaf: ReportLeafId): string {
return isStructuredLeafId(leaf)
? STRUCTURED_LEAF_LABEL_KEYS[leaf]
: MEASUREMENT_TYPE_LABEL_KEYS[leaf];
}
/** The group a leaf belongs to. */
export function leafGroup(leaf: ReportLeafId): ReportGroupId {
return isStructuredLeafId(leaf)
? STRUCTURED_LEAF_GROUP[leaf]
: MEASUREMENT_LEAF_GROUP[leaf];
}
+72
View File
@@ -0,0 +1,72 @@
/**
* The owner's saved report selection — "my selection".
*
* Persisted on `User.reportSelectionJson` and written after a successful
* generation, the same way the practice name is. Two kinds of caller read it:
*
* - the export panel and the share-link create form, so the panel opens
* already carrying the last scope the owner chose, and
* - the surfaces that cannot ask a human — the FHIR REST face and the MCP
* doctor-visit resource and prompt.
*
* The second group is the reason this exists. Those three call sites used to
* assemble the full record at server defaults. Now they replay a real act by
* the owner: the same object reviewed in Settings, no more and no less. An
* account that has never saved one resolves to the EMPTY selection, so an
* assistant asking for a record the owner never scoped gets nothing — not a
* template, not a guess.
*/
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import {
EMPTY_REPORT_SELECTION,
reportSelectionSchema,
selectionFromStoredBlob,
type ReportSelection,
} from "./selection";
/**
* The stored profile: the leaf list plus the render choices the panel
* restores. `format` / `rangeDays` / `includeCharts` are presentation, not
* scope — no gate reads them — so they ride alongside rather than inside the
* selection.
*/
export const savedReportProfileSchema = reportSelectionSchema.extend({
format: z.enum(["pdf", "fhir", "package"]),
rangeDays: z.number().int().min(1).max(365),
includeCharts: z.boolean(),
});
export type SavedReportProfile = z.infer<typeof savedReportProfileSchema>;
/** What the panel seeds from when the account has never saved a profile. */
export const SAVED_PROFILE_FALLBACK = {
format: "pdf",
rangeDays: 90,
includeCharts: true,
} as const;
/**
* Resolve the owner's saved selection.
*
* Anything that is not a well-formed v2 blob — null, a drifted shape, a
* hand-edited row — resolves to the empty selection. A shape the server cannot
* read is not consent to anything.
*/
export async function resolveSavedSelection(
userId: string,
): Promise<ReportSelection> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { reportSelectionJson: true },
});
if (!row) return EMPTY_REPORT_SELECTION;
return selectionFromStoredBlob(row.reportSelectionJson);
}
/** Parse a stored profile blob for the panel, or null when there is none. */
export function parseSavedProfile(raw: unknown): SavedReportProfile | null {
const parsed = savedReportProfileSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
}
+131
View File
@@ -0,0 +1,131 @@
/**
* The resolved report selection: an inclusion list of leaf ids, and the only
* three ways to mint one.
*
* Membership is inclusion. Absence is exclusion. There is no defaults constant
* anywhere in this module, because a default that means "yes" is the defect
* this shape exists to remove: the flat preferences object it replaces
* resolved an absent key to "included", so a caller who sent nothing received
* the whole record.
*
* The brand means a call site cannot hand `collectDoctorReportData` an object
* literal. It has to go through one of the three minting functions below, and
* those are the only place the version policy and the unknown-id policy live.
*/
import { z } from "zod/v4";
import { isReportLeafId, type ReportLeafId, ALL_LEAF_IDS } from "./catalogue";
declare const brand: unique symbol;
/** The stored/wire version. Bumped only when the leaf grammar itself changes. */
export const REPORT_SELECTION_VERSION = 2;
export interface ReportSelection {
readonly [brand]: "report-selection";
readonly version: typeof REPORT_SELECTION_VERSION;
/** Whether this selection admits a leaf. The one question every gate asks. */
has(leaf: ReportLeafId): boolean;
/** The chosen leaves in catalogue order — for the audit row and scope line. */
readonly leaves: readonly ReportLeafId[];
}
/** The wire shape of a selection, as sent and as stored. */
export const reportSelectionSchema = z
.object({
v: z.literal(REPORT_SELECTION_VERSION),
leaves: z.array(z.string().min(1).max(64)).max(ALL_LEAF_IDS.length),
})
.strict();
export type ReportSelectionInput = z.infer<typeof reportSelectionSchema>;
const LEAF_ORDER = new Map<ReportLeafId, number>(
ALL_LEAF_IDS.map((leaf, index) => [leaf, index]),
);
function mint(leaves: Iterable<ReportLeafId>): ReportSelection {
const set = new Set<ReportLeafId>(leaves);
const ordered = ALL_LEAF_IDS.filter((leaf) => set.has(leaf));
return {
version: REPORT_SELECTION_VERSION,
has: (leaf: ReportLeafId) => set.has(leaf),
leaves: ordered,
} as unknown as ReportSelection;
}
/**
* Mint from an explicit leaf list — the shipped template, the panel's own
* state, and tests. Callers pass ids the compiler already checked.
*/
export function selectionFromLeaves(
leaves: Iterable<ReportLeafId>,
): ReportSelection {
return mint(leaves);
}
/** The empty selection: nothing chosen, so nothing is served. */
export const EMPTY_REPORT_SELECTION: ReportSelection = mint([]);
/** What went wrong parsing a request selection, for the 422 that names it. */
export interface SelectionRequestError {
/** Leaf ids the caller sent that this build does not know. */
unknownLeaves: string[];
}
/**
* Mint from the Zod-parsed `selection` field of an API body.
*
* Unknown ids are REJECTED rather than dropped: a client sending an id the
* server does not know is a contract mismatch the client has to see, and
* dropping it would silently narrow a scope the caller did express.
*/
export function selectionFromRequest(
input: ReportSelectionInput,
):
| { ok: true; selection: ReportSelection }
| { ok: false; error: SelectionRequestError } {
const unknownLeaves = input.leaves.filter((leaf) => !isReportLeafId(leaf));
if (unknownLeaves.length > 0) {
return { ok: false, error: { unknownLeaves } };
}
return { ok: true, selection: mint(input.leaves as ReportLeafId[]) };
}
/**
* Mint from a stored blob — a share link's frozen `sectionsJson` or the
* owner's saved profile.
*
* Unknown ids are DROPPED and never added to: a leaf removed in a later
* version simply stops being part of that selection. Anything that is not a
* well-formed v2 blob resolves to the EMPTY selection, not to a default set —
* a shape the server cannot read is not consent to anything. That is what
* makes the legacy share links in migration 0273 revocable rather than
* silently re-scoped.
*/
export function selectionFromStoredBlob(raw: unknown): ReportSelection {
const parsed = reportSelectionSchema.safeParse(raw);
if (!parsed.success) return EMPTY_REPORT_SELECTION;
return mint(parsed.data.leaves.filter(isReportLeafId));
}
/** Serialise a selection to the stored / wire shape. */
export function selectionToBlob(
selection: ReportSelection,
): ReportSelectionInput {
return { v: REPORT_SELECTION_VERSION, leaves: [...selection.leaves] };
}
/** Whether this selection admits nothing at all. */
export function isEmptySelection(selection: ReportSelection): boolean {
return selection.leaves.length === 0;
}
/** Stable ordering helper, exported for the panel's own state folding. */
export function orderLeaves(
leaves: Iterable<ReportLeafId>,
): readonly ReportLeafId[] {
return [...new Set(leaves)].sort(
(a, b) => (LEAF_ORDER.get(a) ?? 0) - (LEAF_ORDER.get(b) ?? 0),
);
}
+46
View File
@@ -0,0 +1,46 @@
/**
* The one shipped template: what a general doctor's visit usually needs.
*
* It is applied on the FIRST run only, with the panel open, so the user
* presses Generate having seen twelve named groups and an empty fenced tier.
* That is consent to a named bundle, not a server-chosen default — nothing
* here is applied to a stored selection, to a share link, or to any surface
* that cannot ask a human.
*
* The template intersects the fenced tier at zero leaves, and
* `report-template-purity.test.ts` fails the build if that stops being true.
*/
import { SENSITIVE_LEAF_IDS, type ReportLeafId } from "./catalogue";
/**
* Identity and insurer (the cover page a practice files the document under),
* the three classic vitals, weight and BMI, the glucose series and its panel,
* labs, the drug list and adherence, allergies and past illness episodes.
*
* Deliberately not in it: everything a wearable produces, gait and mobility,
* environment exposure, the per-dose administration ledger (a clinician asks
* for adherence, not for every logged dose), the GLP-1 block, and the whole
* fenced tier.
*/
export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [
"PATIENT_IDENTITY",
"INSURANCE",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"WEIGHT",
"BODY_MASS_INDEX",
"BLOOD_GLUCOSE",
"GLUCOSE_PANEL",
"LAB_RESULTS",
"MEDICATION_LIST",
"MEDICATION_COMPLIANCE",
"ALLERGIES",
"ILLNESS_EPISODES",
];
/** The template's i18n label — it is named in the panel, never anonymous. */
export const STANDARD_TEMPLATE_LABEL_KEY = "reportSelection.templateStandard";
/** Exported so the purity guard reads the same set the panel does. */
export const SENSITIVE_LEAVES = SENSITIVE_LEAF_IDS;
+18 -8
View File
@@ -8,18 +8,28 @@
* widen/update path. `expiresAt` is REQUIRED and capped at
* `SHARE_LINK_MAX_DAYS` so no link ever lives forever.
*
* The `resourceTypes` / `allowFhirApi` keys are gone. A share link
* serves a rendered page and the documents frozen onto it; it has never served
* FHIR, and accepting a scope for a face that does not exist only made the
* promise look kept. An unknown key is rejected by `.strict()`, so a caller
* still sending them gets a 422 that names them rather than a silent no-op.
* The `resourceTypes` / `allowFhirApi` request keys are gone. They configured
* a scoped FHIR face for a share link that was never built, and accepting a
* scope for a face that does not exist only made the promise look kept. An
* unknown key is rejected by `.strict()`, so a caller still sending them gets
* a 422 that names them rather than a silent no-op. The link's record IS
* reachable in machine form now — as a download at `/c/{token}/fhir`, behind
* the same unlock the page uses, serving exactly the frozen selection.
*
* Strict: `.strict()` rejects unknown keys; there is intentionally no `userId`
* field (the owner is always narrowed from `requireAuth()`).
*/
import { z } from "zod/v4";
import { exportSectionsSchema } from "@/lib/validations/health-record-export";
import { reportSelectionSchema } from "@/lib/report-selection/selection";
/**
* The one leaf a share link may never carry. The share view has never
* decrypted the insurance number, so refusing it here makes that structural
* instead of incidental: the create route 422s naming the leaf, and no path
* exists by which a link could later be widened to include it.
*/
export const SHARE_LINK_FORBIDDEN_LEAVES = ["INSURANCE"] as const;
/** Maximum lifetime of a share link, in days. No never-expiring share. */
export const SHARE_LINK_MAX_DAYS = 90;
@@ -48,7 +58,7 @@ export const createShareLinkSchema = z
label: z.string().trim().min(1).max(120),
rangeStart: z.iso.datetime({ offset: true }),
rangeEnd: z.iso.datetime({ offset: true }).nullable().optional(),
sections: exportSectionsSchema.optional(),
selection: reportSelectionSchema.optional(),
documentIds: z
.array(z.string().trim().min(1).max(64))
.max(SHARE_LINK_MAX_DOCUMENTS)
@@ -58,7 +68,7 @@ export const createShareLinkSchema = z
* report scope and mints a link that serves ONLY the attached documents —
* never a health metric. "Share this document" means the document, not the
* whole record. A documents-only share MUST carry at least one
* `documentId` (enforced below). Any `sections` sent alongside are ignored
* `documentId` (enforced below). Any `selection` sent alongside is ignored
* server-side so a document link can never widen into a record.
*/
documentOnly: z.boolean().optional(),
-228
View File
@@ -1,228 +0,0 @@
/**
* v1.4.25 W6c — per-user Doctor-Report section toggles.
*
* Persisted as a Json blob on `User.doctorReportPrefsJson`. Null = the
* documented defaults (every section ON except mood). The dialog reads
* the row, lets the user flip checkboxes, and writes the chosen shape
* back. The PDF generator + the report aggregator both consult the
* shape: aggregator drops mood data entirely when `mood = false` so the
* data never leaves the DB row (privacy-by-default), and the generator
* skips each section whose flag is false at render time.
*
* The shape is intentionally additive — every new data type added to the
* report grows the schema with a new optional flag and a new entry in
* `DEFAULT_DOCTOR_REPORT_PREFS`. Forward-compat: an unknown / drifted
* shape falls back to defaults rather than throwing.
*/
import { z } from "zod/v4";
/**
* Section toggle schema. Every key is optional so a partial update from
* the dialog (e.g., "the user only flipped mood") doesn't have to
* re-state every other flag. The route layer fills missing keys from the
* defaults before persisting so the column shape stays stable.
*/
export const doctorReportPrefsSchema = z
.object({
bp: z.boolean(),
weight: z.boolean(),
pulse: z.boolean(),
bmi: z.boolean(),
mood: z.boolean(),
medicationList: z.boolean(),
compliance: z.boolean(),
sleep: z.boolean(),
glucose: z.boolean(),
cycle: z.boolean(),
labs: z.boolean(),
allergies: z.boolean(),
familyHistory: z.boolean(),
oxygenSaturation: z.boolean(),
bodyFat: z.boolean(),
bodyComposition: z.boolean(),
restingHeartRate: z.boolean(),
hrv: z.boolean(),
vo2max: z.boolean(),
steps: z.boolean(),
distance: z.boolean(),
mentalHealthScreeners: z.boolean(),
})
.partial();
export type DoctorReportPrefsInput = z.infer<typeof doctorReportPrefsSchema>;
/**
* Fully-resolved section toggles. Every key required so the consumers
* (PDF renderer + aggregator) don't have to thread an "is this key
* present?" check through their render paths.
*/
export interface DoctorReportPrefs {
bp: boolean;
weight: boolean;
pulse: boolean;
bmi: boolean;
mood: boolean;
/**
* The active-medication list (names, doses, schedules) and the
* per-dose administration ledger. Previously exported unconditionally while
* the only medication control folded into {@link DoctorReportPrefs.compliance}.
*/
medicationList: boolean;
compliance: boolean;
sleep: boolean;
glucose: boolean;
cycle: boolean;
labs: boolean;
allergies: boolean;
familyHistory: boolean;
/**
* The measurement groups the export panel has always rendered a
* control for but nothing gated. Each one now drives
* `MEASUREMENT_TYPE_SECTION` in the aggregator, so it reaches PDF, FHIR and
* the package alike.
*/
oxygenSaturation: boolean;
bodyFat: boolean;
bodyComposition: boolean;
restingHeartRate: boolean;
hrv: boolean;
vo2max: boolean;
steps: boolean;
distance: boolean;
/**
* PHQ-9 / GAD-7 / WHO-5 / SCI screening totals. Depression,
* anxiety, wellbeing and insomnia instruments: opt-in per artefact like
* mood, never carried by an absent key.
*/
mentalHealthScreeners: boolean;
}
/**
* Defaults applied when the user has never opened the dialog. Every
* section is ON by default EXCEPT mood, which is opt-in per the maintainer's
* privacy directive (2026-05-14): mental-health data should never appear
* in a clinical PDF the user didn't explicitly check.
*/
export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
bp: true,
weight: true,
pulse: true,
bmi: true,
mood: false, // privacy default per the maintainer
// The medication list has always ridden along; the flag exists so the panel's
// single "medications" control can actually withhold it, not to change what a
// caller that omits the key receives.
medicationList: true,
compliance: true,
sleep: true,
// Glucose the user recorded to share with a clinician — ON by default,
// like BP / weight / labs. The per-report toggle lets a diabetic user
// withhold glucose from a SPECIFIC shared report without disabling the
// glucose module app-wide (default-on preserves the pre-toggle behaviour,
// where glucose always rendered).
glucose: true,
// Cycle data is opt-in: a user sharing a BP report with a cardiologist
// should not auto-leak reproductive data. Same privacy stance as mood.
cycle: false,
// Lab results the user recorded for exactly this purpose — sharing
// bloodwork with a clinician. ON by default, like BP / weight.
labs: true,
// Structured allergy / intolerance records — the section every clinical
// intake asks for first. Reference data recorded to share, ON by default
// like labs.
allergies: true,
// Structured family history — same stance as allergies.
familyHistory: true,
// The measurement groups that gained a real gate here. They default ON
// because that is what an omitted key has always produced; the point of the
// flags is that a caller who switches one OFF is now obeyed. The web panel
// sends every one of them explicitly.
oxygenSaturation: true,
bodyFat: true,
bodyComposition: true,
restingHeartRate: true,
hrv: true,
vo2max: true,
steps: true,
distance: true,
// Mental-health screening totals are opt-in per artefact, same stance as
// mood: PHQ-9 and GAD-7 are depression and anxiety instruments and must not
// leave the instance because a caller omitted a key. The `mentalHealth`
// module gate still applies on top — it just no longer stands alone, having
// been default-ON since v1.29.1.
mentalHealthScreeners: false,
};
/**
* The "no section" shape — every toggle OFF. A share link that carries ONLY
* documents (the "share this document, not the whole record" flow) persists
* this so the clinician view resolves to an empty report scope and NO health
* data is ever aggregated for it. Distinct from the null / `{}` case, which
* resolves to {@link DEFAULT_DOCTOR_REPORT_PREFS} (a full record share).
*/
export const EMPTY_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
bp: false,
weight: false,
pulse: false,
bmi: false,
mood: false,
medicationList: false,
compliance: false,
sleep: false,
glucose: false,
cycle: false,
labs: false,
allergies: false,
familyHistory: false,
oxygenSaturation: false,
bodyFat: false,
bodyComposition: false,
restingHeartRate: false,
hrv: false,
vo2max: false,
steps: false,
distance: false,
mentalHealthScreeners: false,
};
/**
* Whether any report section is enabled. A resolved prefs object with every
* flag OFF means "no health report" — the load-bearing signal a documents-only
* share reads to serve zero metrics. The clinician-view data loader gates the
* whole doctor-report aggregation on this: false ⇒ never touch the DB for
* health data.
*/
export function hasAnyReportSection(prefs: DoctorReportPrefs): boolean {
return Object.values(prefs).some(Boolean);
}
/**
* Parse a row's `doctorReportPrefsJson` Json blob into a typed
* `DoctorReportPrefs`, falling back to the documented defaults when the
* row is null OR the persisted shape has drifted (a forward-compat field
* rename, an admin-side hand-edit, etc.). Missing keys are filled from
* the defaults so callers always get a fully-resolved object.
*/
export function parseDoctorReportPrefs(raw: unknown): DoctorReportPrefs {
if (raw == null) return { ...DEFAULT_DOCTOR_REPORT_PREFS };
const parsed = doctorReportPrefsSchema.safeParse(raw);
if (!parsed.success) return { ...DEFAULT_DOCTOR_REPORT_PREFS };
return {
...DEFAULT_DOCTOR_REPORT_PREFS,
...parsed.data,
};
}
/**
* Resolve a partial input (typically from a PUT body) into the full
* canonical shape, layering the supplied keys over the current
* persisted row (or defaults when null). Keeps the route layer free of
* merge plumbing.
*/
export function resolveDoctorReportPrefs(
current: unknown,
incoming: DoctorReportPrefsInput,
): DoctorReportPrefs {
const base = parseDoctorReportPrefs(current);
return { ...base, ...incoming };
}
+20 -204
View File
@@ -1,155 +1,28 @@
/**
* v1.7.0 — health-record / doctor-handover export selection.
* The health-record / doctor-handover export selection.
*
* Single strict Zod schema driving `POST /api/export/health-record`. The
* flagship export route fails loudly on bad input (unlike the legacy
* doctor-report route which tolerates drift) — `.strict()` rejects unknown
* keys, and there is intentionally NO `userId` field (the user is always
* narrowed from `requireAuth()`, never accepted from the body).
* One strict Zod schema driving `POST /api/export/health-record`. `.strict()`
* rejects unknown keys, and there is intentionally NO `userId` field — the
* user is always narrowed from `requireAuth()`, never accepted from the body.
*
* The grouped `sections` shape is additive over the flat
* `DoctorReportPrefs` used by the aggregator + PDF renderer. The
* `toDoctorReportPrefs()` helper folds the grouped toggles down to the
* flat shape the aggregator consumes, so PDF and FHIR describe identical
* numbers (the source-of-truth property the two PDF endpoints share).
* `selection` is REQUIRED and is the leaf inclusion list. The grouped
* `sections` shape it replaces resolved an absent key to "included", so a
* caller who sent nothing received roughly fifty metrics plus the medication
* list, labs, allergies and family history — a scope nobody chose. Sending
* `sections` now 422s with the key named rather than being folded into a
* default, because silently accepting the old shape would mean silently
* choosing a scope the caller did not express.
*
* The AI summary is gone with it. Its text was pulled from the cached
* briefing with no relation to the selection at all, so its scope could not be
* proven — and prose whose scope cannot be proven cannot ride a document whose
* whole point is a proven scope.
*/
import { z } from "zod/v4";
import { locales } from "@/lib/i18n/config";
import {
DEFAULT_DOCTOR_REPORT_PREFS,
parseDoctorReportPrefs,
type DoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
import { reportSelectionSchema } from "@/lib/report-selection/selection";
/** Grouped vital toggles. */
const vitalsGroupSchema = z
.object({
weight: z.boolean(),
bp: z.boolean(),
pulse: z.boolean(),
oxygenSaturation: z.boolean(),
bodyFat: z.boolean(),
bodyComposition: z.boolean(),
})
.partial();
const cardioFitnessGroupSchema = z
.object({
restingHeartRate: z.boolean(),
hrv: z.boolean(),
vo2max: z.boolean(),
})
.partial();
const activityGroupSchema = z
.object({
steps: z.boolean(),
distance: z.boolean(),
sleep: z.boolean(),
})
.partial();
/**
* `list` gates the medication list plus the per-dose administration
* ledger; `compliance` gates the adherence figures and the GLP-1 therapy block.
* The retired `glp1` / `sideEffects` / `activity.energy` keys were accepted and
* discarded: no surface ever rendered them and no code read them. An unknown
* key inside a group is stripped, so a caller still sending one is unaffected.
*/
const medicationsGroupSchema = z
.object({
list: z.boolean(),
compliance: z.boolean(),
})
.partial();
/**
* Grouped section toggles. Every group + key is optional so a partial
* payload (the user flipped one checkbox) doesn't have to re-state the
* whole tree. Missing keys resolve to the documented defaults.
*/
export const exportSectionsSchema = z
.object({
vitals: vitalsGroupSchema,
cardioFitness: cardioFitnessGroupSchema,
activity: activityGroupSchema,
glucose: z.boolean(),
medications: medicationsGroupSchema,
mood: z.boolean(),
bmi: z.boolean(),
// Cycle / reproductive health — opt-in (privacy default OFF, like mood).
cycle: z.boolean(),
// Structured lab results — ON by default (recorded to share with a
// clinician, same stance as BP / weight).
labs: z.boolean(),
// Structured allergy / intolerance records — ON by default (the
// section every clinical intake asks for first; same stance as labs).
allergies: z.boolean(),
// Structured family history — ON by default, same stance as allergies.
familyHistory: z.boolean(),
// PHQ-9 / GAD-7 / WHO-5 / SCI screening totals. OFF unless the
// caller sets it explicitly (`=== true`), like mood and cycle.
mentalHealthScreeners: z.boolean(),
})
.partial();
export type ExportSections = z.infer<typeof exportSectionsSchema>;
/**
* The top-level keys that exist ONLY in the grouped {@link exportSectionsSchema}
* (never in the flat `doctorReportPrefsSchema`). Their presence is the reliable
* signal that a persisted `sectionsJson` blob is the grouped shape rather than
* the flat one — the flat shape carries `bp` / `weight` / `pulse` at the top
* level, the grouped shape nests them under `vitals`.
*
* `glucose` is deliberately NOT a discriminator: it is a top-level boolean in
* BOTH shapes (the flat `DoctorReportPrefs` gained its own `glucose` section
* toggle), so its presence no longer distinguishes grouped from flat. A real
* grouped export always carries one of the nested groups below, so detection
* stays reliable without it — and a bare `{ glucose: false }` blob correctly
* resolves through the flat parser.
*/
const GROUPED_SECTION_KEYS = [
"vitals",
"cardioFitness",
"activity",
"medications",
] as const;
/**
* v1.28.17 — resolve a STORED `sectionsJson` blob (from a clinician share link
* or any consumer that persists the export selection) to the flat
* {@link DoctorReportPrefs} the aggregator + PDF renderer consume.
*
* The share-link create schema accepts the GROUPED {@link exportSectionsSchema}
* and persists it raw, but the report aggregator speaks the FLAT shape. Reading
* a grouped blob through the flat parser silently DROPS every grouped toggle
* (`vitals.bp`, `activity.sleep`, `medications.compliance`) and falls back to
* defaults-ON — so a section the owner switched OFF would still be served. This
* resolver closes that gap: a grouped blob is folded through
* {@link toDoctorReportPrefs}; a flat / empty / `{}` / null blob keeps the exact
* legacy semantics via {@link parseDoctorReportPrefs}. Detection is by the
* grouped-only keys, so it can never misread a flat blob as grouped (or vice
* versa), and it needs no data migration — existing rows resolve correctly.
*/
export function resolveStoredReportSections(raw: unknown): DoctorReportPrefs {
if (raw != null && typeof raw === "object" && !Array.isArray(raw)) {
const obj = raw as Record<string, unknown>;
const looksGrouped = GROUPED_SECTION_KEYS.some((k) => k in obj);
if (looksGrouped) {
const parsed = exportSectionsSchema.safeParse(obj);
// A well-formed grouped blob folds down; a drifted one falls through to
// the flat parser, which itself defaults on any shape it cannot read.
if (parsed.success) return toDoctorReportPrefs(parsed.data);
}
}
return parseDoctorReportPrefs(raw);
}
/**
* Flagship export selection. `.strict()` so unknown keys (including any
* attempt to smuggle a `userId`) 422 via `returnAllZodIssues`.
*/
export const exportSelectionSchema = z
.object({
format: z.enum(["pdf", "fhir", "package"]),
@@ -161,11 +34,11 @@ export const exportSelectionSchema = z
})
.strict()
.optional(),
sections: exportSectionsSchema.optional(),
/** The leaf inclusion list. Required: a scope has to be stated. */
selection: reportSelectionSchema,
locale: z.enum(locales).optional(),
practiceName: z.string().max(120).optional(),
includeCharts: z.boolean().optional(),
includeAiSummary: z.boolean().optional(),
// FHIR only: additionally emit the German BfArM ATC URI alongside the WHO
// entry on each medication concept (WHO stays first, byte-identical). When
// omitted, the route derives it from a German-region locale.
@@ -174,60 +47,3 @@ export const exportSelectionSchema = z
.strict();
export type ExportSelection = z.infer<typeof exportSelectionSchema>;
/**
* Fold the grouped selection down to the flat `DoctorReportPrefs` the
* aggregator + PDF renderer consume. A group toggle that is `true` (or
* absent, falling back to the section default) flips the matching flat
* flag. Mood, cycle and the mental-health screeners stay privacy-default-OFF:
* they only flip on when the caller set them explicitly.
*
* The fold used to read twelve of the twenty keys the schema
* accepts and drop the rest on the floor, so a caller that switched off SpO₂,
* body fat, body composition, resting heart rate, HRV, VO₂max, steps or
* distance was silently overruled and the export panel rendered eight controls
* that changed nothing. Every accepted key now resolves to a flat flag, and
* `doctor-report-control-gating-guard.test.ts` fails the build if a control is
* ever added back without one.
*/
export function toDoctorReportPrefs(
sections: ExportSections | undefined,
): DoctorReportPrefs {
const s = sections ?? {};
const v = s.vitals ?? {};
const c = s.cardioFitness ?? {};
const a = s.activity ?? {};
const m = s.medications ?? {};
const fallback = DEFAULT_DOCTOR_REPORT_PREFS;
return {
weight: v.weight ?? fallback.weight,
bp: v.bp ?? fallback.bp,
pulse: v.pulse ?? fallback.pulse,
bmi: s.bmi ?? fallback.bmi,
// Mood is opt-in: only true when explicitly requested.
mood: s.mood === true,
medicationList: m.list ?? fallback.medicationList,
// A caller that sends only `medications.list = false` means "no medication
// data" — the single panel control has always driven both keys together, so
// the list value keeps standing in for a missing compliance value.
compliance: m.compliance ?? m.list ?? fallback.compliance,
sleep: a.sleep ?? fallback.sleep,
glucose: s.glucose ?? fallback.glucose,
// Cycle is opt-in: only true when explicitly requested (privacy).
cycle: s.cycle === true,
labs: s.labs ?? fallback.labs,
allergies: s.allergies ?? fallback.allergies,
familyHistory: s.familyHistory ?? fallback.familyHistory,
oxygenSaturation: v.oxygenSaturation ?? fallback.oxygenSaturation,
bodyFat: v.bodyFat ?? fallback.bodyFat,
bodyComposition: v.bodyComposition ?? fallback.bodyComposition,
restingHeartRate: c.restingHeartRate ?? fallback.restingHeartRate,
hrv: c.hrv ?? fallback.hrv,
vo2max: c.vo2max ?? fallback.vo2max,
steps: a.steps ?? fallback.steps,
distance: a.distance ?? fallback.distance,
// Screening totals are opt-in: only true when explicitly requested.
mentalHealthScreeners: s.mentalHealthScreeners === true,
};
}