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