feat(share): carry the owner's module verdict beside the payload

A leaf on a share link can be absent from the payload for two reasons
that look identical downstream: the owner shared it and recorded
nothing, or they shared it and the domain is switched off on their
account. The aggregator ANDs the selection and the module map and
returns the same null either way, so the recipient could not tell an
empty section from one that never had a chance to carry anything.

`loadShareViewData` now resolves the whole module map — it needed the
`doctorReport` key from it anyway — and returns `unavailableLeaves`: the
leaves the link DOES carry whose owning module is off. Only selected
leaves appear, because a withheld leaf's module state is not the
recipient's business in any direction.

The map is deliberately NOT handed to the aggregator. The third argument
is the frozen selection and there is no fourth, which is what keeps this
surface from growing an options object that widens what it asks for; the
resolver memoises its reads per request, so the aggregator resolving its
own map again costs no round-trip.

The module-gate suite moves off `isModuleEnabled` with it, and gains
three cases for the new verdict, including the one that proves an
unshared leaf is never named.
This commit is contained in:
Marc-André Bombeck committed 2026-08-24 01:02:33 +02:00
1 parent 29ab274775
commit fbe2081506
2 files changed
+125 -15

No files matched your search

@@ -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<ModuleKey, boolean> {
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<ModuleKey, boolean> {
const map = allModulesOn();
for (const key of off) map[key] = false;
return map;
}
const collect = collectDoctorReportData as ReturnType<typeof vi.fn>;
const moduleMap = resolveModuleMap as ReturnType<typeof vi.fn>;
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([]);
});
});
+41 -7
View File
@@ -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 };
}
/**