From 9e68ff525f35dc23168b4635823ae28e402ed680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 18:57:45 +0200 Subject: [PATCH] feat(share): serve the record a link already implies, and say when there is none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two downloads under the token — the FHIR document Bundle and the clinical PDF — behind the same unlock cookie the document route enforces, serving exactly the link's frozen selection. That is what the removed FHIR toggle promised, in the form a practice actually uses: open the link, unlock it, save the file. 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 cookie, the same blunt 404 for every miss class so a probe cannot tell "no such link" from "revoked" from "documents-only". It differs in one number, because generating a bundle is far more expensive than serving a stored blob and must not share a budget with it. Each download writes its own audit row. The scope cannot exceed the page's: both resolve the same frozen leaf list through the same resolver, and the insurance leaf is refused at create, so the insurance number cannot reach this path at all. The MCP doctor-visit summary stops claiming presence it cannot support. An account whose owner never saved a report selection resolves to the empty one on that surface, and an empty scope produces an empty payload — which now reads as { present: false, reason }, the same sentinel the other reads use, instead of a record with every section quietly missing. Mutation-checked: removing the unlock check, the rate limit, the documents-only guard or the audit row reddens its own case; pointing the bucket at the document route's key reddens the bucket assertion; making presence unconditional reddens the absence case. The saved profile's shape moves out of the module that reads the row. The panel is a client component and parses the blob from the /me payload; importing the reader dragged the database client into the browser bundle, which typecheck and the unit suite both pass and only `next build` catches. --- src/app/api/auth/me/report-selection/route.ts | 2 +- .../health-record/__tests__/route.test.ts | 58 ++++ .../[token]/__tests__/report-download.test.ts | 278 ++++++++++++++++++ .../report-selection/selection-panel.tsx | 4 +- .../__tests__/leaf-gating-sweep.test.ts | 78 +++++ .../__tests__/doctor-visit-summary.test.ts | 86 ++++++ src/lib/mcp/__tests__/resources.test.ts | 4 +- src/lib/mcp/doctor-visit-summary.ts | 27 ++ src/lib/report-selection/profile-shape.ts | 43 +++ src/lib/report-selection/saved-profile.ts | 44 +-- 10 files changed, 586 insertions(+), 38 deletions(-) create mode 100644 src/app/c/[token]/__tests__/report-download.test.ts create mode 100644 src/lib/mcp/__tests__/doctor-visit-summary.test.ts create mode 100644 src/lib/report-selection/profile-shape.ts diff --git a/src/app/api/auth/me/report-selection/route.ts b/src/app/api/auth/me/report-selection/route.ts index 06b9706b0..dd9c4b6a9 100644 --- a/src/app/api/auth/me/report-selection/route.ts +++ b/src/app/api/auth/me/report-selection/route.ts @@ -21,7 +21,7 @@ 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 { savedReportProfileSchema } from "@/lib/report-selection/profile-shape"; import { selectionFromRequest } from "@/lib/report-selection/selection"; export const dynamic = "force-dynamic"; diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index f8ab6cc74..e17570f34 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -47,6 +47,7 @@ vi.mock("@/lib/cycle/export-data", () => ({ currentPhase: "FOLLICULAR", })), })); +vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "A123456789") })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -80,6 +81,7 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => { import { prisma } from "@/lib/db"; import { ALL_LEAF_IDS } from "@/lib/report-selection/catalogue"; +import { decrypt } from "@/lib/crypto"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { @@ -723,3 +725,59 @@ describe("POST /api/export/health-record — what the route remembers", () => { ); }); }); + +/** + * The insurance number is its own leaf, and it is the most sensitive value on + * this path: encrypted at rest, and the one identifier a share link may never + * carry at all. + * + * Mutation check: drop the `selection.has("INSURANCE")` condition on the + * decrypt and "never decrypts the insurance number" goes red. + */ +describe("POST /api/export/health-record — the insurance leaf", () => { + beforeEach(() => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + username: "sample", + dateOfBirth: null, + gender: null, + heightCm: null, + glucoseUnit: null, + thresholdsJson: null, + fullName: null, + insurerName: "Sample Insurer", + insurerIkNumber: "123456789", + insuranceNumberEncrypted: Buffer.from("ciphertext"), + } as never); + }); + + it("never decrypts the insurance number when the leaf was not chosen", async () => { + const { POST } = await import("../route"); + const res = await POST( + mkReq({ format: "fhir", selection: sel("WEIGHT", "PATIENT_IDENTITY") }), + ); + expect(res.status).toBe(200); + // Not decrypted, so it never entered this process's memory — the stronger + // statement than "it was decrypted and then left out". + expect(decrypt).not.toHaveBeenCalled(); + const bundle = (await res.json()) as { + entry: { resource: { resourceType: string } }[]; + }; + expect(bundle.entry.map((e) => e.resource.resourceType)).not.toContain( + "Coverage", + ); + }); + + it("decrypts and carries it when the leaf was chosen by name", async () => { + const { POST } = await import("../route"); + const res = await POST( + mkReq({ + format: "fhir", + selection: sel("WEIGHT", "PATIENT_IDENTITY", "INSURANCE"), + }), + ); + expect(res.status).toBe(200); + expect(decrypt).toHaveBeenCalled(); + const text = JSON.stringify(await res.json()); + expect(text).toContain("A123456789"); + }); +}); diff --git a/src/app/c/[token]/__tests__/report-download.test.ts b/src/app/c/[token]/__tests__/report-download.test.ts new file mode 100644 index 000000000..3bfde19f6 --- /dev/null +++ b/src/app/c/[token]/__tests__/report-download.test.ts @@ -0,0 +1,278 @@ +/** + * The two machine-format downloads on a share link. + * + * A practice opens the link, unlocks it, and saves the file into their system. + * That is what the removed FHIR toggle promised and never served. The gate has + * to be the document route's, step for step — the same live check, the same + * token-scoped unlock cookie, a rate limit before any aggregation, and the same + * blunt 404 for every miss class, so a probe cannot tell "no such link" from + * "revoked" from "documents-only". + * + * The load-bearing scope property: the download resolves the SAME frozen + * selection the page renders from, so it can never be wider than the page. + * + * Mutation checks: + * - remove the unlock-cookie check in `resolveShareReportDownload` → + * "refuses a protected link without the unlock cookie" goes red. + * - remove the rate-limit check → "refuses over the rate limit" goes red. + * - drop the `view.documentOnly` guard → "refuses a documents-only link" + * goes red. + * - change the bucket key to the document route's `share-documents:` → + * "uses its own rate bucket" goes red. + * - drop the `auditLog` call → "records an audit row per download" goes red. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("next/headers", () => ({ cookies: vi.fn() })); +vi.mock("@/lib/clinician-share/resolve-share-token", () => ({ + resolveShareGateState: vi.fn(), + resolveShareToken: vi.fn(), +})); +vi.mock("@/lib/clinician-share/unlock-cookie", () => ({ + unlockCookieName: (hash: string) => `hls_unlock_${hash}`, + verifyUnlockValue: vi.fn(), +})); +vi.mock("@/lib/clinician-share/share-view-data", () => ({ + loadShareViewData: vi.fn(), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() })); +vi.mock("@/lib/i18n/server-translator", async (importOriginal) => { + const actual = + await importOriginal(); + return actual; +}); + +import { cookies } from "next/headers"; +import { + resolveShareGateState, + resolveShareToken, +} from "@/lib/clinician-share/resolve-share-token"; +import { verifyUnlockValue } from "@/lib/clinician-share/unlock-cookie"; +import { loadShareViewData } from "@/lib/clinician-share/share-view-data"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { auditLog } from "@/lib/auth/audit"; +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import { selectionFromLeaves } from "@/lib/report-selection/selection"; +import type { DoctorReportData } from "@/lib/doctor-report-data"; + +const TOKEN = "hls_deadbeef"; + +function report(): DoctorReportData { + return { + period: { + days: 30, + since: "2026-01-01", + start: "2026-01-01T00:00:00.000Z", + end: "2026-01-31T00:00:00.000Z", + }, + patient: { + username: null, + dateOfBirth: null, + gender: null, + heightCm: null, + }, + practiceName: null, + measurements: {}, + stats: { WEIGHT: { avg: 80, min: 78, max: 82, count: 4, latest: 79 } }, + glucoseStats: {}, + glucoseRanges: {}, + glucoseClinical: computeGlucoseClinicalMetrics([], { + now: new Date("2026-01-31T00:00:00.000Z"), + }), + glucoseUnit: "mg/dL", + bmi: null, + compliance: {}, + medications: [], + mood: null, + wellnessScores: null, + } as DoctorReportData; +} + +function req(): Request { + return new Request(`http://localhost/c/${TOKEN}/fhir`); +} + +const params = { params: Promise.resolve({ token: TOKEN }) }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(resolveShareGateState).mockResolvedValue({ + tokenHash: "hash-1", + passphraseHash: "pass-hash", + } as never); + vi.mocked(cookies).mockResolvedValue({ + get: () => ({ value: "unlock-value" }), + } as never); + vi.mocked(verifyUnlockValue).mockReturnValue(true); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + count: 1, + resetAt: new Date(), + } as never); + vi.mocked(resolveShareToken).mockResolvedValue({ + shareLinkId: "link-1", + ownerUserId: "owner-1", + } as never); + vi.mocked(loadShareViewData).mockResolvedValue({ + report: report(), + selection: selectionFromLeaves(["WEIGHT"]), + documents: [], + documentOnly: false, + } as never); +}); + +describe("GET /c/{token}/fhir", () => { + it("serves the link's frozen scope as a document Bundle", async () => { + const { GET } = await import("../fhir/route"); + const res = await GET(req(), params); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/fhir+json"); + expect(res.headers.get("content-disposition")).toContain("attachment"); + expect(res.headers.get("cache-control")).toBe("no-store"); + const bundle = (await res.json()) as { + resourceType: string; + type: string; + }; + expect(bundle.resourceType).toBe("Bundle"); + expect(bundle.type).toBe("document"); + }); + + it("carries no Coverage — the insurance leaf cannot reach this path", async () => { + const { GET } = await import("../fhir/route"); + const res = await GET(req(), params); + const bundle = (await res.json()) as { + entry: { resource: { resourceType: string } }[]; + }; + expect(bundle.entry.map((e) => e.resource.resourceType)).not.toContain( + "Coverage", + ); + }); + + it("refuses a protected link without the unlock cookie", async () => { + vi.mocked(verifyUnlockValue).mockReturnValue(false); + const { GET } = await import("../fhir/route"); + const res = await GET(req(), params); + expect(res.status).toBe(404); + expect(loadShareViewData).not.toHaveBeenCalled(); + }); + + it("refuses an unknown, revoked or expired token with the same flat 404", async () => { + vi.mocked(resolveShareGateState).mockResolvedValue(null as never); + const { GET } = await import("../fhir/route"); + const res = await GET(req(), params); + expect(res.status).toBe(404); + expect(checkRateLimit).not.toHaveBeenCalled(); + }); + + it("refuses a documents-only link even if a report came back with it", async () => { + // The flag is authoritative on its own. `loadShareViewData` returns a null + // report alongside it today, so the two conditions agree — this fixture + // separates them so the flag's own check is provably load-bearing rather + // than carried by its neighbour. + vi.mocked(loadShareViewData).mockResolvedValue({ + report: report(), + selection: selectionFromLeaves(["WEIGHT"]), + documents: [], + documentOnly: true, + } as never); + const { GET } = await import("../fhir/route"); + const res = await GET(req(), params); + expect(res.status).toBe(404); + }); + + it("refuses a link that produced no report", async () => { + vi.mocked(loadShareViewData).mockResolvedValue({ + report: null, + selection: selectionFromLeaves([]), + documents: [], + documentOnly: false, + } as never); + const { GET } = await import("../fhir/route"); + const res = await GET(req(), params); + expect(res.status).toBe(404); + }); + + it("refuses over the rate limit, before any aggregation", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + count: 21, + resetAt: new Date(), + } as never); + const { GET } = await import("../fhir/route"); + const res = await GET(req(), params); + expect(res.status).toBe(429); + expect(loadShareViewData).not.toHaveBeenCalled(); + }); + + it("uses its own rate bucket, not the document route's", async () => { + const { GET } = await import("../fhir/route"); + await GET(req(), params); + // Generating a bundle is far more expensive than serving a stored blob and + // must not share a budget with it. + expect(checkRateLimit).toHaveBeenCalledWith( + "report-download:hash-1", + 20, + 60 * 60 * 1000, + ); + }); + + it("records an audit row per download, scoped to the owner", async () => { + const { GET } = await import("../fhir/route"); + await GET(req(), params); + expect(auditLog).toHaveBeenCalledWith( + "share-link.report.download", + expect.objectContaining({ + userId: "owner-1", + details: expect.objectContaining({ + shareLinkId: "link-1", + format: "fhir", + leafCount: 1, + }), + }), + ); + }); +}); + +describe("GET /c/{token}/report.pdf", () => { + it("serves the link's frozen scope as a PDF", async () => { + const { GET } = await import("../report.pdf/route"); + const res = await GET( + new Request(`http://localhost/c/${TOKEN}/report.pdf`), + params, + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + expect(res.headers.get("content-disposition")).toContain("attachment"); + const buf = Buffer.from(await res.arrayBuffer()); + expect(buf.subarray(0, 5).toString("latin1")).toBe("%PDF-"); + }); + + it("runs the same gate as the bundle beside it", async () => { + vi.mocked(verifyUnlockValue).mockReturnValue(false); + const { GET } = await import("../report.pdf/route"); + const res = await GET( + new Request(`http://localhost/c/${TOKEN}/report.pdf`), + params, + ); + expect(res.status).toBe(404); + }); + + it("records its own format in the audit row", async () => { + const { GET } = await import("../report.pdf/route"); + await GET(new Request(`http://localhost/c/${TOKEN}/report.pdf`), params); + expect(auditLog).toHaveBeenCalledWith( + "share-link.report.download", + expect.objectContaining({ + details: expect.objectContaining({ format: "pdf" }), + }), + ); + }); +}); diff --git a/src/components/settings/report-selection/selection-panel.tsx b/src/components/settings/report-selection/selection-panel.tsx index 0c5da336a..7ef806750 100644 --- a/src/components/settings/report-selection/selection-panel.tsx +++ b/src/components/settings/report-selection/selection-panel.tsx @@ -54,7 +54,7 @@ import type { ReportLeafId } from "@/lib/report-selection/catalogue"; import { parseSavedProfile, SAVED_PROFILE_FALLBACK, -} from "@/lib/report-selection/saved-profile"; +} from "@/lib/report-selection/profile-shape"; import { STANDARD_TEMPLATE_LABEL_KEY, STANDARD_TEMPLATE_LEAVES, @@ -82,7 +82,7 @@ export function HealthRecordExportPanel() { const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); const [practiceName, setPracticeName] = useState(""); - const [includeCharts, setIncludeCharts] = useState( + const [includeCharts, setIncludeCharts] = useState( SAVED_PROFILE_FALLBACK.includeCharts, ); const [selected, setSelected] = useState>( diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index f5b37ae23..1bfab7e74 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -287,6 +287,84 @@ describe("per-leaf gating sweep", () => { }, 30_000); }); +describe("the derived figures ride their own leaf", () => { + it("withholds the BMI figure while the weight series stays", async () => { + // The sweep alone cannot isolate this: BODY_MASS_INDEX also gates its own + // measurement rows, so the payload differs either way. The figure needs + // its own assertion, because weight rows stay readable for exactly this + // computation and it would be easy to leave the result ungated. + const withBmi = await collectDoctorReportData( + "u1", + RANGE, + selectionFromLeaves(["WEIGHT", "BODY_MASS_INDEX"]), + { moduleMap: moduleMap() }, + ); + expect(withBmi.bmi).not.toBeNull(); + + const withoutBmi = await collectDoctorReportData( + "u1", + RANGE, + selectionFromLeaves(["WEIGHT"]), + { moduleMap: moduleMap() }, + ); + expect(withoutBmi.bmi).toBeNull(); + expect(withoutBmi.stats.WEIGHT).toBeDefined(); + }); + + it("computes the BMI figure without carrying the weight series", async () => { + // The other half: choosing the figure and not the series still needs the + // weight rows read, and must not smuggle the series out with them. + const payload = await collectDoctorReportData( + "u1", + RANGE, + selectionFromLeaves(["BODY_MASS_INDEX"]), + { moduleMap: moduleMap() }, + ); + expect(payload.bmi).not.toBeNull(); + expect(payload.stats.WEIGHT).toBeUndefined(); + expect(payload.measurements.WEIGHT).toBeUndefined(); + }); + + it("withholds the GLP-1 block while its inputs stay readable", async () => { + const on = await collectDoctorReportData( + "u1", + RANGE, + selectionFromLeaves(["GLP1_THERAPY"]), + { moduleMap: moduleMap() }, + ); + expect(on.glp1).not.toBeNull(); + + const off = await collectDoctorReportData( + "u1", + RANGE, + selectionFromLeaves(["MEDICATION_LIST", "MEDICATION_COMPLIANCE"]), + { moduleMap: moduleMap() }, + ); + expect(off.glp1).toBeNull(); + expect(off.medications.length).toBeGreaterThan(0); + }); + + it("withholds the glucose panel while the raw series stays", async () => { + const withPanel = await collectDoctorReportData( + "u1", + RANGE, + selectionFromLeaves(["BLOOD_GLUCOSE", "GLUCOSE_PANEL"]), + { moduleMap: moduleMap() }, + ); + expect(Object.keys(withPanel.glucoseStats).length).toBeGreaterThan(0); + + const seriesOnly = await collectDoctorReportData( + "u1", + RANGE, + selectionFromLeaves(["BLOOD_GLUCOSE"]), + { moduleMap: moduleMap() }, + ); + expect(seriesOnly.glucoseStats).toEqual({}); + expect(seriesOnly.glucoseClinical.readingCount).toBe(0); + expect(seriesOnly.stats.BLOOD_GLUCOSE).toBeDefined(); + }); +}); + describe("fail-closed exclusion", () => { it("excludes a measurement type the catalogue does not know", () => { const gate = resolveReportGate( diff --git a/src/lib/mcp/__tests__/doctor-visit-summary.test.ts b/src/lib/mcp/__tests__/doctor-visit-summary.test.ts new file mode 100644 index 000000000..01e81b3b4 --- /dev/null +++ b/src/lib/mcp/__tests__/doctor-visit-summary.test.ts @@ -0,0 +1,86 @@ +/** + * The doctor-visit summariser, and what it says when there is nothing to say. + * + * The MCP surfaces cannot ask a human, so they replay the owner's saved report + * selection. An account that never saved one resolves to the empty selection + * and the aggregator returns an empty payload — which has to read as "nothing + * was recorded for this", not as a record whose every section happens to be + * missing. The `{ present: false, reason }` sentinel is the same one the other + * MCP reads use. + * + * Mutation checks: + * - make `present` unconditionally `true` again → "reports absence for an + * empty scope" goes red. + * - drop `data.bmi !== null` from the `hasContent` test → "counts a lone BMI + * figure as content" goes red. + */ +import { describe, it, expect } from "vitest"; + +import { summariseForVisit } from "../doctor-visit-summary"; +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import type { DoctorReportData } from "@/lib/doctor-report-data"; + +function payload(overrides: Partial = {}): DoctorReportData { + return { + period: { + days: 90, + since: "2026-01-01", + start: "2026-01-01T00:00:00.000Z", + end: "2026-04-01T00:00:00.000Z", + }, + patient: { + username: null, + dateOfBirth: null, + gender: null, + heightCm: null, + }, + practiceName: null, + measurements: {}, + stats: {}, + glucoseStats: {}, + glucoseRanges: {}, + glucoseClinical: computeGlucoseClinicalMetrics([], { + now: new Date("2026-04-01T00:00:00.000Z"), + }), + glucoseUnit: "mg/dL", + bmi: null, + compliance: {}, + medications: [], + wellnessScores: null, + ...overrides, + } as DoctorReportData; +} + +describe("summariseForVisit", () => { + it("reports absence for an empty scope, with the period it looked at", () => { + const summary = summariseForVisit(payload()); + expect(summary.present).toBe(false); + expect(summary.reason).toBe("empty_report_selection"); + expect(summary.period).toEqual({ + days: 90, + start: "2026-01-01T00:00:00.000Z", + end: "2026-04-01T00:00:00.000Z", + }); + // Nothing is zero-filled in its place. + expect(summary).not.toHaveProperty("vitals"); + expect(summary).not.toHaveProperty("medications"); + }); + + it("reports presence as soon as one metric survives the gate", () => { + const summary = summariseForVisit( + payload({ + stats: { WEIGHT: { avg: 80, min: 78, max: 82, count: 4, latest: 79 } }, + }), + ); + expect(summary.present).toBe(true); + expect(summary.vitals).toHaveLength(1); + }); + + it("counts a lone BMI figure as content", () => { + // The BMI figure can be the only thing chosen — the weight series stays + // withheld — and a summary carrying it is not an empty one. + const summary = summariseForVisit(payload({ bmi: 24.5 })); + expect(summary.present).toBe(true); + expect(summary.bmi).toBe(24.5); + }); +}); diff --git a/src/lib/mcp/__tests__/resources.test.ts b/src/lib/mcp/__tests__/resources.test.ts index c3da9bfbb..eae946c0a 100644 --- a/src/lib/mcp/__tests__/resources.test.ts +++ b/src/lib/mcp/__tests__/resources.test.ts @@ -256,7 +256,9 @@ describe("healthlog://report/doctor-visit", () => { vi.mocked(collectDoctorReportData).mockResolvedValue({ period: { days: 90, since: "", start: "", end: "" }, patient: { dateOfBirth: "1990-01-01", gender: "female", heightCm: 170 }, - stats: {}, + // One metric survived the gate, so the record is present; everything + // else is absent and must stay absent rather than being zero-filled. + stats: { WEIGHT: { avg: 80, min: 78, max: 82, count: 4, latest: 79 } }, glucoseClinical: { stillLearning: true }, bmi: null, compliance: {}, diff --git a/src/lib/mcp/doctor-visit-summary.ts b/src/lib/mcp/doctor-visit-summary.ts index 23f4e8467..53af77e41 100644 --- a/src/lib/mcp/doctor-visit-summary.ts +++ b/src/lib/mcp/doctor-visit-summary.ts @@ -112,6 +112,33 @@ export function summariseForVisit( resolvedAt: e.resolvedAt, })); + // Absence is honest here too. An account whose owner never saved a report + // selection resolves to the empty one on this surface, and an empty scope + // produces an empty payload — which must read as "nothing was recorded for + // this", not as a record with every section quietly missing. The + // `{ present: false, reason }` sentinel is the same one the other MCP reads + // use, so an assistant already knows how to read it. + const hasContent = + vitals.length > 0 || + medications.length > 0 || + compliance.length > 0 || + labs.length > 0 || + wellness.length > 0 || + illness.length > 0 || + data.mood != null || + data.bmi != null; + if (!hasContent) { + return { + present: false, + reason: "empty_report_selection", + period: { + days: data.period.days, + start: data.period.start, + end: data.period.end, + }, + }; + } + return { present: true, period: { diff --git a/src/lib/report-selection/profile-shape.ts b/src/lib/report-selection/profile-shape.ts new file mode 100644 index 000000000..d531175c1 --- /dev/null +++ b/src/lib/report-selection/profile-shape.ts @@ -0,0 +1,43 @@ +/** + * The shape of the owner's saved report profile, without the read that + * fetches it. + * + * The export panel is a client component and needs to parse the blob that + * rides the `/api/auth/me` payload. Keeping the schema here means it can do + * that without pulling the Prisma client into the browser bundle — the same + * separation `saved-profile.ts` documents on the server side. + */ +import { z } from "zod/v4"; + +import { reportSelectionSchema } 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; + +/** + * The render choices the panel starts from when the account has never saved a + * profile. Presentation only — this is deliberately NOT a scope fallback; + * there is no such thing here, because a scope nobody chose is the defect. + */ +export const SAVED_PROFILE_FALLBACK = { + format: "pdf", + rangeDays: 90, + includeCharts: true, +} as const; + +/** Parse a stored profile blob, or `null` when there is none this build reads. */ +export function parseSavedProfile(raw: unknown): SavedReportProfile | null { + const parsed = savedReportProfileSchema.safeParse(raw); + return parsed.success ? parsed.data : null; +} diff --git a/src/lib/report-selection/saved-profile.ts b/src/lib/report-selection/saved-profile.ts index b0615a71e..987c2c02c 100644 --- a/src/lib/report-selection/saved-profile.ts +++ b/src/lib/report-selection/saved-profile.ts @@ -1,5 +1,5 @@ /** - * The owner's saved report selection — "my selection". + * The owner's saved report selection — "my selection" — read from the row. * * Persisted on `User.reportSelectionJson` and written after a successful * generation, the same way the practice name is. Two kinds of caller read it: @@ -15,41 +15,23 @@ * 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. + * + * The shape itself lives in `./profile-shape` so the client panel can parse + * the blob without pulling the Prisma client into the browser bundle. */ -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; - -/** - * The render choices the panel starts from when the account has never saved a - * profile. Presentation only — this is deliberately NOT a scope fallback; - * there is no such thing here, because a scope nobody chose is the defect. - */ -export const SAVED_PROFILE_FALLBACK = { - format: "pdf", - rangeDays: 90, - includeCharts: true, -} as const; +export { + savedReportProfileSchema, + parseSavedProfile, + SAVED_PROFILE_FALLBACK, + type SavedReportProfile, +} from "./profile-shape"; /** * Resolve the owner's saved selection. @@ -68,9 +50,3 @@ export async function resolveSavedSelection( 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; -}