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 566c86175..b1535f6e0 100644 --- a/src/lib/clinician-share/__tests__/share-view-data.test.ts +++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts @@ -10,8 +10,13 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; +// The loader resolves the whole module MAP rather than the single +// `doctorReport` key. The per-leaf "shared, but switched off at the source" +// verdicts the clinician view renders come off that same read: derived from a +// second one, the notice on the page and the absence of the data behind it +// could disagree. vi.mock("@/lib/modules/gate", () => ({ - isModuleEnabled: vi.fn(async () => true), + resolveModuleMap: vi.fn(async () => allModulesOn()), })); vi.mock("@/lib/doctor-report-data", () => ({ collectDoctorReportData: vi.fn(), @@ -26,7 +31,10 @@ vi.mock("@/lib/db", () => ({ })); import { loadShareViewData } from "../share-view-data"; -import { isModuleEnabled } from "@/lib/modules/gate"; +import { resolveModuleMap } from "@/lib/modules/gate"; +// From the registry, not the gate: the gate is mocked above, so its re-export +// of the key list would come back undefined. +import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; import { collectDoctorReportData } from "@/lib/doctor-report-data"; import { selectionToBlob, @@ -35,7 +43,23 @@ import { import { prisma } from "@/lib/db"; import type { ShareContext } from "../resolve-share-token"; +/** Every module on — the shape `resolveModuleMap` returns for a fresh account. */ +function allModulesOn(): Record { + return Object.fromEntries(MODULE_KEYS.map((key) => [key, true])) as Record< + ModuleKey, + boolean + >; +} + +/** Every module on except the named ones. */ +function modulesWithout(...off: ModuleKey[]): Record { + const map = allModulesOn(); + for (const key of off) map[key] = false; + return map; +} + const collect = collectDoctorReportData as ReturnType; +const moduleMap = resolveModuleMap as ReturnType; const findDocs = prisma.clinicianShareLinkDocument.findMany as ReturnType< typeof vi.fn >; @@ -349,7 +373,7 @@ describe("clinician share — owner doctorReport module gate", () => { beforeEach(() => { // Sibling of the suite above, so the outer reset does not reach here. vi.clearAllMocks(); - vi.mocked(isModuleEnabled).mockImplementation(async () => true); + moduleMap.mockResolvedValue(allModulesOn()); collect.mockResolvedValue({ patient: { displayName: "Shared record" } }); }); @@ -365,7 +389,7 @@ describe("clinician share — owner doctorReport module gate", () => { }); it("collapses to documents-only with the module off", async () => { - vi.mocked(isModuleEnabled).mockImplementation(async () => false); + moduleMap.mockResolvedValue(modulesWithout("doctorReport")); findDocs.mockResolvedValue([]); const res = await loadShareViewData(ctx({ documentOnly: false })); @@ -382,13 +406,13 @@ describe("clinician share — owner doctorReport module gate", () => { // from the frozen share context. findDocs.mockResolvedValue([]); await loadShareViewData(ctx({ documentOnly: false })); - expect(isModuleEnabled).toHaveBeenCalledWith("owner-1", "doctorReport"); + expect(moduleMap).toHaveBeenCalledWith("owner-1"); }); it("closes the operator kill-switch path too", async () => { - vi.mocked(isModuleEnabled).mockImplementation( - async (_u: string, key: string) => key !== "doctorReport", - ); + // The operator layer and the per-user layer resolve into the same map, so + // an operator-disabled `doctorReport` arrives here as the same `false`. + moduleMap.mockResolvedValue(modulesWithout("doctorReport")); findDocs.mockResolvedValue([]); const res = await loadShareViewData(ctx({ documentOnly: false })); @@ -397,3 +421,55 @@ describe("clinician share — owner doctorReport module gate", () => { expect(collect).not.toHaveBeenCalled(); }); }); + +/** + * The third state a recipient has to be able to see. + * + * A leaf the owner DID share, in a domain their account has switched off, + * produces exactly the same absence in the payload as a leaf they shared and + * never recorded anything for. The loader carries the module verdict out + * alongside the payload so the page can say which one it is, and it carries + * ONLY leaves the selection admits — a withheld leaf's module state is not the + * recipient's business in any direction. + */ +describe("loadShareViewData — leaves shared but switched off at the source", () => { + beforeEach(() => { + vi.clearAllMocks(); + moduleMap.mockResolvedValue(allModulesOn()); + collect.mockResolvedValue({ patient: { displayName: "Shared record" } }); + findDocs.mockResolvedValue([]); + }); + + it("names a selected leaf whose owning module is off", async () => { + moduleMap.mockResolvedValue(modulesWithout("labs")); + const res = await loadShareViewData( + ctx({ + sectionsJson: selectionToBlob( + selectionFromLeaves(["LAB_RESULTS", "WEIGHT"]), + ), + }), + ); + expect(res.unavailableLeaves).toEqual(["LAB_RESULTS"]); + }); + + it("stays empty when every selected leaf's module is on", async () => { + const res = await loadShareViewData( + ctx({ + sectionsJson: selectionToBlob( + selectionFromLeaves(["LAB_RESULTS", "MOOD", "WEIGHT"]), + ), + }), + ); + expect(res.unavailableLeaves).toEqual([]); + }); + + it("never names a leaf the link does not carry", async () => { + // `mood` is off AND unshared. The recipient learns nothing about it, + // because they were never told it existed. + moduleMap.mockResolvedValue(modulesWithout("mood")); + const res = await loadShareViewData( + ctx({ sectionsJson: selectionToBlob(selectionFromLeaves(["WEIGHT"])) }), + ); + expect(res.unavailableLeaves).toEqual([]); + }); +}); diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts index b6f994412..1d0eb370a 100644 --- a/src/lib/clinician-share/share-view-data.ts +++ b/src/lib/clinician-share/share-view-data.ts @@ -18,9 +18,13 @@ import { type DoctorReportData, type DoctorReportRange, } from "@/lib/doctor-report-data"; -import { isModuleEnabled } from "@/lib/modules/gate"; +import { resolveModuleMap } from "@/lib/modules/gate"; import { servingClassFor } from "@/lib/documents/upload-policy"; import type { DocumentServingClass } from "@/lib/documents/upload-policy"; +import { + LEAF_MODULE, + type ReportLeafId, +} from "@/lib/report-selection/catalogue"; import { isEmptySelection, selectionFromStoredBlob, @@ -61,6 +65,22 @@ export interface ShareViewData { report: DoctorReportData | null; /** The link's frozen selection, resolved. Empty when it carries no scope. */ selection: ReportSelection; + /** + * Leaves the owner DID freeze onto the link and whose owning module is + * switched off on the account it came from. + * + * The aggregator ANDs the two gates and returns the same `null` either way, + * so the payload alone cannot tell "shared, nothing recorded" from "shared, + * but the domain is switched off here". The recipient is owed that + * difference — a doctor reading an empty Lab results card should know + * whether the person has no results or whether the section never had a + * chance to carry any — so the second gate's verdict is carried alongside + * the payload rather than being collapsed into it. + * + * Only leaves the selection carries appear here: a leaf that was never + * shared is not the recipient's business in any state. + */ + unavailableLeaves: ReportLeafId[]; /** v1.28 — the hand-picked documents on this link (metadata only). */ documents: ShareViewDocument[]; /** @@ -122,12 +142,26 @@ export async function loadShareViewData( // the link to exactly the documents the owner attached. That is fail-closed // for the health record while keeping a public link from 500-ing. The // documents themselves are a separate module and keep their own gate. - const reportModuleEnabled = await isModuleEnabled( - context.ownerUserId, - "doctorReport", - ); + // + // The whole map is resolved rather than the one key, because the per-leaf + // verdicts below come off the same read. It is NOT handed to the aggregator: + // the third argument is the frozen selection and there is deliberately no + // fourth, so this surface cannot grow an options object that widens what it + // asks for. `resolveModuleMap` memoises its DB reads per request, so the + // aggregator resolving its own map again costs no round-trip. + const moduleMap = await resolveModuleMap(context.ownerUserId); const documentOnly = - context.documentOnly || isEmptySelection(selection) || !reportModuleEnabled; + context.documentOnly || + isEmptySelection(selection) || + moduleMap.doctorReport === false; + + // Shared, but switched off at the source. Derived from the SAME map the + // aggregator gates with, so the notice on the page and the absence of the + // data behind it can never disagree. + const unavailableLeaves = selection.leaves.filter((leaf) => { + const moduleKey = LEAF_MODULE[leaf]; + return moduleKey !== undefined && moduleMap[moduleKey] === false; + }); const [report, documents] = await Promise.all([ documentOnly @@ -136,7 +170,7 @@ export async function loadShareViewData( loadShareDocuments(context), ]); - return { report, selection, documents, documentOnly }; + return { report, selection, unavailableLeaves, documents, documentOnly }; } /**