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

@@ -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 });
});