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

+43 -24
View File
@@ -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/<token>/d/<id>`), 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 (
<main
@@ -214,12 +230,14 @@ export function ClinicianView({
{label ? (
<p className="text-muted-foreground mt-1 text-sm">{label}</p>
) : null}
<p className="text-muted-foreground mt-3 text-sm">
{t("clinicianView.period", {
start: fmtDate(report.period.start),
end: fmtDate(report.period.end),
})}
</p>
{report && !documentOnly ? (
<p className="text-muted-foreground mt-3 text-sm">
{t("clinicianView.period", {
start: fmtDate(report.period.start),
end: fmtDate(report.period.end),
})}
</p>
) : null}
<p className="text-muted-foreground mt-1 text-xs">
{t("clinicianView.expires", { date: fmtDate(expiresAt) })}
</p>
@@ -244,7 +262,10 @@ export function ClinicianView({
})}
/>
))}
{report.bmi !== null && report.bmi !== undefined && sections.bmi ? (
{report &&
report.bmi !== null &&
report.bmi !== undefined &&
sections.bmi ? (
<StatRow
label={t("clinicianView.bmi")}
value={String(fmtNum(report.bmi))}
@@ -272,10 +293,10 @@ export function ClinicianView({
) : null}
{/* ── Medications + adherence ─────────────────────────────── */}
{report.medications.length > 0 ? (
{medications.length > 0 ? (
<Section title={t("clinicianView.medications")}>
{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]) => (
<StatRow
key={name}
@@ -40,6 +40,7 @@ export function DocumentShareSheet({
>
{open ? (
<ShareLinkCreateForm
documentOnly
initialDocuments={[{ id: documentId, title: documentTitle }]}
initialLabel={documentTitle}
/>
@@ -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("<ShareLinkCreateForm> — 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"]);
});
});
@@ -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<ShareLinkCreated>("/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<ShareLinkCreated>(
"/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({
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="share-range">{t("settings.sharing.range")}</Label>
<Input
id="share-range"
type="number"
min={1}
max={3650}
value={rangeDays}
onChange={(e) =>
setRangeDays(Math.max(1, Number(e.target.value) || 1))
}
/>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.rangeHint")}
</p>
</div>
{/* The history-range window only scopes the report — meaningless for
a documents-only share, so it is hidden there. */}
{!documentOnly ? (
<div className="space-y-1.5">
<Label htmlFor="share-range">{t("settings.sharing.range")}</Label>
<Input
id="share-range"
type="number"
min={1}
max={3650}
value={rangeDays}
onChange={(e) =>
setRangeDays(Math.max(1, Number(e.target.value) || 1))
}
/>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.rangeHint")}
</p>
</div>
) : null}
<div className="space-y-1.5">
<Label htmlFor="share-expiry">{t("settings.sharing.expiry")}</Label>
<Input
@@ -275,23 +344,28 @@ export function ShareLinkCreateForm({
</div>
</div>
<div className="border-border flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5 pr-3">
<Label htmlFor="share-fhir" className="text-sm font-medium">
{t("settings.sharing.fhirApi")}
</Label>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.fhirApiHint")}
</p>
{/* 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 ? (
<div className="border-border flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5 pr-3">
<Label htmlFor="share-fhir" className="text-sm font-medium">
{t("settings.sharing.fhirApi")}
</Label>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.fhirApiHint")}
</p>
</div>
<Switch
id="share-fhir"
checked={allowFhirApi}
onCheckedChange={setAllowFhirApi}
/>
</div>
<Switch
id="share-fhir"
checked={allowFhirApi}
onCheckedChange={setAllowFhirApi}
/>
</div>
) : null}
{allowFhirApi && (
{!documentOnly && allowFhirApi && (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("settings.sharing.resourceTypes")}