mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
/api/fhir/* serves the same whole-record aggregate as /api/export/health-record -- right down to the decrypted insurance number on the Patient resource -- but never consulted the module gate. The export refuses with a 403 module.disabled envelope when the account turns the doctor-report module off; the FHIR face happily returned the full Bundle to the same token. Gate all five data routes (Patient, Observation, MedicationStatement, MedicationAdministration, $everything). Refuse rather than omit: a whole-record export has no truthful partial answer, and unlike the sync delta feed no background client depends on it draining to stay consistent. The refusal reuses the shared module.disabled envelope so the errorCode clients already branch on stays the same. loadFhirContext gains a fail-closed assertion as a second layer. It is the one door to the aggregate, so it refuses outright instead of trusting that every future caller remembers its gate; reaching it means a route is missing one, which is a bug to surface rather than a flow to serve. The static CapabilityStatement at /api/fhir/metadata stays open -- server metadata, no user data.
57 lines
2.5 KiB
TypeScript
57 lines
2.5 KiB
TypeScript
/**
|
|
* 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 { requireModuleEnabled } from "@/lib/modules/gate";
|
|
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" } });
|
|
|
|
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
|
|
// `/api/export/health-record` (the doctor-report builder), right down to
|
|
// the decrypted insurance number on the Patient resource. That export
|
|
// gates on the `doctorReport` module; this surface did not, so the module
|
|
// could be off and `/api/fhir/*` still handed out the full record.
|
|
//
|
|
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
|
|
// a whole-record export, not an incremental feed: there is no partial
|
|
// answer that is still a truthful FHIR Bundle, and no background client
|
|
// depends on it draining to stay consistent. Mirroring the sibling export's
|
|
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
|
|
// the errorCode the clients already branch on for a disabled module.
|
|
const gate = await requireModuleEnabled(user.id, "doctorReport");
|
|
if (!gate.enabled) return gate.response;
|
|
|
|
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);
|
|
});
|