mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(fhir): read-only FHIR R4 REST search routes
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
6c3b8be099
commit
57b8fd8ad9
10 files changed
+809
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<typeof import("@/lib/fhir/rest")>();
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -2102,6 +2102,150 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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"],
|
||||
|
||||
Reference in new issue
Block a user