From d4965033c2bec903567b60de94f260a51ef08087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 10 Jul 2026 00:18:57 +0200 Subject: [PATCH] fix(clinician-share): a document share exposes only the document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing a single document from the vault minted a full-record share: the create form always defaulted `resourceTypes` to ["Patient","Observation"] and never signalled a documents-only intent, so the served /c/ view aggregated the whole doctor report — every vital, lab, medication and wellness figure — alongside the attached file. An empty section blob was indistinguishable from "defaults", so the report always resolved to the full record. Introduce an explicit documents-only share: - add a `documentOnly` flag to the create payload; when set the server forces an empty report scope (all-off sections, no FHIR resource types, FHIR API off) and requires at least one document, so a document link can never widen into a record — whatever the body sends. - gate the clinician-view aggregation on an enabled section: a share with no section on never calls the report aggregator, so no health metric leaves the database. The recipient sees only the attached document(s). - the document detail Share action opens the create form in documents-only mode: hide the FHIR toggle, the FHIR resource-type checkboxes and the history-range selector, keep the picker/label/expiry/passphrase, and always carry the launched document into the created link. - Settings sharing keeps the full record-share form unchanged. Extract the create-payload builder as a pure function and cover the scope contract, the empty-scope data load, and the served-view metric-free guarantee with tests; refresh the share e2e and regenerate the OpenAPI contract. --- docs/api/openapi.yaml | 2 + e2e/share-documents.spec.ts | 57 +++++- .../api/share-links/__tests__/route.test.ts | 45 +++++ src/app/api/share-links/route.ts | 21 ++- src/app/c/[token]/page.tsx | 7 +- src/components/clinician/clinician-view.tsx | 67 ++++--- .../documents/document-share-sheet.tsx | 1 + .../__tests__/share-link-create-form.test.tsx | 76 +++++++- .../settings/share-link-create-form.tsx | 168 +++++++++++++----- .../__tests__/share-view-data.test.ts | 72 +++++++- src/lib/clinician-share/share-view-data.ts | 31 +++- src/lib/validations/clinician-share-link.ts | 16 +- src/lib/validations/doctor-report-prefs.ts | 32 ++++ 13 files changed, 503 insertions(+), 92 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9fcb1df37..9c922c3e0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1106,6 +1106,8 @@ paths: type: string minLength: 1 maxLength: 64 + documentOnly: + type: boolean expiresAt: type: string format: date-time diff --git a/e2e/share-documents.spec.ts b/e2e/share-documents.spec.ts index 518853a56..1163bd826 100644 --- a/e2e/share-documents.spec.ts +++ b/e2e/share-documents.spec.ts @@ -234,12 +234,14 @@ test.describe("clinician document sharing", () => { await clinician.context().close(); }); - test("the document detail Share action opens the create flow with that document pre-attached", async ({ + test("the document Share action mints a DOCUMENT-ONLY link — the document, never the record", async ({ page, + browser, }) => { - // Opens two stacked sheets (detail → share) and drives a real create with - // the one-time QR render — heavier than the default 30s budget on a loaded - // runner. Runs on the desktop and 390px mobile projects. + // Opens two stacked sheets (detail → share), drives a real create with the + // one-time QR render, then unlocks the link as a recipient to prove the + // served view carries the document and ZERO health metrics. Heavier than + // the default 30s budget. Runs on the desktop and 390px mobile projects. test.slow(); // Deep-link straight to the document detail sheet. @@ -263,6 +265,13 @@ test.describe("clinician document sharing", () => { await expect(chips).toHaveCount(1); await expect(chips.first()).toContainText("MRT Knie"); + // Document-only mode hides every record-scope control: no FHIR API toggle, + // no FHIR resource-type checkboxes, no history-range selector. Only the + // document, label, and expiry remain. + await expect(shareSheet.locator("#share-fhir")).toHaveCount(0); + await expect(shareSheet.locator("#share-range")).toHaveCount(0); + await expect(shareSheet.locator("#share-expiry")).toHaveCount(1); + // Create the link — the one-time reveal (with the scannable QR) lands in // the same sheet, carrying the one attached document. await shareSheet.getByRole("button", { name: "Create link" }).click(); @@ -276,6 +285,46 @@ test.describe("clinician document sharing", () => { const box = await qrImg.boundingBox(); expect(box, "QR image has a rendered box").not.toBeNull(); expect(box!.width).toBeGreaterThanOrEqual(160); + + // Read the one-time secrets off the reveal and unlock the link as a + // recipient (no owner session). + const shareUrl = (await reveal.locator("code").first().innerText()).trim(); + const token = shareUrl.split("/c/")[1]; + expect(token, "token parsed from share URL").toMatch(/^hls_[0-9a-f]{48}$/); + const passphrase = ( + await shareSheet.getByTestId("share-passphrase").innerText() + ).trim(); + const clinician = await openUnlockedClinician(browser, { + token, + passphrase, + label: "MRT Knie", + }); + + // The served view lists the attached document… + await expect( + clinician.getByRole("heading", { name: "Documents" }), + ).toBeVisible(); + await expect( + clinician.locator( + `iframe[src*="/d/${MRT_DOC_ID}"], img[src*="/d/${MRT_DOC_ID}"]`, + ), + ).toHaveCount(1); + + // …and NOTHING from the health record: no vitals, no labs, no medications, + // no reporting-period line. This is the privacy guarantee — sharing a + // document must never expose a single metric. + await expect( + clinician.getByRole("heading", { name: "Vital signs" }), + ).toHaveCount(0); + await expect( + clinician.getByRole("heading", { name: "Lab values" }), + ).toHaveCount(0); + await expect( + clinician.getByRole("heading", { name: "Medications & adherence" }), + ).toHaveCount(0); + await expect(clinician.getByText("Reporting period:")).toHaveCount(0); + + await clinician.context().close(); }); test("picker enforces the document cap at the limit", async ({ page }) => { diff --git a/src/app/api/share-links/__tests__/route.test.ts b/src/app/api/share-links/__tests__/route.test.ts index 2398adafd..3da5d5ebe 100644 --- a/src/app/api/share-links/__tests__/route.test.ts +++ b/src/app/api/share-links/__tests__/route.test.ts @@ -41,6 +41,7 @@ vi.mock("next/headers", () => ({ import { POST, GET } from "../route"; import { DELETE } from "../[id]/route"; +import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -260,6 +261,50 @@ describe("POST /api/share-links — create", () => { const body = (await res.json()) as { data: { documentCount: number } }; expect(body.data.documentCount).toBe(1); }); + + it("documentOnly FORCES an empty report scope even when the body smuggles one", async () => { + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ + { id: "doc-a" }, + ] as never); + vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue( + storedRow({ resourceTypes: [], _count: { documents: 1 } }) as never, + ); + + // A hostile / stale client tries to widen a document link into a record + // share: FHIR on, resource types set, sections requested. The server must + // ignore every one of them. + const res = await POST( + postReq( + validBody({ + documentOnly: true, + allowFhirApi: true, + resourceTypes: ["Observation", "Patient"], + sections: { vitals: { bp: true }, labs: true }, + documentIds: ["doc-a"], + }), + ), + ); + expect(res.status).toBe(201); + + const createArg = vi.mocked(prisma.clinicianShareLink.create).mock + .calls[0][0]; + // Empty FHIR scope, FHIR API off — no health surface at all. + expect(createArg.data.resourceTypes).toEqual([]); + expect(createArg.data.allowFhirApi).toBe(false); + // Sections persist as an explicit all-OFF blob (distinct from `{}` = full + // record defaults). The clinician view reads this as "no report". + expect(createArg.data.sectionsJson).toEqual(EMPTY_DOCTOR_REPORT_PREFS); + // The picked document still rides along. + expect(createArg.data.documents).toEqual({ + create: [{ documentId: "doc-a" }], + }); + }); + + it("rejects a documentOnly share with no documents (422)", async () => { + const res = await POST(postReq(validBody({ documentOnly: true }))); + expect(res.status).toBe(422); + expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled(); + }); }); describe("GET /api/share-links — list", () => { diff --git a/src/app/api/share-links/route.ts b/src/app/api/share-links/route.ts index 94111c12a..82b32bd2a 100644 --- a/src/app/api/share-links/route.ts +++ b/src/app/api/share-links/route.ts @@ -29,6 +29,7 @@ import { 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 { generatePassphrase, hashPassphrase, @@ -134,6 +135,20 @@ 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 + // columns 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: no + // sections (an explicit all-OFF prefs blob, distinct from the `{}` that means + // "full record defaults"), no FHIR resource types, FHIR API off. "Share this + // document" means the document, not the whole record. + const documentOnly = input.documentOnly === true; + const sectionsJson = documentOnly + ? EMPTY_DOCTOR_REPORT_PREFS + : (input.sections ?? {}); + const resourceTypes = documentOnly ? [] : (input.resourceTypes ?? []); + const allowFhirApi = documentOnly ? false : (input.allowFhirApi ?? false); + annotate({ meta: { documentOnly } }); + // Mint the 192-bit raw token, store only its HMAC hash. const rawToken = `hls_${randomBytes(24).toString("hex")}`; const tokenHash = hashToken(rawToken); @@ -175,9 +190,9 @@ export const POST = apiHandler(async (request: NextRequest) => { label: input.label, rangeStart: new Date(input.rangeStart), rangeEnd: input.rangeEnd ? new Date(input.rangeEnd) : null, - sectionsJson: (input.sections ?? {}) as Prisma.InputJsonValue, - resourceTypes: input.resourceTypes ?? [], - allowFhirApi: input.allowFhirApi ?? false, + sectionsJson: sectionsJson as Prisma.InputJsonValue, + resourceTypes, + allowFhirApi, expiresAt: new Date(input.expiresAt), documents: documentIds.length > 0 diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx index 8900ba51f..6ab0d7554 100644 --- a/src/app/c/[token]/page.tsx +++ b/src/app/c/[token]/page.tsx @@ -103,10 +103,8 @@ export default async function ClinicianSharePage({ const context = await resolveShareToken(token); if (!context) notFound(); - const [{ report, sections, documents }, locale] = await Promise.all([ - loadShareViewData(context), - resolveLocale(), - ]); + const [{ report, sections, documents, documentOnly }, locale] = + await Promise.all([loadShareViewData(context), resolveLocale()]); const { t } = getServerTranslator(locale); return ( @@ -117,6 +115,7 @@ export default async function ClinicianSharePage({ report={report} sections={sections} documents={documents} + documentOnly={documentOnly} token={token} locale={locale} /> diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index 3aaaf7344..420c8a399 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -31,8 +31,19 @@ interface ClinicianViewProps { label: string; /** ISO expiry instant — surfaced so the clinician knows the link lifetime. */ expiresAt: string; - report: DoctorReportData; + /** + * 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. + */ + report: DoctorReportData | null; sections: DoctorReportPrefs; + /** + * 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. + */ + documentOnly?: boolean; /** * The frozen document set on this link (metadata only — never bytes). Each * entry points at the token-scoped serve route (`/c//d/`), the @@ -183,23 +194,28 @@ export function ClinicianView({ report, sections, documents = [], + documentOnly = false, token = "", locale = "en", }: ClinicianViewProps) { const fmtDate = (iso: string) => new Date(iso).toLocaleDateString(); const fmtNum = (n: number) => Math.round(n * 100) / 100; - // Measurement series with at least one stat, mapped to their stats row. - const measurementEntries = Object.entries(report.stats).filter( - ([, s]) => s.count > 0, - ); - const glucoseEntries = Object.entries(report.glucoseStats).filter( - ([, s]) => s.count > 0, - ); - const complianceEntries = sections.compliance - ? Object.entries(report.compliance).filter(([, c]) => c.total > 0) + // 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 wellness = report.wellnessScores?.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 (
{label}

) : null} -

- {t("clinicianView.period", { - start: fmtDate(report.period.start), - end: fmtDate(report.period.end), - })} -

+ {report && !documentOnly ? ( +

+ {t("clinicianView.period", { + start: fmtDate(report.period.start), + end: fmtDate(report.period.end), + })} +

+ ) : null}

{t("clinicianView.expires", { date: fmtDate(expiresAt) })}

@@ -244,7 +262,10 @@ export function ClinicianView({ })} /> ))} - {report.bmi !== null && report.bmi !== undefined && sections.bmi ? ( + {report && + report.bmi !== null && + report.bmi !== undefined && + sections.bmi ? ( 0 ? ( + {medications.length > 0 ? (
- {report.medications.map((med) => { - const comp = report.compliance[med.name]; + {medications.map((med) => { + const comp = report?.compliance[med.name]; const rate = sections.compliance && comp && comp.total > 0 ? `${Math.round((comp.taken / comp.total) * 100)}%` @@ -293,9 +314,7 @@ export function ClinicianView({ ); })} {complianceEntries - .filter( - ([name]) => !report.medications.some((m) => m.name === name), - ) + .filter(([name]) => !medications.some((m) => m.name === name)) .map(([name, c]) => ( {open ? ( diff --git a/src/components/settings/__tests__/share-link-create-form.test.tsx b/src/components/settings/__tests__/share-link-create-form.test.tsx index bbde00093..707c63ca1 100644 --- a/src/components/settings/__tests__/share-link-create-form.test.tsx +++ b/src/components/settings/__tests__/share-link-create-form.test.tsx @@ -25,11 +25,15 @@ vi.mock("@tanstack/react-query", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { ShareLinkCreateForm } from "../share-link-create-form"; +import { + ShareLinkCreateForm, + buildShareLinkCreatePayload, +} from "../share-link-create-form"; import type { PickedDocument } from "../share-document-picker"; function render( props: { + documentOnly?: boolean; initialDocuments?: PickedDocument[]; initialLabel?: string; } = {}, @@ -81,4 +85,74 @@ describe(" — shared create flow", () => { // A seeded set surfaces the EXIF note the picker path also shows. expect(html).toContain("Camera metadata"); }); + + it("document-only mode hides every record-scope control but keeps expiry + the doc", () => { + const html = render({ + documentOnly: true, + initialDocuments: [{ id: "doc-1", title: "Blood panel 2026" }], + initialLabel: "Blood panel 2026", + }); + // No record-scope UI: no FHIR toggle, no FHIR resource-type checkboxes, no + // history-range selector — none of those apply to a document link. + expect(html).not.toContain('id="share-fhir"'); + expect(html).not.toContain('id="share-range"'); + expect(html).not.toContain('id="share-rt-Observation"'); + // Expiry, label, and the pre-attached document all stay. + expect(html).toContain('id="share-expiry"'); + expect(html).toContain('value="Blood panel 2026"'); + expect(html).toContain('data-testid="share-attached-chips"'); + expect(html).toContain("1 of 50 selected"); + }); +}); + +describe("buildShareLinkCreatePayload — scope contract", () => { + const common = { + label: " Blood panel 2026 ", + rangeDays: 30, + expiryDays: 30, + }; + + it("document-only: empty report scope, FHIR off, and the launched doc always rides along", () => { + const payload = buildShareLinkCreatePayload({ + ...common, + // Even if the caller state still holds record-scope values, they must be + // dropped: a document link never carries a health scope. + allowFhirApi: true, + resourceTypes: ["Patient", "Observation"], + documentIds: ["doc-1"], + documentOnly: true, + }); + expect(payload.resourceTypes).toEqual([]); + expect(payload.allowFhirApi).toBe(false); + expect(payload.documentOnly).toBe(true); + // No report sections are ever sent for a document link. + expect(payload).not.toHaveProperty("sections"); + // The launched document is always in the created link (pre-attach fix). + expect(payload.documentIds).toEqual(["doc-1"]); + expect(payload.label).toBe("Blood panel 2026"); + }); + + it("record share: keeps the full scope and omits documentIds when none picked", () => { + const payload = buildShareLinkCreatePayload({ + ...common, + allowFhirApi: false, + resourceTypes: ["Patient", "Observation"], + documentIds: [], + documentOnly: false, + }); + expect(payload.resourceTypes).toEqual(["Patient", "Observation"]); + expect(payload).not.toHaveProperty("documentIds"); + expect(payload).not.toHaveProperty("documentOnly"); + }); + + it("record share: attaches the picked documents when the owner chose some", () => { + const payload = buildShareLinkCreatePayload({ + ...common, + allowFhirApi: false, + resourceTypes: ["Patient", "Observation"], + documentIds: ["a", "b"], + documentOnly: false, + }); + expect(payload.documentIds).toEqual(["a", "b"]); + }); }); diff --git a/src/components/settings/share-link-create-form.tsx b/src/components/settings/share-link-create-form.tsx index b193c10a0..299bd663a 100644 --- a/src/components/settings/share-link-create-form.tsx +++ b/src/components/settings/share-link-create-form.tsx @@ -94,11 +94,80 @@ function isoDaysFromNow(days: number): string { return new Date(Date.now() + days * 86_400_000).toISOString(); } +/** The create body posted to `POST /api/share-links`. */ +export interface ShareLinkCreatePayload { + label: string; + rangeStart: string; + rangeEnd: null; + resourceTypes: ResourceType[]; + allowFhirApi: boolean; + documentIds?: string[]; + documentOnly?: boolean; + expiresAt: string; +} + +/** + * Build the create payload from the resolved form state. Extracted as a pure + * function so the two surfaces' scope contract is unit-testable without a DOM. + * + * The `documentOnly` branch is the privacy-load-bearing one: a document-launched + * share posts an EMPTY report scope (`resourceTypes: []`, no `sections`, FHIR + * off) plus `documentOnly: true`, and ALWAYS carries the picked document ids — + * so the created link serves the document(s) and nothing else. The record share + * (Settings → Sharing) keeps its full scope and only attaches documents when + * the owner picked some. + */ +export function buildShareLinkCreatePayload(input: { + label: string; + rangeDays: number; + expiryDays: number; + allowFhirApi: boolean; + resourceTypes: ResourceType[]; + documentIds: string[]; + documentOnly: boolean; +}): ShareLinkCreatePayload { + const base = { + label: input.label.trim(), + rangeStart: isoDaysFromNow(-input.rangeDays), + rangeEnd: null as null, + expiresAt: isoDaysFromNow(input.expiryDays), + }; + if (input.documentOnly) { + return { + ...base, + resourceTypes: [], + allowFhirApi: false, + documentOnly: true, + // The launched document(s) always ride along — a documents-only share + // with no document would be meaningless (the server 422s it). + documentIds: input.documentIds, + }; + } + return { + ...base, + resourceTypes: input.resourceTypes, + allowFhirApi: input.allowFhirApi, + // Omit the key entirely when nothing is attached (a documents-less record + // share stays the default). The server re-validates each id as the caller's + // own live document before minting the link. + ...(input.documentIds.length > 0 ? { documentIds: input.documentIds } : {}), + }; +} + export function ShareLinkCreateForm({ + documentOnly = false, initialDocuments, initialLabel, onCreated, }: { + /** + * Documents-only mode (the document-launched flow). Hides the record-scope + * controls — FHIR API toggle, FHIR resource-type checkboxes, and the + * history-range selector — and posts an empty report scope so the minted + * link serves ONLY the attached document(s). Keeps the document picker, + * label, expiry, and one-time passphrase reveal. + */ + documentOnly?: boolean; /** Documents to pre-attach — the document-launched flow seeds the one doc. */ initialDocuments?: PickedDocument[]; /** Optional pre-filled label (e.g. the document title). */ @@ -158,27 +227,23 @@ export function ShareLinkCreateForm({ const createMutation = useMutation({ mutationFn: () => { - const trimmed = label.trim(); // Surface the same expiry-bound the server enforces before the round // trip, so the validation feedback is immediate. if (expiryDays < 1 || expiryDays > MAX_DAYS) { return Promise.reject(new Error("EXPIRY_RANGE")); } - return apiPost("/api/share-links", { - label: trimmed, - rangeStart: isoDaysFromNow(-rangeDays), - rangeEnd: null, - resourceTypes, - allowFhirApi, - // v1.28 — the hand-picked, frozen-at-create document set. Omit the key - // entirely when nothing is attached (a documents-less share stays the - // default). The server re-validates each id as the caller's own live - // document before minting the link. - ...(selectedDocs.length > 0 - ? { documentIds: selectedDocs.map((d) => d.id) } - : {}), - expiresAt: isoDaysFromNow(expiryDays), - }); + return apiPost( + "/api/share-links", + buildShareLinkCreatePayload({ + label, + rangeDays, + expiryDays, + allowFhirApi, + resourceTypes, + documentIds: selectedDocs.map((d) => d.id), + documentOnly, + }), + ); }, onSuccess: (result) => { // Clear any stale QR before the effect renders the new one. @@ -240,22 +305,26 @@ export function ShareLinkCreateForm({
-
- - - setRangeDays(Math.max(1, Number(e.target.value) || 1)) - } - /> -

- {t("settings.sharing.rangeHint")} -

-
+ {/* The history-range window only scopes the report — meaningless for + a documents-only share, so it is hidden there. */} + {!documentOnly ? ( +
+ + + setRangeDays(Math.max(1, Number(e.target.value) || 1)) + } + /> +

+ {t("settings.sharing.rangeHint")} +

+
+ ) : null}
-
-
- -

- {t("settings.sharing.fhirApiHint")} -

+ {/* The FHIR API + its resource-type scope expose the health RECORD. + A documents-only link serves no record, so both are hidden and the + posted scope is forced empty server-side regardless. */} + {!documentOnly ? ( +
+
+ +

+ {t("settings.sharing.fhirApiHint")} +

+
+
- -
+ ) : null} - {allowFhirApi && ( + {!documentOnly && allowFhirApi && (
{t("settings.sharing.resourceTypes")} diff --git a/src/lib/clinician-share/__tests__/share-view-data.test.ts b/src/lib/clinician-share/__tests__/share-view-data.test.ts index 5dfe72a85..0cbe7c7f1 100644 --- a/src/lib/clinician-share/__tests__/share-view-data.test.ts +++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts @@ -13,9 +13,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/doctor-report-data", () => ({ collectDoctorReportData: vi.fn(), })); -vi.mock("@/lib/validations/doctor-report-prefs", () => ({ - parseDoctorReportPrefs: vi.fn((s: unknown) => s ?? {}), -})); +// The real prefs parser is used so the "no section enabled ⇒ documents-only" +// signal is exercised end to end (a stubbed parser would let the test lie +// about which sectionsJson resolves to an empty report scope). vi.mock("@/lib/db", () => ({ prisma: { clinicianShareLinkDocument: { findMany: vi.fn() }, @@ -24,6 +24,7 @@ vi.mock("@/lib/db", () => ({ import { loadShareViewData } from "../share-view-data"; import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs"; import { prisma } from "@/lib/db"; import type { ShareContext } from "../resolve-share-token"; @@ -154,3 +155,68 @@ describe("loadShareViewData — KVNR default OFF", () => { expect(range.days).toBeGreaterThan(0); }); }); + +/** + * The load-bearing privacy guarantee: a documents-only share (every report + * section OFF) serves ZERO health metrics. The doctor-report aggregator is + * never called, so no vital / lab / medication / wellness figure ever leaves + * the database — the recipient sees only the attached document(s). "Share this + * document" means the document, not the whole record. + */ +describe("loadShareViewData — documents-only share exposes no health data", () => { + beforeEach(() => { + vi.clearAllMocks(); + collect.mockResolvedValue({ patient: { displayName: "Shared record" } }); + findDocs.mockResolvedValue([]); + }); + + it("never aggregates a report and returns report=null when no section is enabled", async () => { + findDocs.mockResolvedValue([ + { + document: { + id: "doc-a", + title: "Blood panel", + kind: "LAB_REPORT", + documentDate: new Date("2026-01-15T00:00:00Z"), + byteSize: 12345, + mimeType: "application/pdf", + }, + }, + ]); + + const { report, documentOnly, documents } = await loadShareViewData( + ctx({ sectionsJson: EMPTY_DOCTOR_REPORT_PREFS }), + ); + + // The one guarantee: the aggregator is NEVER invoked — no health data is + // read from the DB, let alone served. + expect(collect).not.toHaveBeenCalled(); + expect(report).toBeNull(); + expect(documentOnly).toBe(true); + + // The attached document is still surfaced (metadata only, never bytes). + expect(documents).toEqual([ + { + id: "doc-a", + title: "Blood panel", + kind: "LAB_REPORT", + documentDate: "2026-01-15", + byteSize: 12345, + mimeType: "application/pdf", + servingClass: "inline", + }, + ]); + }); + + it("still aggregates for a record share (defaults resolve to an enabled scope)", async () => { + // `{}` / null sections resolve to the documented defaults (a full record + // share), so the aggregator DOES run — the empty-scope short-circuit must + // not swallow a normal record share. + const { report, documentOnly } = await loadShareViewData( + ctx({ sectionsJson: {} }), + ); + expect(collect).toHaveBeenCalledTimes(1); + expect(documentOnly).toBe(false); + expect(report).not.toBeNull(); + }); +}); diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts index 43e2360b7..10f15774c 100644 --- a/src/lib/clinician-share/share-view-data.ts +++ b/src/lib/clinician-share/share-view-data.ts @@ -20,7 +20,10 @@ import { } from "@/lib/doctor-report-data"; import { servingClassFor } from "@/lib/documents/upload-policy"; import type { DocumentServingClass } from "@/lib/documents/upload-policy"; -import { parseDoctorReportPrefs } from "@/lib/validations/doctor-report-prefs"; +import { + hasAnyReportSection, + parseDoctorReportPrefs, +} from "@/lib/validations/doctor-report-prefs"; import type { ShareContext } from "@/lib/clinician-share/resolve-share-token"; /** @@ -47,12 +50,23 @@ export interface ShareViewDocument { } export interface ShareViewData { - /** The aggregated, owner-scoped report payload over the frozen window. */ - report: DoctorReportData; + /** + * The aggregated, owner-scoped report payload over the frozen window, or + * `null` for a documents-only share. `null` is the load-bearing privacy + * state: the aggregator is NEVER called, so no health data leaves the DB — + * the recipient sees only the attached documents. + */ + report: DoctorReportData | null; /** The resolved section toggles (mood opt-in, defaults otherwise). */ sections: ReturnType; /** v1.28 — the hand-picked documents on this link (metadata only). */ documents: ShareViewDocument[]; + /** + * v1.28.13 — whether this link carries ONLY documents (no report section + * enabled). The public view reads it to render a documents-only surface with + * no health-record chrome. + */ + documentOnly: boolean; } /** @@ -81,12 +95,19 @@ export async function loadShareViewData( const sections = parseDoctorReportPrefs(context.sectionsJson); const range = frozenRange(context); + // A share with NO report section enabled is a documents-only share: never + // aggregate — no health metric is read from the DB, let alone served. This is + // the load-bearing guarantee behind "share this document, not the record". + const documentOnly = !hasAnyReportSection(sections); + const [report, documents] = await Promise.all([ - collectDoctorReportData(context.ownerUserId, range, { sections }), + documentOnly + ? Promise.resolve(null) + : collectDoctorReportData(context.ownerUserId, range, { sections }), loadShareDocuments(context), ]); - return { report, sections, documents }; + return { report, sections, documents, documentOnly }; } /** diff --git a/src/lib/validations/clinician-share-link.ts b/src/lib/validations/clinician-share-link.ts index 41fe80d54..fb98b488e 100644 --- a/src/lib/validations/clinician-share-link.ts +++ b/src/lib/validations/clinician-share-link.ts @@ -62,6 +62,16 @@ export const createShareLinkSchema = z .array(z.string().trim().min(1).max(64)) .max(SHARE_LINK_MAX_DOCUMENTS) .optional(), + /** + * v1.28.13 — a documents-only share. When true the server FORCES an empty + * report scope (no sections, no FHIR resource types, FHIR API off) 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` / `resourceTypes` / `allowFhirApi` sent alongside + * are ignored server-side so a document link can never widen into a record. + */ + documentOnly: z.boolean().optional(), expiresAt: z.iso .datetime({ offset: true }) .refine((v) => new Date(v).getTime() > Date.now(), { @@ -77,7 +87,11 @@ export const createShareLinkSchema = z v.rangeEnd == null || new Date(v.rangeEnd).getTime() >= new Date(v.rangeStart).getTime(), { message: "rangeEnd must not precede rangeStart", path: ["rangeEnd"] }, - ); + ) + .refine((v) => !v.documentOnly || (v.documentIds?.length ?? 0) > 0, { + message: "A documents-only share must carry at least one document", + path: ["documentIds"], + }); export type CreateShareLinkInput = z.infer; diff --git a/src/lib/validations/doctor-report-prefs.ts b/src/lib/validations/doctor-report-prefs.ts index e19e93df1..c4a0daddb 100644 --- a/src/lib/validations/doctor-report-prefs.ts +++ b/src/lib/validations/doctor-report-prefs.ts @@ -87,6 +87,38 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = { familyHistory: true, }; +/** + * 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, + compliance: false, + sleep: false, + cycle: false, + labs: false, + allergies: false, + familyHistory: 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