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

+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