mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user