mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Add a single canonical FHIR_REST_RESOURCE_TYPES catalogue (plus the $everything operation and honoured search params) to fhir/rest.ts; the metadata CapabilityStatement and the share-link resource-type enum now derive from it so the advertised set can never drift from what is routed. Extend GET /api/meta/capabilities with the FHIR REST descriptor (restBaseUrl, readScope, resourceTypes, operations, searchParams) and a clinician-share descriptor (supported, maxDays, resourceTypes, scopeable sections) — every list sourced from its canonical registry, guarded by the existing drift assertions. Grow the OpenAPI CapabilitiesResponse to match. Fill the clinician test-matrix gap: assert the clinician view never requests a KVNR / identifier opt-in from the aggregator (default-OFF by construction) and resolves a rolling window without reaching data older than the frozen start.
215 lines
7.4 KiB
TypeScript
215 lines
7.4 KiB
TypeScript
/**
|
|
* v1.11.0 — shared helpers for the read-only FHIR R4 REST face.
|
|
*
|
|
* The `GET /api/fhir/*` search routes are thin: each resolves the caller's own
|
|
* `DoctorReportData` (the SAME aggregator the PDF + document export consume),
|
|
* runs the matching shared emitter from `./resources`, then wraps the result
|
|
* in a `type: "searchset"` Bundle via the helpers here. Keeping the bundling,
|
|
* paging, content-type and `OperationOutcome` shaping in one place means every
|
|
* resource endpoint answers identically.
|
|
*
|
|
* Read-only: there are no write handlers anywhere under `/api/fhir`. Auth is a
|
|
* narrow `fhir:read` Bearer scope (cookie sessions also pass); `userId` is
|
|
* always narrowed from the auth context, never accepted from the wire.
|
|
*/
|
|
import { NextResponse } from "next/server";
|
|
|
|
import { decrypt } from "@/lib/crypto";
|
|
import { prisma } from "@/lib/db";
|
|
import {
|
|
collectDoctorReportData,
|
|
normaliseDateRange,
|
|
type DoctorReportData,
|
|
} from "@/lib/doctor-report-data";
|
|
import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/resources";
|
|
import type {
|
|
FhirBundleEntry,
|
|
FhirBundleLink,
|
|
FhirOperationOutcome,
|
|
FhirResource,
|
|
FhirSearchsetBundle,
|
|
} from "@/lib/fhir/types";
|
|
|
|
/** The Bearer scope a narrow-scoped token must carry to read the FHIR face. */
|
|
export const FHIR_READ_SCOPE = "fhir:read";
|
|
|
|
/**
|
|
* Canonical catalogue of FHIR R4 resource types the read-only REST face serves
|
|
* (`read` + `search-type` interactions only — no write, ever). One source of
|
|
* truth: the `metadata` CapabilityStatement, the `/api/meta/capabilities`
|
|
* discovery surface, and the share-link resource-type enum all derive from
|
|
* this so the advertised set can never drift from what is actually routed.
|
|
*/
|
|
export const FHIR_REST_RESOURCE_TYPES = [
|
|
"Patient",
|
|
"Observation",
|
|
"MedicationStatement",
|
|
"MedicationAdministration",
|
|
] as const;
|
|
|
|
/**
|
|
* The whole-record operation the REST face exposes (`GET /api/fhir/$everything`),
|
|
* returning the existing `type: "document"` Bundle. Surfaced in discovery so a
|
|
* client knows the snapshot pull exists alongside the per-resource searches.
|
|
*/
|
|
export const FHIR_EVERYTHING_OPERATION = "$everything";
|
|
|
|
/** Search parameters honoured uniformly across the search routes. */
|
|
export const FHIR_SEARCH_PARAMS = ["_count", "_offset"] as const;
|
|
|
|
/** Canonical FHIR media type for every response (success or outcome). */
|
|
export const FHIR_CONTENT_TYPE = "application/fhir+json; charset=utf-8";
|
|
|
|
/** Default page size; clamped to `[1, MAX_COUNT]`. */
|
|
export const DEFAULT_COUNT = 50;
|
|
/** Hard ceiling on `_count` so a single search can never page the whole store. */
|
|
export const MAX_COUNT = 200;
|
|
|
|
/**
|
|
* Parsed + clamped paging parameters. `count` is bounded to `[1, MAX_COUNT]`;
|
|
* `offset` floors at 0. A non-numeric / negative input collapses to the
|
|
* default rather than erroring — FHIR search is forgiving on paging params.
|
|
*/
|
|
export function parsePaging(searchParams: URLSearchParams): {
|
|
count: number;
|
|
offset: number;
|
|
} {
|
|
const rawCount = Number(searchParams.get("_count"));
|
|
const count =
|
|
Number.isFinite(rawCount) && rawCount > 0
|
|
? Math.min(Math.floor(rawCount), MAX_COUNT)
|
|
: DEFAULT_COUNT;
|
|
const rawOffset = Number(searchParams.get("_offset"));
|
|
const offset =
|
|
Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0;
|
|
return { count, offset };
|
|
}
|
|
|
|
/**
|
|
* Build the absolute self link, and a next link when more rows remain. The
|
|
* URL is rebuilt from the request URL with the paging params normalised, so
|
|
* the echoed link always carries the clamped `_count` and concrete `_offset`.
|
|
*/
|
|
function pagingLinks(
|
|
requestUrl: URL,
|
|
total: number,
|
|
count: number,
|
|
offset: number,
|
|
): FhirBundleLink[] {
|
|
const self = new URL(requestUrl.toString());
|
|
self.searchParams.set("_count", String(count));
|
|
self.searchParams.set("_offset", String(offset));
|
|
const links: FhirBundleLink[] = [{ relation: "self", url: self.toString() }];
|
|
|
|
const nextOffset = offset + count;
|
|
if (nextOffset < total) {
|
|
const next = new URL(requestUrl.toString());
|
|
next.searchParams.set("_count", String(count));
|
|
next.searchParams.set("_offset", String(nextOffset));
|
|
links.push({ relation: "next", url: next.toString() });
|
|
}
|
|
return links;
|
|
}
|
|
|
|
/**
|
|
* Wrap a page of resources in a `searchset` Bundle and return it as a FHIR
|
|
* JSON response. `total` is the FULL unpaged match count; `page` is the
|
|
* already-sliced window. Each entry is tagged `search.mode: "match"`.
|
|
*/
|
|
export function searchsetResponse(
|
|
requestUrl: URL,
|
|
page: FhirResource[],
|
|
total: number,
|
|
count: number,
|
|
offset: number,
|
|
): NextResponse {
|
|
const entry: FhirBundleEntry[] = page.map((resource) => ({
|
|
fullUrl: `${requestUrl.origin}/api/fhir/${resource.resourceType}/${resource.id}`,
|
|
resource,
|
|
search: { mode: "match" },
|
|
}));
|
|
|
|
const bundle: FhirSearchsetBundle = {
|
|
resourceType: "Bundle",
|
|
type: "searchset",
|
|
timestamp: new Date().toISOString(),
|
|
total,
|
|
link: pagingLinks(requestUrl, total, count, offset),
|
|
entry,
|
|
};
|
|
|
|
return new NextResponse(JSON.stringify(bundle), {
|
|
status: 200,
|
|
headers: { "Content-Type": FHIR_CONTENT_TYPE, "Cache-Control": "no-store" },
|
|
});
|
|
}
|
|
|
|
/** Return a raw FHIR resource (e.g. CapabilityStatement) as a FHIR response. */
|
|
export function fhirJsonResponse(body: unknown): NextResponse {
|
|
return new NextResponse(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { "Content-Type": FHIR_CONTENT_TYPE, "Cache-Control": "no-store" },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build an `OperationOutcome` JSON response — the FHIR error envelope. Used
|
|
* for 404 (no such resource) and any other FHIR-shaped error the routes
|
|
* surface. (Auth 401/403 stay on the standard envelope via `requireAuth`.)
|
|
*/
|
|
export function operationOutcome(
|
|
status: number,
|
|
code: string,
|
|
diagnostics: string,
|
|
severity: FhirOperationOutcome["issue"][number]["severity"] = "error",
|
|
): NextResponse {
|
|
const outcome: FhirOperationOutcome = {
|
|
resourceType: "OperationOutcome",
|
|
issue: [{ severity, code, diagnostics }],
|
|
};
|
|
return new NextResponse(JSON.stringify(outcome), {
|
|
status,
|
|
headers: { "Content-Type": FHIR_CONTENT_TYPE, "Cache-Control": "no-store" },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve everything an emitter needs for a caller: the aggregated report data
|
|
* over the default reporting window, plus the decrypted KVNR identity and the
|
|
* BfArM-ATC flag (derived from the user's locale). Centralised so each
|
|
* resource route is a one-liner over the shared emitters.
|
|
*
|
|
* The window matches the document export's default (`normaliseDateRange`
|
|
* with no override). KVNR decryption is fail-soft: a key-rotation gap on a
|
|
* single row omits the identifier rather than 500-ing the read.
|
|
*/
|
|
export async function loadFhirContext(userId: string): Promise<{
|
|
data: DoctorReportData;
|
|
identity: { insuranceNumber: string | null };
|
|
germanAtc: boolean;
|
|
}> {
|
|
const range = normaliseDateRange(undefined);
|
|
const [data, userRow] = await Promise.all([
|
|
collectDoctorReportData(userId, range, {}),
|
|
prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { insuranceNumberEncrypted: true, locale: true },
|
|
}),
|
|
]);
|
|
|
|
let insuranceNumber: string | null = null;
|
|
if (userRow?.insuranceNumberEncrypted) {
|
|
try {
|
|
insuranceNumber = decrypt(userRow.insuranceNumberEncrypted);
|
|
} catch {
|
|
insuranceNumber = null;
|
|
}
|
|
}
|
|
|
|
const germanAtc = (GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(
|
|
userRow?.locale ?? "",
|
|
);
|
|
|
|
return { data, identity: { insuranceNumber }, germanAtc };
|
|
}
|