fix(clinician-share): a document share exposes only the document

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/<token> 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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-10 00:18:57 +02:00
1 parent fa42c8bc0b
commit d4965033c2
13 files changed
+503 -92

No files matched your search

@@ -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();
});
});
+26 -5
View File
@@ -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<typeof parseDoctorReportPrefs>;
/** 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 };
}
/**
+15 -1
View File
@@ -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<typeof createShareLinkSchema>;
@@ -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