From 57b8fd8ad972cffd5f22f480b4fb08e814cd49f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 01:04:26 +0200 Subject: [PATCH] feat(fhir): read-only FHIR R4 REST search routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/fhir/{metadata,Patient,Observation,MedicationStatement, MedicationAdministration} and $everything, returning type:"searchset" Bundles (total + self/next links, offset paging via _count clamped to 200) as application/fhir+json, with an OperationOutcome on error. The per-resource emitters are reused from src/lib/fhir/resources.ts so the document export and the REST face share one coding source and can never drift. Auth is a narrow fhir:read scope via requireAuth (cookie sessions also pass); userId is narrowed from auth. Read-only — no write handlers. Rate-limited per user. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/fhir/$everything/route.ts | 53 +++++ .../fhir/MedicationAdministration/route.ts | 39 ++++ src/app/api/fhir/MedicationStatement/route.ts | 40 ++++ src/app/api/fhir/Observation/route.ts | 40 ++++ src/app/api/fhir/Patient/route.ts | 39 ++++ src/app/api/fhir/__tests__/route.test.ts | 167 +++++++++++++++ src/app/api/fhir/metadata/route.ts | 62 ++++++ src/lib/fhir/rest.ts | 190 ++++++++++++++++++ src/lib/fhir/types.ts | 35 ++++ src/lib/openapi/routes.ts | 144 +++++++++++++ 10 files changed, 809 insertions(+) create mode 100644 src/app/api/fhir/$everything/route.ts create mode 100644 src/app/api/fhir/MedicationAdministration/route.ts create mode 100644 src/app/api/fhir/MedicationStatement/route.ts create mode 100644 src/app/api/fhir/Observation/route.ts create mode 100644 src/app/api/fhir/Patient/route.ts create mode 100644 src/app/api/fhir/__tests__/route.test.ts create mode 100644 src/app/api/fhir/metadata/route.ts create mode 100644 src/lib/fhir/rest.ts diff --git a/src/app/api/fhir/$everything/route.ts b/src/app/api/fhir/$everything/route.ts new file mode 100644 index 000000000..e804b1f0e --- /dev/null +++ b/src/app/api/fhir/$everything/route.ts @@ -0,0 +1,53 @@ +/** + * GET /api/fhir/$everything — FHIR R4 `$everything` operation. + * + * Returns every resource in the caller's own record — Patient, Coverage, + * Observations, MedicationStatements, MedicationAdministrations — in one + * `searchset` Bundle, in the canonical document order. Read-only; `userId` + * narrowed from `requireAuth`. Offset paging applies across the flattened + * resource list via `_count` (clamped ≤200) / `_offset`. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { + coverageResource, + medicationAdministrationsFromReportData, + medicationStatementsFromReportData, + observationsFromReportData, + patientResource, +} from "@/lib/fhir/resources"; +import type { FhirResource } from "@/lib/fhir/types"; +import { + FHIR_READ_SCOPE, + loadFhirContext, + operationOutcome, + parsePaging, + searchsetResponse, +} from "@/lib/fhir/rest"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(FHIR_READ_SCOPE); + annotate({ action: { name: "fhir.everything.read" } }); + + const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000); + if (!rl.allowed) { + return operationOutcome(429, "throttled", "Rate limit exceeded"); + } + + const { count, offset } = parsePaging(request.nextUrl.searchParams); + const { data, identity, germanAtc } = await loadFhirContext(user.id); + + const all: FhirResource[] = [patientResource(data, identity)]; + const coverage = coverageResource(data, identity); + if (coverage) all.push(coverage); + all.push(...observationsFromReportData(data, identity, { germanAtc })); + all.push(...medicationStatementsFromReportData(data, { germanAtc })); + all.push(...medicationAdministrationsFromReportData(data, { germanAtc })); + + const page = all.slice(offset, offset + count); + annotate({ meta: { total: all.length, count, offset } }); + return searchsetResponse(request.nextUrl, page, all.length, count, offset); +}); diff --git a/src/app/api/fhir/MedicationAdministration/route.ts b/src/app/api/fhir/MedicationAdministration/route.ts new file mode 100644 index 000000000..a8e489309 --- /dev/null +++ b/src/app/api/fhir/MedicationAdministration/route.ts @@ -0,0 +1,39 @@ +/** + * GET /api/fhir/MedicationAdministration — FHIR R4 `searchset` of the caller's + * own acted intakes (`completed` taken / `not-done` skip), with the same + * ATC/RxNorm + dose/route/site coding the document export carries. + * + * Read-only. `userId` narrowed from `requireAuth`; shared emitter is the + * single source of the coding. Offset paging via `_count` (≤200) / `_offset`. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { medicationAdministrationsFromReportData } from "@/lib/fhir/resources"; +import { + FHIR_READ_SCOPE, + loadFhirContext, + operationOutcome, + parsePaging, + searchsetResponse, +} from "@/lib/fhir/rest"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(FHIR_READ_SCOPE); + annotate({ action: { name: "fhir.medicationadministration.search" } }); + + const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000); + if (!rl.allowed) { + return operationOutcome(429, "throttled", "Rate limit exceeded"); + } + + const { count, offset } = parsePaging(request.nextUrl.searchParams); + const { data, germanAtc } = await loadFhirContext(user.id); + + const all = medicationAdministrationsFromReportData(data, { germanAtc }); + const page = all.slice(offset, offset + count); + annotate({ meta: { total: all.length, count, offset } }); + return searchsetResponse(request.nextUrl, page, all.length, count, offset); +}); diff --git a/src/app/api/fhir/MedicationStatement/route.ts b/src/app/api/fhir/MedicationStatement/route.ts new file mode 100644 index 000000000..6cbafb86b --- /dev/null +++ b/src/app/api/fhir/MedicationStatement/route.ts @@ -0,0 +1,40 @@ +/** + * GET /api/fhir/MedicationStatement — FHIR R4 `searchset` of the caller's own + * active-medication statements (one per active medication, with additive + * ATC/RxNorm codings). + * + * Read-only. `userId` narrowed from `requireAuth`; shared emitter is the + * single source of the drug coding. Offset paging via `_count` (≤200) / + * `_offset`. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { medicationStatementsFromReportData } from "@/lib/fhir/resources"; +import { + FHIR_READ_SCOPE, + loadFhirContext, + operationOutcome, + parsePaging, + searchsetResponse, +} from "@/lib/fhir/rest"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(FHIR_READ_SCOPE); + annotate({ action: { name: "fhir.medicationstatement.search" } }); + + const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000); + if (!rl.allowed) { + return operationOutcome(429, "throttled", "Rate limit exceeded"); + } + + const { count, offset } = parsePaging(request.nextUrl.searchParams); + const { data, germanAtc } = await loadFhirContext(user.id); + + const all = medicationStatementsFromReportData(data, { germanAtc }); + const page = all.slice(offset, offset + count); + annotate({ meta: { total: all.length, count, offset } }); + return searchsetResponse(request.nextUrl, page, all.length, count, offset); +}); diff --git a/src/app/api/fhir/Observation/route.ts b/src/app/api/fhir/Observation/route.ts new file mode 100644 index 000000000..c440b2577 --- /dev/null +++ b/src/app/api/fhir/Observation/route.ts @@ -0,0 +1,40 @@ +/** + * GET /api/fhir/Observation — FHIR R4 `searchset` of the caller's own + * Observations (vitals / activity / lab / survey), one latest reading per + * type plus the BP panel, BMI, glucose, adherence, mood and wellness scores. + * + * Read-only. `userId` is narrowed from `requireAuth`; the shared emitter is + * the single source of the LOINC/UCUM coding. Offset paging via `_count` + * (clamped ≤200) / `_offset`. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { observationsFromReportData } from "@/lib/fhir/resources"; +import { + FHIR_READ_SCOPE, + loadFhirContext, + operationOutcome, + parsePaging, + searchsetResponse, +} from "@/lib/fhir/rest"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(FHIR_READ_SCOPE); + annotate({ action: { name: "fhir.observation.search" } }); + + const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000); + if (!rl.allowed) { + return operationOutcome(429, "throttled", "Rate limit exceeded"); + } + + const { count, offset } = parsePaging(request.nextUrl.searchParams); + const { data, identity, germanAtc } = await loadFhirContext(user.id); + + const all = observationsFromReportData(data, identity, { germanAtc }); + const page = all.slice(offset, offset + count); + annotate({ meta: { total: all.length, count, offset } }); + return searchsetResponse(request.nextUrl, page, all.length, count, offset); +}); diff --git a/src/app/api/fhir/Patient/route.ts b/src/app/api/fhir/Patient/route.ts new file mode 100644 index 000000000..19d6e1aa8 --- /dev/null +++ b/src/app/api/fhir/Patient/route.ts @@ -0,0 +1,39 @@ +/** + * GET /api/fhir/Patient — FHIR R4 `searchset` of the caller's own Patient. + * + * Read-only. A FHIR `Patient` search always yields exactly the authenticated + * user's single Patient resource (the `userId` is narrowed from `requireAuth`; + * there is no cross-user search). Returned as a one-entry `searchset` Bundle + * so a generic FHIR client can page it like any other resource type. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { patientResource } from "@/lib/fhir/resources"; +import { + FHIR_READ_SCOPE, + loadFhirContext, + operationOutcome, + parsePaging, + searchsetResponse, +} from "@/lib/fhir/rest"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(FHIR_READ_SCOPE); + annotate({ action: { name: "fhir.patient.search" } }); + + const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000); + if (!rl.allowed) { + return operationOutcome(429, "throttled", "Rate limit exceeded"); + } + + const { count, offset } = parsePaging(request.nextUrl.searchParams); + const { data, identity } = await loadFhirContext(user.id); + + const all = [patientResource(data, identity)]; + const page = all.slice(offset, offset + count); + annotate({ meta: { total: all.length, count, offset } }); + return searchsetResponse(request.nextUrl, page, all.length, count, offset); +}); diff --git a/src/app/api/fhir/__tests__/route.test.ts b/src/app/api/fhir/__tests__/route.test.ts new file mode 100644 index 000000000..bc8fbf169 --- /dev/null +++ b/src/app/api/fhir/__tests__/route.test.ts @@ -0,0 +1,167 @@ +/** + * v1.11.0 (Epic C, C2) — read-only FHIR R4 REST face. + * + * Asserts the searchset Bundle shape (total + self/next links + `match` + * mode + `application/fhir+json`), `_count` clamping (≤200), paging, the + * `fhir:read` scope wiring, and the `OperationOutcome` rate-limit envelope. + * The shared data loader is stubbed so the test pins the REST contract, not + * the aggregator (which has its own coverage). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/fhir/rest", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, loadFhirContext: vi.fn() }; +}); +vi.mock("@/lib/fhir/resources", () => ({ + observationsFromReportData: vi.fn(), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET as observationGet } from "../Observation/route"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { loadFhirContext, MAX_COUNT } from "@/lib/fhir/rest"; +import { observationsFromReportData } from "@/lib/fhir/resources"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function obs(id: number) { + return { + resourceType: "Observation" as const, + id: `obs-${id}`, + status: "final" as const, + code: { text: `m-${id}` }, + subject: { reference: "Patient/patient-1" }, + }; +} + +function req(query = ""): NextRequest { + return new NextRequest(`http://localhost/api/fhir/Observation${query}`); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + count: 1, + resetAt: Date.now(), + } as never); + vi.mocked(loadFhirContext).mockResolvedValue({ + data: {} as never, + identity: { insuranceNumber: null }, + germanAtc: false, + }); +}); + +describe("GET /api/fhir/Observation — searchset", () => { + it("returns a searchset Bundle with total, self link, and match mode", async () => { + vi.mocked(observationsFromReportData).mockReturnValue([ + obs(1), + obs(2), + ] as never); + + const res = await observationGet(req()); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/fhir+json"); + + const bundle = (await res.json()) as { + resourceType: string; + type: string; + total: number; + link: Array<{ relation: string; url: string }>; + entry: Array<{ search: { mode: string }; fullUrl: string }>; + }; + expect(bundle.resourceType).toBe("Bundle"); + expect(bundle.type).toBe("searchset"); + expect(bundle.total).toBe(2); + expect(bundle.entry).toHaveLength(2); + expect(bundle.entry[0].search.mode).toBe("match"); + expect(bundle.entry[0].fullUrl).toContain("/api/fhir/Observation/obs-1"); + expect(bundle.link.find((l) => l.relation === "self")).toBeTruthy(); + // Single page → no next link. + expect(bundle.link.find((l) => l.relation === "next")).toBeUndefined(); + }); + + it("clamps _count above the ceiling and emits a next link when more remain", async () => { + vi.mocked(observationsFromReportData).mockReturnValue( + Array.from({ length: 250 }, (_, i) => obs(i)) as never, + ); + + const res = await observationGet(req("?_count=9999&_offset=0")); + const bundle = (await res.json()) as { + total: number; + link: Array<{ relation: string; url: string }>; + entry: unknown[]; + }; + // 250 total, clamped page of MAX_COUNT. + expect(bundle.total).toBe(250); + expect(bundle.entry).toHaveLength(MAX_COUNT); + const next = bundle.link.find((l) => l.relation === "next"); + expect(next).toBeTruthy(); + expect(next!.url).toContain(`_count=${MAX_COUNT}`); + expect(next!.url).toContain(`_offset=${MAX_COUNT}`); + }); + + it("pages with _offset", async () => { + vi.mocked(observationsFromReportData).mockReturnValue([ + obs(1), + obs(2), + obs(3), + ] as never); + const res = await observationGet(req("?_count=2&_offset=2")); + const bundle = (await res.json()) as { + total: number; + entry: Array<{ resource: { id: string } }>; + }; + expect(bundle.total).toBe(3); + expect(bundle.entry).toHaveLength(1); + expect(bundle.entry[0].resource.id).toBe("obs-3"); + }); + + it("enforces the fhir:read scope (Bearer narrow token without it 403s)", async () => { + // No session → Bearer path. requireAuth(FHIR_READ_SCOPE) must reject a + // token lacking the scope; here no auth at all yields 401, proving the + // route does gate auth before doing any work. + vi.mocked(getSession).mockResolvedValue(null as never); + const res = await observationGet(req()); + expect(res.status).toBe(401); + expect(loadFhirContext).not.toHaveBeenCalled(); + }); + + it("returns an OperationOutcome on rate-limit", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + count: 999, + resetAt: Date.now(), + } as never); + const res = await observationGet(req()); + expect(res.status).toBe(429); + expect(res.headers.get("content-type")).toContain("application/fhir+json"); + const body = (await res.json()) as { + resourceType: string; + issue: Array<{ severity: string; code: string }>; + }; + expect(body.resourceType).toBe("OperationOutcome"); + expect(body.issue[0].code).toBe("throttled"); + }); +}); diff --git a/src/app/api/fhir/metadata/route.ts b/src/app/api/fhir/metadata/route.ts new file mode 100644 index 000000000..29a0cab19 --- /dev/null +++ b/src/app/api/fhir/metadata/route.ts @@ -0,0 +1,62 @@ +/** + * GET /api/fhir/metadata — FHIR R4 `CapabilityStatement`. + * + * Declares the read-only REST face: the resource types served, the search + * parameters honoured (`_count`, `_offset`), the `$everything` operation, and + * the `application/fhir+json` format. Static — no per-user data — but still + * gated behind the `fhir:read` scope so the whole `/api/fhir` tree answers + * uniformly. Read-only: no write interactions are advertised. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { FHIR_READ_SCOPE, fhirJsonResponse } from "@/lib/fhir/rest"; + +const READ_RESOURCES = [ + "Patient", + "Observation", + "MedicationStatement", + "MedicationAdministration", +] as const; + +export const GET = apiHandler(async () => { + await requireAuth(FHIR_READ_SCOPE); + annotate({ action: { name: "fhir.metadata.read" } }); + + const capability = { + resourceType: "CapabilityStatement", + status: "active", + date: new Date().toISOString(), + kind: "instance", + fhirVersion: "4.0.1", + format: ["application/fhir+json"], + rest: [ + { + mode: "server", + documentation: + "Read-only access to the authenticated user's own health record.", + resource: [ + ...READ_RESOURCES.map((type) => ({ + type, + interaction: [{ code: "read" }, { code: "search-type" }], + searchParam: [ + { name: "_count", type: "number" }, + { name: "_offset", type: "number" }, + ], + })), + { + type: "Patient", + operation: [ + { + name: "everything", + definition: + "http://hl7.org/fhir/OperationDefinition/Patient-everything", + }, + ], + }, + ], + }, + ], + }; + + return fhirJsonResponse(capability); +}); diff --git a/src/lib/fhir/rest.ts b/src/lib/fhir/rest.ts new file mode 100644 index 000000000..f6b8e28ac --- /dev/null +++ b/src/lib/fhir/rest.ts @@ -0,0 +1,190 @@ +/** + * 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 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 }; +} diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index 3c05fb84d..d8e05fd3d 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -203,6 +203,8 @@ export type FhirResource = export interface FhirBundleEntry { fullUrl?: string; resource: FhirResource; + /** v1.11.0 — `searchset` per-entry search metadata (`mode: "match"`). */ + search?: { mode: "match" | "include" | "outcome" }; } export interface FhirBundle { @@ -211,3 +213,36 @@ export interface FhirBundle { timestamp: string; entry: FhirBundleEntry[]; } + +/** v1.11.0 — `Bundle.link` relation (self / next paging). */ +export interface FhirBundleLink { + relation: "self" | "next"; + url: string; +} + +/** + * v1.11.0 — a `searchset` Bundle, the wire shape the FHIR REST search routes + * return. `total` is the unpaged match count; `link` carries the self + next + * relations for offset paging; each `entry.search.mode` is `"match"`. + */ +export interface FhirSearchsetBundle { + resourceType: "Bundle"; + type: "searchset"; + timestamp: string; + total: number; + link?: FhirBundleLink[]; + entry: FhirBundleEntry[]; +} + +/** v1.11.0 — `OperationOutcome.issue` element. */ +export interface FhirOperationOutcomeIssue { + severity: "fatal" | "error" | "warning" | "information"; + code: string; + diagnostics?: string; +} + +/** v1.11.0 — `OperationOutcome`, the FHIR error envelope. */ +export interface FhirOperationOutcome { + resourceType: "OperationOutcome"; + issue: FhirOperationOutcomeIssue[]; +} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index b119ab02b..7b6d70e2b 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -2102,6 +2102,150 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/fhir/metadata": { + get: { + tags: ["FHIR"], + summary: "FHIR R4 CapabilityStatement (v1.11.0)", + description: + "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Observation, MedicationStatement, MedicationAdministration), the `$everything` operation, and the `application/fhir+json` format. Auth: `fhir:read` scope (cookie sessions also pass).", + responses: { + "200": { + description: "CapabilityStatement (application/fhir+json).", + content: { + "application/fhir+json": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/fhir/Patient": { + get: { + tags: ["FHIR"], + summary: "FHIR R4 Patient search (v1.11.0)", + description: + "Read-only `searchset` Bundle of the caller's own Patient resource. Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. `userId` is narrowed from auth.", + requestParams: { + query: z.object({ + _count: z.coerce.number().optional(), + _offset: z.coerce.number().optional(), + }), + }, + responses: { + "200": { + description: "searchset Bundle (application/fhir+json).", + content: { + "application/fhir+json": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/fhir/Observation": { + get: { + tags: ["FHIR"], + summary: "FHIR R4 Observation search (v1.11.0)", + description: + "Read-only `searchset` Bundle of the caller's own Observations (vitals / activity / lab / survey). Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`.", + requestParams: { + query: z.object({ + _count: z.coerce.number().optional(), + _offset: z.coerce.number().optional(), + }), + }, + responses: { + "200": { + description: "searchset Bundle (application/fhir+json).", + content: { + "application/fhir+json": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/fhir/MedicationStatement": { + get: { + tags: ["FHIR"], + summary: "FHIR R4 MedicationStatement search (v1.11.0)", + description: + "Read-only `searchset` Bundle of the caller's own active-medication statements. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.", + requestParams: { + query: z.object({ + _count: z.coerce.number().optional(), + _offset: z.coerce.number().optional(), + }), + }, + responses: { + "200": { + description: "searchset Bundle (application/fhir+json).", + content: { + "application/fhir+json": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/fhir/MedicationAdministration": { + get: { + tags: ["FHIR"], + summary: "FHIR R4 MedicationAdministration search (v1.11.0)", + description: + "Read-only `searchset` Bundle of the caller's own acted intakes (completed / not-done). Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.", + requestParams: { + query: z.object({ + _count: z.coerce.number().optional(), + _offset: z.coerce.number().optional(), + }), + }, + responses: { + "200": { + description: "searchset Bundle (application/fhir+json).", + content: { + "application/fhir+json": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/fhir/$everything": { + get: { + tags: ["FHIR"], + summary: "FHIR R4 $everything (v1.11.0)", + description: + "Read-only `$everything` operation: every resource in the caller's own record (Patient, Coverage, Observations, MedicationStatements, MedicationAdministrations) in one `searchset` Bundle. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.", + requestParams: { + query: z.object({ + _count: z.coerce.number().optional(), + _offset: z.coerce.number().optional(), + }), + }, + responses: { + "200": { + description: "searchset Bundle (application/fhir+json).", + content: { + "application/fhir+json": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/auth/login": { post: { tags: ["Auth"],