mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(export): make the doctor report carry only what was chosen
The report resolved an absent key to "included". A caller that sent no sections object, or an empty group inside one, received the full record at server-chosen defaults: roughly fifty metrics plus the medication list, labs, allergies and family history. The web panel sent every key explicitly so it looked fine from the browser, but the share link, the FHIR REST face, the MCP doctor-visit resource and the cycle-only export card all went through paths where absence meant yes. No arrangement of booleans fixes a default that means yes, so the shape goes. A selection is now an inclusion list of leaf ids. A leaf is the smallest thing that can be released or withheld: every measurement type, and thirteen structured sections — identity, insurer, the glucose panel, labs, the drug list, the administration ledger, adherence, the GLP-1 block, allergies, illness episodes, family history, mood, cycle. Membership is inclusion, absence is exclusion, and there is no defaults constant anywhere to drift. Three locks keep a metric added later off a report chosen earlier. A stored selection is a list of what was chosen, so a leaf that did not exist then is not in it. The catalogue is keyed over the measurement enum, so adding a member without deciding its group fails typecheck in the same commit. And the aggregator's filter inverts: a type the catalogue does not know is excluded and annotated, where it used to be passed through. The aggregator's third parameter is now required and brand-typed, which turned the three surfaces that had no scope at all into compile errors. Each one had to name a source, and each now replays the owner's saved selection — the same object reviewed in Settings. An account that never saved one gets nothing there rather than a guess. Identity and insurance become selectable, and a share link refuses the insurance leaf outright. The share view never decrypted the number; that is structural now instead of incidental. The KVNR is not even read unless the leaf was chosen. Two surfaces gain a record they always implied. A share link serves its frozen selection as a FHIR bundle and as the PDF, behind the same unlock cookie the document route uses, with its own rate bucket, its own audit row and the same blunt 404 for every miss. That is what the removed FHIR toggle promised, in the form a practice actually uses. The AI summary leaves the report. Its text came from the cached briefing with no relation to the selection, so its scope could not be proven, and prose whose scope cannot be proven has no place on a document whose whole point is a proven scope. The PDF table stops being a nine-type whitelist. It prints every selected leaf that has data, sub-headed by group, in the same order the panel shows — so selecting resting heart rate no longer means seeing it in the bundle and not in the PDF. The clinician view is grouped the same way and reads real labels instead of "Phq9 score". Migration 0273 adds the saved-selection column, drops the orphaned prefs column and the two retired share-link scope columns, and revokes every live record share link minted before this model. Their frozen scope was "everything except mood and cycle", which is a scope nobody chose; narrowing it would still be a scope nobody chose. Documents-only shares are untouched. The sharing list shows each closed link in a pick-again state with its label, window and expiry kept. The retired allowFhirApi and resourceTypes stay on the create and list responses as the constant false and empty list. The request refuses them. They come off the response once the native client confirms it does not decode them. Six files are split at seams this change already touched: the aggregator into its gate, its dense-bucket query, its series, its glucose panel, its medication ledger and its clinical records; the export route's three emitters into their own module; the clinician view into its sections, its document list and its downloads.
This commit is contained in:
1 parent
f90219784b
commit
5e490f31ec
46 files changed
+3734
-3083
No files matched your search
@@ -1,210 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
toJson: <T>(v: T) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPut(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/doctor-report-prefs", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/doctor-report-prefs", () => {
|
||||
it("returns defaults when the column is null", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/doctor-report-prefs"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: typeof DEFAULT_DOCTOR_REPORT_PREFS;
|
||||
};
|
||||
expect(env.data).toEqual(DEFAULT_DOCTOR_REPORT_PREFS);
|
||||
expect(env.data.mood).toBe(false); // privacy default per the maintainer
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/doctor-report-prefs", () => {
|
||||
// ── Case 1 — happy path ─────────────────────────────────────────────
|
||||
it("persists the full shape and returns the canonical form", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const body = {
|
||||
bp: true,
|
||||
weight: true,
|
||||
pulse: false,
|
||||
bmi: true,
|
||||
mood: true,
|
||||
compliance: true,
|
||||
sleep: false,
|
||||
glucose: true,
|
||||
cycle: false,
|
||||
labs: true,
|
||||
allergies: true,
|
||||
familyHistory: false,
|
||||
};
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: typeof body };
|
||||
// The canonical shape is the full prefs object: keys the body omits fill
|
||||
// from the defaults rather than being dropped.
|
||||
const canonical = { ...DEFAULT_DOCTOR_REPORT_PREFS, ...body };
|
||||
expect(env.data).toEqual(canonical);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { doctorReportPrefsJson: canonical },
|
||||
});
|
||||
});
|
||||
|
||||
// ── Case 2 — partial update layers over current persisted row ───────
|
||||
it("merges a partial update over the persisted row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const current = {
|
||||
bp: false,
|
||||
weight: false,
|
||||
pulse: false,
|
||||
bmi: false,
|
||||
mood: true,
|
||||
compliance: false,
|
||||
sleep: false,
|
||||
glucose: false,
|
||||
cycle: false,
|
||||
labs: false,
|
||||
allergies: false,
|
||||
familyHistory: false,
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: current,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
// User only flips mood off — every other key keeps its persisted value.
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ mood: false }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: typeof current };
|
||||
const merged = {
|
||||
...DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
...current,
|
||||
mood: false,
|
||||
};
|
||||
expect(env.data).toEqual(merged);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { doctorReportPrefsJson: merged },
|
||||
});
|
||||
});
|
||||
|
||||
// ── Case 3 — invalid shape ─────────────────────────────────────────
|
||||
it("rejects a non-boolean value with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ bp: "nope" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Case 4 — unauthenticated cross-user request ────────────────────
|
||||
// the maintainer's spec mentions "cross-user 404" — the route layer returns 401
|
||||
// when there is no session at all, which is the only "you are not the
|
||||
// right user" surface available before requireAuth() resolves an
|
||||
// identity. `prisma.user.update` must NOT fire in this case.
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ mood: false }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Case 5 — audit trail (v1.4.25 W10 reconcile security M-3) ──────
|
||||
it("writes an audit-log entry with the previous and new pref shape", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const current = {
|
||||
bp: true,
|
||||
weight: true,
|
||||
pulse: true,
|
||||
bmi: true,
|
||||
mood: false,
|
||||
compliance: true,
|
||||
sleep: true,
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: current,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ mood: true }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.doctor-report-prefs.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
previous: current,
|
||||
next: expect.objectContaining({ mood: true }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W6c — per-user Doctor-Report section toggles.
|
||||
*
|
||||
* GET /api/auth/me/doctor-report-prefs — returns the resolved prefs
|
||||
* (defaults when the row is null).
|
||||
* PUT /api/auth/me/doctor-report-prefs — merges the supplied partial
|
||||
* shape over the current row
|
||||
* and persists the canonical
|
||||
* fully-resolved object so a
|
||||
* future schema migration
|
||||
* doesn't need to back-fill
|
||||
* missing keys.
|
||||
*
|
||||
* Bearer-auth + cookie-auth both work via the shared `requireAuth()`
|
||||
* helper. The doctor-report aggregator + PDF renderer both read this
|
||||
* row at generation time so mood data is dropped at the aggregator
|
||||
* layer when `mood = false` — the data never leaves the DB.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
doctorReportPrefsSchema,
|
||||
parseDoctorReportPrefs,
|
||||
resolveDoctorReportPrefs,
|
||||
} from "@/lib/validations/doctor-report-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.doctor-report-prefs.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { doctorReportPrefsJson: true },
|
||||
});
|
||||
return apiSuccess(parseDoctorReportPrefs(row?.doctorReportPrefsJson));
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "doctor-report-prefs.body.invalid_json");
|
||||
}
|
||||
|
||||
const parsed = doctorReportPrefsSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.doctor-report-prefs.put.invalid" },
|
||||
meta: { issues: parsed.error.issues.length },
|
||||
});
|
||||
throw new HttpError(422, "doctor-report-prefs.body.invalid_shape");
|
||||
}
|
||||
|
||||
// Merge the partial input over the current row (or defaults) so a
|
||||
// dialog that only toggles `mood` doesn't have to re-state every other
|
||||
// flag. Persist the canonical fully-resolved object so the column
|
||||
// shape stays stable across future schema additions.
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { doctorReportPrefsJson: true },
|
||||
});
|
||||
const merged = resolveDoctorReportPrefs(
|
||||
current?.doctorReportPrefsJson,
|
||||
parsed.data,
|
||||
);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
doctorReportPrefsJson: toJson(merged),
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.25 W10 reconcile (security M-3): record the previous + new
|
||||
// pref shape in the audit log. Doctor-Report pref toggles
|
||||
// (especially `mood: true`) widen the PDF surface; without an
|
||||
// audit trail a silent compromise (or unintended client write)
|
||||
// is invisible. Mirrors the timezone-route audit pattern.
|
||||
await auditLog("user.doctor-report-prefs.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous: current?.doctorReportPrefsJson ?? null,
|
||||
next: merged,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.doctor-report-prefs.put" },
|
||||
meta: {
|
||||
bp: merged.bp,
|
||||
weight: merged.weight,
|
||||
pulse: merged.pulse,
|
||||
bmi: merged.bmi,
|
||||
mood: merged.mood,
|
||||
compliance: merged.compliance,
|
||||
sleep: merged.sleep,
|
||||
},
|
||||
});
|
||||
return apiSuccess(merged);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* The owner's saved report selection.
|
||||
*
|
||||
* GET /api/auth/me/report-selection — the saved profile, or `null` when the
|
||||
* account has never saved one.
|
||||
* PUT /api/auth/me/report-selection — replace it.
|
||||
*
|
||||
* Replace, not merge. A selection is a list of what was chosen, so merging a
|
||||
* partial over a stored one would re-introduce exactly the failure this shape
|
||||
* removes: a leaf staying in because the caller did not mention it. The body
|
||||
* states the whole scope or it is not a scope.
|
||||
*
|
||||
* Unknown leaf ids are refused with a 422 that names them. A client sending an
|
||||
* id this build does not know is a contract mismatch the client has to see.
|
||||
*
|
||||
* Replaces `GET`/`PUT /api/auth/me/doctor-report-prefs`, whose column nothing
|
||||
* outside that route ever read.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { savedReportProfileSchema } from "@/lib/report-selection/saved-profile";
|
||||
import { selectionFromRequest } from "@/lib/report-selection/selection";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.report-selection.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { reportSelectionJson: true },
|
||||
});
|
||||
const parsed = savedReportProfileSchema.safeParse(row?.reportSelectionJson);
|
||||
// A drifted or absent blob answers `null`, not a default profile: the panel
|
||||
// then applies the named template and shows it, which is a visible act
|
||||
// rather than a silent server choice.
|
||||
return apiSuccess({ profile: parsed.success ? parsed.data : null });
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "report-selection.body.invalid_json");
|
||||
}
|
||||
|
||||
const parsed = savedReportProfileSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.report-selection.put.invalid" },
|
||||
meta: { issues: parsed.error.issues.length },
|
||||
});
|
||||
throw new HttpError(422, "report-selection.body.invalid_shape");
|
||||
}
|
||||
|
||||
const minted = selectionFromRequest(parsed.data);
|
||||
if (!minted.ok) {
|
||||
annotate({
|
||||
action: { name: "auth.me.report-selection.put.invalid" },
|
||||
meta: { unknownLeaves: minted.error.unknownLeaves },
|
||||
});
|
||||
throw new HttpError(422, "report-selection.leaves.unknown");
|
||||
}
|
||||
|
||||
// Persist the canonical ordering rather than the caller's, so two clients
|
||||
// that chose the same scope store the same bytes.
|
||||
const profile = {
|
||||
v: parsed.data.v,
|
||||
leaves: [...minted.selection.leaves],
|
||||
format: parsed.data.format,
|
||||
rangeDays: parsed.data.rangeDays,
|
||||
includeCharts: parsed.data.includeCharts,
|
||||
};
|
||||
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { reportSelectionJson: true },
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { reportSelectionJson: toJson(profile) },
|
||||
});
|
||||
|
||||
// The saved selection is what the FHIR REST face and the MCP doctor-visit
|
||||
// surfaces replay for a caller that cannot ask a human, so widening it
|
||||
// widens those too. That belongs in the audit trail.
|
||||
await auditLog("user.report-selection.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous: current?.reportSelectionJson ?? null,
|
||||
next: profile,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.report-selection.put" },
|
||||
meta: { leafCount: profile.leaves.length, format: profile.format },
|
||||
});
|
||||
return apiSuccess({ profile });
|
||||
});
|
||||
@@ -1,25 +1,25 @@
|
||||
/**
|
||||
* POST /api/export/health-record — v1.7.0 flagship health-record export.
|
||||
* POST /api/export/health-record — the doctor-handover artefact.
|
||||
*
|
||||
* One entry point that produces the doctor-handover artefact in one of three
|
||||
* formats, driven by a strict Zod selection payload:
|
||||
* - `pdf` → `application/pdf` (enhanced clinical report)
|
||||
* One entry point producing one of three formats from one aggregated payload,
|
||||
* driven by a strict Zod selection:
|
||||
* - `pdf` → `application/pdf`
|
||||
* - `fhir` → `application/fhir+json` (HL7 FHIR R4 document Bundle)
|
||||
* - `package` → `application/zip` (PDF + FHIR + README in one download)
|
||||
* - `package` → `application/zip` (PDF + Bundle + README)
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h) — so structured + PDF
|
||||
* + CSV exports cannot be parallelised past the cap.
|
||||
* Audit: `health-record.export` (records format/days/sections/charts — never
|
||||
* the values).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h).
|
||||
* Audit: `health-record.export` — format, window, and the chosen leaf ids,
|
||||
* never the values behind them.
|
||||
*
|
||||
* Strict validation: unlike the legacy doctor-report route (which tolerates
|
||||
* drift), this route fails loudly — `.strict()` + `returnAllZodIssues` returns
|
||||
* a 422 multi-issue envelope on any unknown key (including any attempt to
|
||||
* smuggle a `userId`, which is always narrowed from `requireAuth()`).
|
||||
* The `selection` field is required and carries the leaf inclusion list. An
|
||||
* unknown leaf id is a 422 that names it; the old grouped `sections` shape is
|
||||
* a 422 too, because accepting it would mean choosing a scope the caller did
|
||||
* not express. The rendering itself lives in
|
||||
* `src/lib/export/health-record-artefacts.ts`; this route keeps the HTTP
|
||||
* concerns.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { zipSync, strToU8 } from "fflate";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { toAllergyDTO, toFamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
import {
|
||||
@@ -40,39 +40,46 @@ import {
|
||||
normaliseDateRange,
|
||||
sanitisePracticeName,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
|
||||
import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/build-bundle";
|
||||
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
|
||||
import { selectionFromRequest } from "@/lib/report-selection/selection";
|
||||
import {
|
||||
buildFhirDocumentBundle,
|
||||
GERMAN_ATC_DEFAULT_LOCALES,
|
||||
} from "@/lib/fhir/build-bundle";
|
||||
import {
|
||||
exportSelectionSchema,
|
||||
toDoctorReportPrefs,
|
||||
} from "@/lib/validations/health-record-export";
|
||||
buildBundleJson,
|
||||
buildPackageZip,
|
||||
buildPdfBytes,
|
||||
type ArtefactInputs,
|
||||
} from "@/lib/export/health-record-artefacts";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
/**
|
||||
* Remember the practice / clinic name for the next report, so the user does
|
||||
* not retype it every time (`User.lastReportPracticeName`, the preference the
|
||||
* doctor-report contract has always promised).
|
||||
* Remember what the owner chose, so the panel opens carrying it next time.
|
||||
*
|
||||
* Called only once the requested artefact has actually been produced: a
|
||||
* generation that throws must not overwrite the remembered name with a
|
||||
* half-typed one. `null` is a legitimate value — the user cleared the line and
|
||||
* the next cover should stay cleared. The value is the already-sanitised one
|
||||
* (`sanitisePracticeName`, 120-char cap), and the update names its single
|
||||
* field explicitly rather than spreading the parsed selection.
|
||||
* generation that throws must not overwrite the remembered scope or a
|
||||
* half-typed practice name. `null` is a legitimate practice name — the user
|
||||
* cleared the line and the next cover should stay cleared. Both fields are
|
||||
* named explicitly rather than spread from the parsed body.
|
||||
*/
|
||||
async function rememberPracticeName(
|
||||
async function rememberChoices(
|
||||
userId: string,
|
||||
practiceName: string | null,
|
||||
profile: {
|
||||
v: 2;
|
||||
leaves: string[];
|
||||
format: "pdf" | "fhir" | "package";
|
||||
rangeDays: number;
|
||||
includeCharts: boolean;
|
||||
},
|
||||
) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { lastReportPracticeName: practiceName },
|
||||
data: {
|
||||
lastReportPracticeName: practiceName,
|
||||
reportSelectionJson: toJson(profile),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,10 +87,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "export.health-record.build" } });
|
||||
|
||||
// v1.18.0 B3 — the whole doctor-report / health-record surface is the
|
||||
// `doctorReport` module. Refuse with a 403 `module.disabled` envelope when
|
||||
// the account turned it off — even with a valid Bearer token. The settings
|
||||
// entry-point already hides client-side; this is the hard enforcement.
|
||||
// The whole doctor-report surface is the `doctorReport` module. Refuse with
|
||||
// a 403 `module.disabled` envelope when the account turned it off — even
|
||||
// with a valid Bearer token.
|
||||
const gate = await requireModuleEnabled(user.id, "doctorReport");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -92,11 +98,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
// The selection payload is small and bounded — format, range,
|
||||
// section toggles, a few flags, an optional practice-name string.
|
||||
// 64 KB is far above any legitimate selection while still rejecting
|
||||
// a multi-megabyte body before it reaches `JSON.parse`. A DoS
|
||||
// ceiling, not a tight bound.
|
||||
// The selection payload is small and bounded. 64 KB is far above any
|
||||
// legitimate one while still rejecting a multi-megabyte body before it
|
||||
// reaches `JSON.parse`. A DoS ceiling, not a tight bound.
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
@@ -106,18 +110,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error);
|
||||
}
|
||||
const selection = parsed.data;
|
||||
const input = parsed.data;
|
||||
|
||||
const range = normaliseDateRange(selection.range ?? undefined);
|
||||
const practiceName = sanitisePracticeName(selection.practiceName);
|
||||
const sections = toDoctorReportPrefs(selection.sections);
|
||||
const includeCharts = selection.includeCharts ?? true;
|
||||
const minted = selectionFromRequest(input.selection);
|
||||
if (!minted.ok) {
|
||||
annotate({ meta: { unknownLeaves: minted.error.unknownLeaves } });
|
||||
return apiError(
|
||||
`Unknown selection leaf: ${minted.error.unknownLeaves.join(", ")}`,
|
||||
422,
|
||||
{ errorCode: "export.selection.unknown_leaf" },
|
||||
);
|
||||
}
|
||||
const selection = minted.selection;
|
||||
|
||||
// Locale resolution, most-specific first: the explicit selection wins, then
|
||||
// the in-app `healthlog-locale` cookie (what the user actually chose in the
|
||||
// UI), and only then the browser's Accept-Language header. The header is the
|
||||
// weakest signal — a German user on an English-default browser would
|
||||
// otherwise get an English report.
|
||||
const range = normaliseDateRange(input.range ?? undefined);
|
||||
const practiceName = sanitisePracticeName(input.practiceName);
|
||||
const includeCharts = input.includeCharts ?? true;
|
||||
|
||||
// Locale, most-specific first: the explicit selection wins, then the in-app
|
||||
// cookie (what the user actually chose in the UI), and only then the
|
||||
// browser's Accept-Language header — the weakest signal.
|
||||
const cookieValue = request.cookies.get("healthlog-locale")?.value;
|
||||
const cookieLocale = (locales as readonly string[]).includes(
|
||||
cookieValue ?? "",
|
||||
@@ -125,45 +137,34 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
? (cookieValue as Locale)
|
||||
: null;
|
||||
const locale: Locale =
|
||||
selection.locale ??
|
||||
input.locale ??
|
||||
cookieLocale ??
|
||||
parseLocaleFromAcceptLanguage(request.headers.get("accept-language") ?? "");
|
||||
|
||||
// BfArM ATC: an explicit selection flag wins; otherwise derive it from a
|
||||
// German-region locale. The WHO ATC coding is unaffected either way.
|
||||
const germanAtc =
|
||||
selection.germanAtc ??
|
||||
input.germanAtc ??
|
||||
(GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(locale);
|
||||
|
||||
const [data, userTz, userRow, allergyRows, familyHistoryRows] =
|
||||
await Promise.all([
|
||||
collectDoctorReportData(user.id, range, { practiceName, sections }),
|
||||
collectDoctorReportData(user.id, range, selection, { practiceName }),
|
||||
resolveUserTimezone(user.id),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
insuranceNumberEncrypted: true,
|
||||
insightsCachedText: true,
|
||||
timeFormat: true,
|
||||
},
|
||||
select: { insuranceNumberEncrypted: true, timeFormat: true },
|
||||
}),
|
||||
// v1.25 (W-RECORDS) — structured records are always-available reference
|
||||
// data (not time-windowed), so they ride alongside the report rather
|
||||
// than through `collectDoctorReportData`. Owner-scoped, live rows only.
|
||||
//
|
||||
// Gated on the section toggles. These two reads fed ONLY the
|
||||
// FHIR bundle, and they ran unconditionally: `sections.allergies = false`
|
||||
// produced a PDF without an allergy section and a bundle carrying every
|
||||
// AllergyIntolerance, and in the `package` format both shipped in the
|
||||
// same zip. Zero DB read when off, matching how the aggregator treats
|
||||
// mood — deselected data should not be loaded, not merely dropped later.
|
||||
sections.allergies
|
||||
// Structured records are always-available reference data (not
|
||||
// time-windowed), so they ride alongside the report rather than through
|
||||
// the aggregator. Owner-scoped, live rows only — and each read is gated
|
||||
// on its own leaf, so a deselected section is not loaded, not merely
|
||||
// dropped later.
|
||||
selection.has("ALLERGIES")
|
||||
? prisma.allergy.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
sections.familyHistory
|
||||
selection.has("FAMILY_HISTORY")
|
||||
? prisma.familyHistoryEntry.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
@@ -171,16 +172,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
// FHIR records folded into every bundle format (fhir + package).
|
||||
const fhirRecords = {
|
||||
allergies: allergyRows.map(toAllergyDTO),
|
||||
familyHistory: familyHistoryRows.map(toFamilyHistoryEntryDTO),
|
||||
};
|
||||
|
||||
// Decrypt the KVNR fail-soft: a key-rotation gap on one row should
|
||||
// never 500 the export — the cover/identifier just omits the value.
|
||||
// The KVNR is encrypted at rest and is its own leaf. It is neither read nor
|
||||
// decrypted unless the insurance leaf was chosen. The decrypt is fail-soft:
|
||||
// a key-rotation gap on one row omits the value rather than 500-ing the
|
||||
// export.
|
||||
let insuranceNumber: string | null = null;
|
||||
if (userRow?.insuranceNumberEncrypted) {
|
||||
if (selection.has("INSURANCE") && userRow?.insuranceNumberEncrypted) {
|
||||
try {
|
||||
insuranceNumber = decrypt(userRow.insuranceNumberEncrypted);
|
||||
} catch {
|
||||
@@ -188,43 +190,53 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
// AI summary is OUT of the clinical PDF by default — only included when
|
||||
// the user explicitly toggled `includeAiSummary` AND a cached briefing
|
||||
// text exists. Never reaches the FHIR bundle (not a clinical observation).
|
||||
const aiSummary =
|
||||
selection.includeAiSummary && userRow?.insightsCachedText
|
||||
? extractAiSummaryText(userRow.insightsCachedText)
|
||||
: null;
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
const { t } = getServerTranslator(locale);
|
||||
|
||||
const artefact: ArtefactInputs = {
|
||||
data,
|
||||
insuranceNumber,
|
||||
records: fhirRecords,
|
||||
germanAtc,
|
||||
includeCharts,
|
||||
locale,
|
||||
userTz,
|
||||
timeFormat: userRow?.timeFormat ?? "AUTO",
|
||||
t,
|
||||
};
|
||||
|
||||
const profile = {
|
||||
v: 2 as const,
|
||||
leaves: [...selection.leaves],
|
||||
format: input.format,
|
||||
rangeDays: Math.min(365, Math.max(1, range.days)),
|
||||
includeCharts,
|
||||
};
|
||||
|
||||
await auditLog("health-record.export", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
format: selection.format,
|
||||
format: input.format,
|
||||
days: range.days,
|
||||
startDate: range.start.toISOString(),
|
||||
endDate: range.end.toISOString(),
|
||||
charts: includeCharts,
|
||||
aiSummary: aiSummary !== null,
|
||||
// Section toggles only (booleans) — never the underlying values.
|
||||
sections,
|
||||
// The chosen leaf ids — never the values behind them.
|
||||
leaves: profile.leaves,
|
||||
},
|
||||
});
|
||||
|
||||
if (selection.format === "fhir") {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
data,
|
||||
{ insuranceNumber },
|
||||
undefined,
|
||||
{ germanAtc },
|
||||
fhirRecords,
|
||||
);
|
||||
const json = JSON.stringify(bundle);
|
||||
await rememberPracticeName(user.id, practiceName);
|
||||
if (input.format === "fhir") {
|
||||
const json = buildBundleJson(artefact);
|
||||
await rememberChoices(user.id, practiceName, profile);
|
||||
annotate({
|
||||
meta: { format: "fhir", bytes: json.length, days: range.days },
|
||||
meta: {
|
||||
format: "fhir",
|
||||
bytes: json.length,
|
||||
days: range.days,
|
||||
leafCount: profile.leaves.length,
|
||||
},
|
||||
});
|
||||
return new NextResponse(json, {
|
||||
status: 200,
|
||||
@@ -236,23 +248,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { t } = getServerTranslator(locale);
|
||||
const pdfBytes = renderDoctorReportPdfBytes(data, {
|
||||
t,
|
||||
locale,
|
||||
userTz,
|
||||
timeFormat: userRow?.timeFormat ?? "AUTO",
|
||||
insuranceNumber,
|
||||
includeCharts,
|
||||
aiSummary,
|
||||
});
|
||||
// Both remaining formats (`pdf`, `package`) carry the rendered PDF, so the
|
||||
// artefact exists from here on.
|
||||
await rememberPracticeName(user.id, practiceName);
|
||||
const pdfBytes = buildPdfBytes(artefact);
|
||||
// Both remaining formats carry the rendered PDF, so the artefact exists from
|
||||
// here on.
|
||||
await rememberChoices(user.id, practiceName, profile);
|
||||
|
||||
if (selection.format === "pdf") {
|
||||
if (input.format === "pdf") {
|
||||
annotate({
|
||||
meta: { format: "pdf", bytes: pdfBytes.byteLength, days: range.days },
|
||||
meta: {
|
||||
format: "pdf",
|
||||
bytes: pdfBytes.byteLength,
|
||||
days: range.days,
|
||||
leafCount: profile.leaves.length,
|
||||
},
|
||||
});
|
||||
const buffer = pdfBytes.slice().buffer;
|
||||
return new NextResponse(buffer, {
|
||||
@@ -266,25 +274,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
// format === "package": one zip holding the PDF + FHIR Bundle + a README.
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
data,
|
||||
{ insuranceNumber },
|
||||
undefined,
|
||||
{ germanAtc },
|
||||
fhirRecords,
|
||||
);
|
||||
const readme = t("doctorReport.packageReadme");
|
||||
const zipped = zipSync(
|
||||
{
|
||||
"report.pdf": pdfBytes,
|
||||
"bundle.json": strToU8(JSON.stringify(bundle, null, 2)),
|
||||
"README.txt": strToU8(readme),
|
||||
},
|
||||
{ level: 6 },
|
||||
);
|
||||
const zipped = buildPackageZip(artefact, pdfBytes);
|
||||
annotate({
|
||||
meta: { format: "package", bytes: zipped.byteLength, days: range.days },
|
||||
meta: {
|
||||
format: "package",
|
||||
bytes: zipped.byteLength,
|
||||
days: range.days,
|
||||
leafCount: profile.leaves.length,
|
||||
},
|
||||
});
|
||||
const zipBuffer = zipped.slice().buffer;
|
||||
return new NextResponse(zipBuffer, {
|
||||
@@ -297,26 +294,3 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Extract plain summary text from the cached insights JSON blob. The cache
|
||||
* is provider-shaped JSON; we pull the human-readable summary field if
|
||||
* present, else fall back to a trimmed stringification. Always plain text
|
||||
* (no markdown rendering — the PDF prints React-free string children).
|
||||
*/
|
||||
function extractAiSummaryText(raw: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const candidate =
|
||||
(typeof parsed.summary === "string" && parsed.summary) ||
|
||||
(typeof parsed.text === "string" && parsed.text) ||
|
||||
(typeof parsed.briefing === "string" && parsed.briefing) ||
|
||||
null;
|
||||
if (candidate) return candidate.trim().slice(0, 4000);
|
||||
return null;
|
||||
} catch {
|
||||
// Not JSON — treat as plain text.
|
||||
const trimmed = raw.trim();
|
||||
return trimmed.length > 0 ? trimmed.slice(0, 4000) : null;
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,10 @@ import {
|
||||
FHIR_SEARCH_PARAMS,
|
||||
} from "@/lib/fhir/rest";
|
||||
import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link";
|
||||
import { exportSectionsSchema } from "@/lib/validations/health-record-export";
|
||||
import {
|
||||
ALL_LEAF_IDS,
|
||||
REPORT_GROUP_ORDER,
|
||||
} from "@/lib/report-selection/catalogue";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -113,17 +116,22 @@ export const GET = apiHandler(async () => {
|
||||
operations: [FHIR_EVERYTHING_OPERATION],
|
||||
searchParams: FHIR_SEARCH_PARAMS,
|
||||
},
|
||||
// Clinician share-link surface (v1.11): a scoped, time-boxed, revocable
|
||||
// read-only link to the owner's record. Descriptor sourced from the
|
||||
// canonical share validation + export-section constants. The share serves
|
||||
// the rendered record view only; no `/api/fhir/*` route honours a share
|
||||
// token yet (they require an authenticated `fhir:read` Bearer), so the
|
||||
// share→FHIR face is not advertised as live.
|
||||
// Clinician share-link surface: a scoped, time-boxed, revocable read-only
|
||||
// link to the owner's record, sourced from the canonical share validation
|
||||
// and the selection catalogue.
|
||||
//
|
||||
// `reportDownload` replaces the old `fhirApi: false`, which described a
|
||||
// face that was never built. The record IS reachable in machine form now:
|
||||
// as a download under the token, behind the same unlock the page uses,
|
||||
// serving exactly the link's frozen selection. No bearer face, no new
|
||||
// scope — `/api/fhir/*` still refuses a share token.
|
||||
share: {
|
||||
supported: true,
|
||||
maxDays: SHARE_LINK_MAX_DAYS,
|
||||
fhirApi: false,
|
||||
sections: Object.keys(exportSectionsSchema.shape),
|
||||
reportDownload: ["fhir", "pdf"],
|
||||
selectionVersion: 2,
|
||||
groups: REPORT_GROUP_ORDER,
|
||||
leaves: ALL_LEAF_IDS,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -28,8 +28,16 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
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 {
|
||||
createShareLinkSchema,
|
||||
SHARE_LINK_FORBIDDEN_LEAVES,
|
||||
} from "@/lib/validations/clinician-share-link";
|
||||
import {
|
||||
EMPTY_REPORT_SELECTION,
|
||||
reportSelectionSchema,
|
||||
selectionFromRequest,
|
||||
selectionToBlob,
|
||||
} from "@/lib/report-selection/selection";
|
||||
import {
|
||||
generatePassphrase,
|
||||
hashPassphrase,
|
||||
@@ -66,6 +74,10 @@ const shareLinkSummarySelect = {
|
||||
rangeStart: true,
|
||||
rangeEnd: true,
|
||||
documentOnly: true,
|
||||
// Read to answer one owner-facing question: does this link's frozen scope
|
||||
// predate the selection model? Never returned — only the boolean derived
|
||||
// from it is.
|
||||
sectionsJson: true,
|
||||
passphraseHash: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
@@ -75,6 +87,22 @@ const shareLinkSummarySelect = {
|
||||
_count: { select: { documents: true } },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* RETIRED, kept on the response only.
|
||||
*
|
||||
* These two fields described a scoped FHIR face for a share link that was
|
||||
* never built. The request schema refuses them — sending either is a 422 —
|
||||
* their columns are dropped, and nothing reads or honours them. They stay in
|
||||
* the create and list responses, serving the constant `false` and `[]`,
|
||||
* because the native client was given this response shape and a missing
|
||||
* non-optional key fails a whole Swift decode. They come out once #70 in the
|
||||
* native repository confirms the client does not decode them.
|
||||
*/
|
||||
const RETIRED_FHIR_SCOPE_FIELDS = {
|
||||
allowFhirApi: false,
|
||||
resourceTypes: [] as string[],
|
||||
} as const;
|
||||
|
||||
/** Project a stored row to the safe owner-facing shape (never the token). */
|
||||
function toSummary(row: {
|
||||
id: string;
|
||||
@@ -82,6 +110,7 @@ function toSummary(row: {
|
||||
rangeStart: Date;
|
||||
rangeEnd: Date | null;
|
||||
documentOnly: boolean;
|
||||
sectionsJson: unknown;
|
||||
passphraseHash: string | null;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
@@ -98,6 +127,14 @@ function toSummary(row: {
|
||||
// v1.28.16 — the frozen documents-only flag (owner-facing; the serve path
|
||||
// gates on the column, not on "are all sections off?").
|
||||
documentOnly: row.documentOnly,
|
||||
// A record link whose frozen scope predates the selection model. The
|
||||
// v1.32.37 migration revoked every one of them, because their scope was
|
||||
// "everything except mood and cycle" — a scope nobody chose. The sharing
|
||||
// list surfaces this so the owner sees why the link closed and can re-mint
|
||||
// with the same label, window and expiry, stopped at the selection step.
|
||||
needsReselection:
|
||||
!row.documentOnly &&
|
||||
!reportSelectionSchema.safeParse(row.sectionsJson).success,
|
||||
// v1.18.7 — surface only WHETHER a passphrase guards the link, never the
|
||||
// hash itself. Legacy (null-hash) links read `false` and stay ungated.
|
||||
protected: row.passphraseHash !== null,
|
||||
@@ -134,16 +171,41 @@ 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
|
||||
// column 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: an
|
||||
// explicit all-OFF prefs blob, distinct from the `{}` that means "full record
|
||||
// defaults". "Share this document" means the document, not the whole record.
|
||||
// v1.28.13 — a documents-only share carries NO report scope. Force it empty
|
||||
// here (not just at the client) so a document link can never serve a single
|
||||
// health metric, whatever the body tried to smuggle. "Share this document"
|
||||
// means the document, not the whole record.
|
||||
const documentOnly = input.documentOnly === true;
|
||||
const sectionsJson = documentOnly
|
||||
? EMPTY_DOCTOR_REPORT_PREFS
|
||||
: (input.sections ?? {});
|
||||
annotate({ meta: { documentOnly } });
|
||||
|
||||
let selection = EMPTY_REPORT_SELECTION;
|
||||
if (!documentOnly && input.selection) {
|
||||
const minted = selectionFromRequest(input.selection);
|
||||
if (!minted.ok) {
|
||||
return apiError(
|
||||
`Unknown selection leaf: ${minted.error.unknownLeaves.join(", ")}`,
|
||||
422,
|
||||
{ errorCode: "share-link.selection.unknown_leaf" },
|
||||
);
|
||||
}
|
||||
selection = minted.selection;
|
||||
}
|
||||
|
||||
// The insurance leaf is refused on a share link. The share view has never
|
||||
// decrypted the insurance number, and refusing it here makes that structural
|
||||
// rather than incidental: there is no path by which a link could carry it.
|
||||
const forbidden = SHARE_LINK_FORBIDDEN_LEAVES.filter((leaf) =>
|
||||
selection.has(leaf),
|
||||
);
|
||||
if (forbidden.length > 0) {
|
||||
return apiError(`A share link cannot carry: ${forbidden.join(", ")}`, 422, {
|
||||
errorCode: "share-link.selection.forbidden_leaf",
|
||||
});
|
||||
}
|
||||
|
||||
const sectionsJson = selectionToBlob(selection);
|
||||
annotate({
|
||||
meta: { documentOnly, leafCount: selection.leaves.length },
|
||||
});
|
||||
|
||||
// Mint the 192-bit raw token, store only its HMAC hash.
|
||||
const rawToken = `hls_${randomBytes(24).toString("hex")}`;
|
||||
@@ -217,6 +279,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiSuccess(
|
||||
{
|
||||
...toSummary(created),
|
||||
...RETIRED_FHIR_SCOPE_FIELDS,
|
||||
token: rawToken,
|
||||
passphrase: rawPassphrase,
|
||||
shareUrl,
|
||||
@@ -236,5 +299,10 @@ export const GET = apiHandler(async () => {
|
||||
select: shareLinkSummarySelect,
|
||||
});
|
||||
|
||||
return apiSuccess({ shareLinks: rows.map(toSummary) });
|
||||
return apiSuccess({
|
||||
shareLinks: rows.map((row) => ({
|
||||
...toSummary(row),
|
||||
...RETIRED_FHIR_SCOPE_FIELDS,
|
||||
})),
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user