mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
@@ -1106,6 +1106,8 @@ paths:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
documentOnly:
|
||||
type: boolean
|
||||
expiresAt:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
@@ -234,12 +234,14 @@ test.describe("clinician document sharing", () => {
|
||||
await clinician.context().close();
|
||||
});
|
||||
|
||||
test("the document detail Share action opens the create flow with that document pre-attached", async ({
|
||||
test("the document Share action mints a DOCUMENT-ONLY link — the document, never the record", async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
// Opens two stacked sheets (detail → share) and drives a real create with
|
||||
// the one-time QR render — heavier than the default 30s budget on a loaded
|
||||
// runner. Runs on the desktop and 390px mobile projects.
|
||||
// Opens two stacked sheets (detail → share), drives a real create with the
|
||||
// one-time QR render, then unlocks the link as a recipient to prove the
|
||||
// served view carries the document and ZERO health metrics. Heavier than
|
||||
// the default 30s budget. Runs on the desktop and 390px mobile projects.
|
||||
test.slow();
|
||||
|
||||
// Deep-link straight to the document detail sheet.
|
||||
@@ -263,6 +265,13 @@ test.describe("clinician document sharing", () => {
|
||||
await expect(chips).toHaveCount(1);
|
||||
await expect(chips.first()).toContainText("MRT Knie");
|
||||
|
||||
// Document-only mode hides every record-scope control: no FHIR API toggle,
|
||||
// no FHIR resource-type checkboxes, no history-range selector. Only the
|
||||
// document, label, and expiry remain.
|
||||
await expect(shareSheet.locator("#share-fhir")).toHaveCount(0);
|
||||
await expect(shareSheet.locator("#share-range")).toHaveCount(0);
|
||||
await expect(shareSheet.locator("#share-expiry")).toHaveCount(1);
|
||||
|
||||
// Create the link — the one-time reveal (with the scannable QR) lands in
|
||||
// the same sheet, carrying the one attached document.
|
||||
await shareSheet.getByRole("button", { name: "Create link" }).click();
|
||||
@@ -276,6 +285,46 @@ test.describe("clinician document sharing", () => {
|
||||
const box = await qrImg.boundingBox();
|
||||
expect(box, "QR image has a rendered box").not.toBeNull();
|
||||
expect(box!.width).toBeGreaterThanOrEqual(160);
|
||||
|
||||
// Read the one-time secrets off the reveal and unlock the link as a
|
||||
// recipient (no owner session).
|
||||
const shareUrl = (await reveal.locator("code").first().innerText()).trim();
|
||||
const token = shareUrl.split("/c/")[1];
|
||||
expect(token, "token parsed from share URL").toMatch(/^hls_[0-9a-f]{48}$/);
|
||||
const passphrase = (
|
||||
await shareSheet.getByTestId("share-passphrase").innerText()
|
||||
).trim();
|
||||
const clinician = await openUnlockedClinician(browser, {
|
||||
token,
|
||||
passphrase,
|
||||
label: "MRT Knie",
|
||||
});
|
||||
|
||||
// The served view lists the attached document…
|
||||
await expect(
|
||||
clinician.getByRole("heading", { name: "Documents" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
clinician.locator(
|
||||
`iframe[src*="/d/${MRT_DOC_ID}"], img[src*="/d/${MRT_DOC_ID}"]`,
|
||||
),
|
||||
).toHaveCount(1);
|
||||
|
||||
// …and NOTHING from the health record: no vitals, no labs, no medications,
|
||||
// no reporting-period line. This is the privacy guarantee — sharing a
|
||||
// document must never expose a single metric.
|
||||
await expect(
|
||||
clinician.getByRole("heading", { name: "Vital signs" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
clinician.getByRole("heading", { name: "Lab values" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
clinician.getByRole("heading", { name: "Medications & adherence" }),
|
||||
).toHaveCount(0);
|
||||
await expect(clinician.getByText("Reporting period:")).toHaveCount(0);
|
||||
|
||||
await clinician.context().close();
|
||||
});
|
||||
|
||||
test("picker enforces the document cap at the limit", async ({ page }) => {
|
||||
|
||||
@@ -41,6 +41,7 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { POST, GET } from "../route";
|
||||
import { DELETE } from "../[id]/route";
|
||||
import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -260,6 +261,50 @@ describe("POST /api/share-links — create", () => {
|
||||
const body = (await res.json()) as { data: { documentCount: number } };
|
||||
expect(body.data.documentCount).toBe(1);
|
||||
});
|
||||
|
||||
it("documentOnly FORCES an empty report scope even when the body smuggles one", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
{ id: "doc-a" },
|
||||
] as never);
|
||||
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
|
||||
storedRow({ resourceTypes: [], _count: { documents: 1 } }) as never,
|
||||
);
|
||||
|
||||
// A hostile / stale client tries to widen a document link into a record
|
||||
// share: FHIR on, resource types set, sections requested. The server must
|
||||
// ignore every one of them.
|
||||
const res = await POST(
|
||||
postReq(
|
||||
validBody({
|
||||
documentOnly: true,
|
||||
allowFhirApi: true,
|
||||
resourceTypes: ["Observation", "Patient"],
|
||||
sections: { vitals: { bp: true }, labs: true },
|
||||
documentIds: ["doc-a"],
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const createArg = vi.mocked(prisma.clinicianShareLink.create).mock
|
||||
.calls[0][0];
|
||||
// Empty FHIR scope, FHIR API off — no health surface at all.
|
||||
expect(createArg.data.resourceTypes).toEqual([]);
|
||||
expect(createArg.data.allowFhirApi).toBe(false);
|
||||
// Sections persist as an explicit all-OFF blob (distinct from `{}` = full
|
||||
// record defaults). The clinician view reads this as "no report".
|
||||
expect(createArg.data.sectionsJson).toEqual(EMPTY_DOCTOR_REPORT_PREFS);
|
||||
// The picked document still rides along.
|
||||
expect(createArg.data.documents).toEqual({
|
||||
create: [{ documentId: "doc-a" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a documentOnly share with no documents (422)", async () => {
|
||||
const res = await POST(postReq(validBody({ documentOnly: true })));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/share-links — list", () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
|
||||
import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
|
||||
import {
|
||||
generatePassphrase,
|
||||
hashPassphrase,
|
||||
@@ -134,6 +135,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const input = parsed.data;
|
||||
|
||||
// v1.28.13 — a documents-only share carries NO report scope. Force the scope
|
||||
// columns to empty here (not just at the client) so a document link can never
|
||||
// serve a single health metric, whatever the body tried to smuggle: no
|
||||
// sections (an explicit all-OFF prefs blob, distinct from the `{}` that means
|
||||
// "full record defaults"), no FHIR resource types, FHIR API off. "Share this
|
||||
// document" means the document, not the whole record.
|
||||
const documentOnly = input.documentOnly === true;
|
||||
const sectionsJson = documentOnly
|
||||
? EMPTY_DOCTOR_REPORT_PREFS
|
||||
: (input.sections ?? {});
|
||||
const resourceTypes = documentOnly ? [] : (input.resourceTypes ?? []);
|
||||
const allowFhirApi = documentOnly ? false : (input.allowFhirApi ?? false);
|
||||
annotate({ meta: { documentOnly } });
|
||||
|
||||
// Mint the 192-bit raw token, store only its HMAC hash.
|
||||
const rawToken = `hls_${randomBytes(24).toString("hex")}`;
|
||||
const tokenHash = hashToken(rawToken);
|
||||
@@ -175,9 +190,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
label: input.label,
|
||||
rangeStart: new Date(input.rangeStart),
|
||||
rangeEnd: input.rangeEnd ? new Date(input.rangeEnd) : null,
|
||||
sectionsJson: (input.sections ?? {}) as Prisma.InputJsonValue,
|
||||
resourceTypes: input.resourceTypes ?? [],
|
||||
allowFhirApi: input.allowFhirApi ?? false,
|
||||
sectionsJson: sectionsJson as Prisma.InputJsonValue,
|
||||
resourceTypes,
|
||||
allowFhirApi,
|
||||
expiresAt: new Date(input.expiresAt),
|
||||
documents:
|
||||
documentIds.length > 0
|
||||
|
||||
@@ -103,10 +103,8 @@ export default async function ClinicianSharePage({
|
||||
const context = await resolveShareToken(token);
|
||||
if (!context) notFound();
|
||||
|
||||
const [{ report, sections, documents }, locale] = await Promise.all([
|
||||
loadShareViewData(context),
|
||||
resolveLocale(),
|
||||
]);
|
||||
const [{ report, sections, documents, documentOnly }, locale] =
|
||||
await Promise.all([loadShareViewData(context), resolveLocale()]);
|
||||
const { t } = getServerTranslator(locale);
|
||||
|
||||
return (
|
||||
@@ -117,6 +115,7 @@ export default async function ClinicianSharePage({
|
||||
report={report}
|
||||
sections={sections}
|
||||
documents={documents}
|
||||
documentOnly={documentOnly}
|
||||
token={token}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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