diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index 543156eb4..073af830a 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -39,7 +39,10 @@ import { sanitisePracticeName, } from "@/lib/doctor-report-data"; import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core"; -import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle"; +import { + buildFhirDocumentBundle, + GERMAN_ATC_DEFAULT_LOCALES, +} from "@/lib/fhir/build-bundle"; import { exportSelectionSchema, toDoctorReportPrefs, @@ -99,7 +102,9 @@ export const POST = apiHandler(async (request: NextRequest) => { // 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 ?? locale === "de"; + const germanAtc = + selection.germanAtc ?? + (GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(locale); const [data, userTz, userRow] = await Promise.all([ collectDoctorReportData(user.id, range, { practiceName, sections }), diff --git a/src/app/api/meta/capabilities/__tests__/route.test.ts b/src/app/api/meta/capabilities/__tests__/route.test.ts new file mode 100644 index 000000000..65aae485e --- /dev/null +++ b/src/app/api/meta/capabilities/__tests__/route.test.ts @@ -0,0 +1,172 @@ +/** + * GET /api/meta/capabilities route tests. + * + * This endpoint exists to retire the "doc says N, server ships M" enum-drift + * class for the native client — so the drift-guard assertions ARE the point: + * + * 1. 401 when unauthenticated. + * 2. `derivedMetricIds` is exactly the derived registry's id set (same + * length AND members) — the endpoint can never silently diverge from + * the registry it claims to mirror. + * 3. `ingest.writeAllowlist` deep-equals the `WRITABLE_MEASUREMENT_SOURCES` + * constant — a client reads the live write allowlist, not a copy. + * 4. Every quantity-mapping row + the FHIR constants + the contract version + * are sourced from the canonical server constants, not hand-typed here. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// The route transitively imports `@/lib/insights/derived/wellness-scores`, +// which imports the Prisma client; mock it so the module graph loads without +// a real DB. The route itself only reads `WELLNESS_SCORE_TYPES` (a plain +// object), never the client. +vi.mock("@/lib/db", () => ({ prisma: {} })); + +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 } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { DERIVED_METRIC_IDS } from "@/lib/insights/derived/registry"; +import { WRITABLE_MEASUREMENT_SOURCES } from "@/lib/validations/measurement"; +import { APPLE_HEALTH_TYPE_MAP } from "@/lib/measurements/apple-health-mapping"; +import { + ATC_SYSTEM, + SNOMED_SYSTEM, + GERMAN_ATC_DEFAULT_LOCALES, +} from "@/lib/fhir/build-bundle"; + +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 }, +}; + +type CapabilitiesBody = { + data: { + apiContractVersion: string; + derivedMetricIds: string[]; + vitalsBaselineTypes: string[]; + layoutTileIds: string[]; + metricStatusIds: string[]; + ingest: { + quantityTypes: { type: string; hk: string; unit: string }[]; + eventTypes: string[]; + computedScores: string[]; + writeAllowlist: string[]; + }; + fhir: { + atcSystem: string; + snomedRoute: string; + germanAtcDefaultLocales: string[]; + }; + }; +}; + +async function call(): Promise { + return (GET as unknown as () => Promise)(); +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("GET /api/meta/capabilities — auth", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await call(); + expect(res.status).toBe(401); + }); +}); + +describe("GET /api/meta/capabilities — drift guards", () => { + beforeEach(() => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + }); + + it("derivedMetricIds is exactly the derived registry's id set", async () => { + const res = await call(); + expect(res.status).toBe(200); + const body = (await res.json()) as CapabilitiesBody; + + // Length AND membership — the endpoint cannot silently drift from the + // registry (the whole reason it exists). + expect(body.data.derivedMetricIds.length).toBe(DERIVED_METRIC_IDS.length); + expect([...body.data.derivedMetricIds].sort()).toEqual( + [...DERIVED_METRIC_IDS].sort(), + ); + }); + + it("ingest.writeAllowlist deep-equals WRITABLE_MEASUREMENT_SOURCES", async () => { + const res = await call(); + const body = (await res.json()) as CapabilitiesBody; + expect(body.data.ingest.writeAllowlist).toEqual([ + ...WRITABLE_MEASUREMENT_SOURCES, + ]); + }); + + it("computedScores are the three persisted wellness scores", async () => { + const res = await call(); + const body = (await res.json()) as CapabilitiesBody; + expect([...body.data.ingest.computedScores].sort()).toEqual( + ["RECOVERY_SCORE", "STRAIN_SCORE", "STRESS_SCORE"], + ); + }); + + it("fhir constants mirror the FHIR builder source", async () => { + const res = await call(); + const body = (await res.json()) as CapabilitiesBody; + expect(body.data.fhir.atcSystem).toBe(ATC_SYSTEM); + expect(body.data.fhir.snomedRoute).toBe(SNOMED_SYSTEM); + expect(body.data.fhir.germanAtcDefaultLocales).toEqual([ + ...GERMAN_ATC_DEFAULT_LOCALES, + ]); + }); + + it("quantityTypes are sourced from the HealthKit ingest mapping", async () => { + const res = await call(); + const body = (await res.json()) as CapabilitiesBody; + + // Every emitted quantity row must trace back to a real mapping entry — + // never an entry the ingest path doesn't actually accept. + const byHk = new Map( + Object.values(APPLE_HEALTH_TYPE_MAP).map((m) => [m.hkIdentifier, m]), + ); + expect(body.data.ingest.quantityTypes.length).toBeGreaterThan(0); + for (const q of body.data.ingest.quantityTypes) { + const mapping = byHk.get(q.hk); + expect(mapping).toBeDefined(); + expect(q.type).toBe(mapping!.measurementType); + expect(q.unit).toBe(mapping!.dbUnit); + // Quantity rows are the non-event-class mappings. + expect( + Boolean( + mapping!.eventClassificationMap || mapping!.fallbackClassification, + ), + ).toBe(false); + } + }); + + it("apiContractVersion is a non-empty string", async () => { + const res = await call(); + const body = (await res.json()) as CapabilitiesBody; + expect(typeof body.data.apiContractVersion).toBe("string"); + expect(body.data.apiContractVersion.length).toBeGreaterThan(0); + }); +}); diff --git a/src/app/api/meta/capabilities/route.ts b/src/app/api/meta/capabilities/route.ts new file mode 100644 index 000000000..eaeeea996 --- /dev/null +++ b/src/app/api/meta/capabilities/route.ts @@ -0,0 +1,101 @@ +/** + * GET /api/meta/capabilities + * + * Live capability / discovery surface for the native client. Returns the + * server's REAL id vocabularies — derived-metric ids, vitals-baseline + * types, insights layout tile-ids, metric-status ids, the HealthKit ingest + * mapping, and the FHIR coding constants — plus the running API contract + * version. The iOS app reads this once on launch (or on a version bump) and + * gates its UI / decoder against what the server actually ships, retiring + * the recurring "doc says N, server ships M" enum-drift class. + * + * Auth: cookie session OR Bearer token (`requireAuth`). NOT admin — every + * authenticated client may discover the contract. + * + * DESIGN RULE: every list is SOURCED from the existing canonical server + * constant / Zod enum / registry — never hand-duplicated here. Adding a + * derived metric, a tile, a writable source, or a HealthKit mapping + * auto-updates this endpoint, so it cannot drift from the real registries. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; + +import packageJson from "../../../../../package.json"; + +import { + DERIVED_METRIC_IDS, + VITALS_BASELINE_TYPES, +} from "@/lib/insights/derived/registry"; +import { INSIGHTS_TILE_IDS } from "@/lib/insights-layout"; +import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry"; +import { WELLNESS_SCORE_TYPES } from "@/lib/insights/derived/wellness-scores"; +import { WRITABLE_MEASUREMENT_SOURCES } from "@/lib/validations/measurement"; +import { + APPLE_HEALTH_TYPE_MAP, + type AppleHealthMapping, +} from "@/lib/measurements/apple-health-mapping"; +import { + ATC_SYSTEM, + SNOMED_SYSTEM, + GERMAN_ATC_DEFAULT_LOCALES, +} from "@/lib/fhir/build-bundle"; + +export const dynamic = "force-dynamic"; + +/** + * An EVENT-class HealthKit identifier carries a device-produced verdict, not + * a continuous reading — it is marked by an `eventClassificationMap` or a + * `fallbackClassification` in the ingest mapping. Everything else is a + * quantity sample. Splitting the single mapping by this predicate keeps the + * two ingest lists derived from one registry. + */ +function isEventMapping(m: AppleHealthMapping): boolean { + return Boolean(m.eventClassificationMap || m.fallbackClassification); +} + +export const GET = apiHandler(async () => { + await requireAuth(); + annotate({ action: { name: "meta.capabilities.read" } }); + + // Mirror the /api/version source so the contract version cannot drift from + // the running build's reported version (build-arg env wins over the + // package.json fallback for local dev). + const apiContractVersion = + process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version; + + const mappings = Object.values(APPLE_HEALTH_TYPE_MAP); + + const quantityTypes = mappings + .filter((m) => !isEventMapping(m)) + .map((m) => ({ + type: m.measurementType, + hk: m.hkIdentifier, + unit: m.dbUnit, + })); + + // Distinct event-class MeasurementTypes (several HK event identifiers can + // share one MeasurementType, e.g. the audio-exposure pair). + const eventTypes = Array.from( + new Set(mappings.filter(isEventMapping).map((m) => m.measurementType)), + ); + + return apiSuccess({ + apiContractVersion, + derivedMetricIds: DERIVED_METRIC_IDS, + vitalsBaselineTypes: VITALS_BASELINE_TYPES, + layoutTileIds: INSIGHTS_TILE_IDS, + metricStatusIds: METRIC_STATUS_IDS, + ingest: { + quantityTypes, + eventTypes, + computedScores: Object.values(WELLNESS_SCORE_TYPES), + writeAllowlist: WRITABLE_MEASUREMENT_SOURCES, + }, + fhir: { + atcSystem: ATC_SYSTEM, + snomedRoute: SNOMED_SYSTEM, + germanAtcDefaultLocales: GERMAN_ATC_DEFAULT_LOCALES, + }, + }); +}); diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index bdafc2d66..bc650c642 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -68,7 +68,7 @@ const IKNR_SYSTEM = "http://fhir.de/sid/arge-ik/iknr"; * coding. Both are additive `coding[]` entries on the same concept; the * free-text `.text` (the user's medication name) stays the anchor. */ -const ATC_SYSTEM = "http://www.whocc.no/atc"; +export const ATC_SYSTEM = "http://www.whocc.no/atc"; /** * German national ATC URI maintained by the BfArM. Same ATC classification * as WHO under a national CodeSystem; emitted as an ADDITIONAL coding (never @@ -78,7 +78,16 @@ const ATC_SYSTEM = "http://www.whocc.no/atc"; const ATC_BFARM_SYSTEM = "http://fhir.de/CodeSystem/bfarm/atc"; const RXNORM_SYSTEM = "http://www.nlm.nih.gov/research/umls/rxnorm"; /** SNOMED CT URI. Concept ids are referenced (not redistributed) in FHIR instances. */ -const SNOMED_SYSTEM = "http://snomed.info/sct"; +export const SNOMED_SYSTEM = "http://snomed.info/sct"; + +/** + * The app locales for which a health-record export defaults `germanAtc` on + * (the additive BfArM ATC coding). The export route derives the flag from + * the user's locale against this set; the capabilities endpoint surfaces it + * verbatim so a client can predict the coding without a round-trip. Keeping + * it here — beside the BfArM URI it gates — makes the two move together. + */ +export const GERMAN_ATC_DEFAULT_LOCALES = ["de"] as const; /** Options threaded from the export route into the builder. Additive, all defaulted. */ export interface FhirBuildOptions { diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 762dc90c8..3ce0fab6a 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -68,6 +68,7 @@ const openApiBase: Pick< { name: "Export" }, { name: "Sync" }, { name: "Admin" }, + { name: "Meta" }, ], }; diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index ed4a8b3de..a20996484 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1854,6 +1854,71 @@ exportSelectionSchema.meta({ "v1.7.0 — health-record / doctor-handover export selection. `format` picks PDF, FHIR R4 document Bundle, or a combined zip package. Grouped `sections` toggles drive which domains are read (mood is opt-in, off by default). No `userId` field — the user is always narrowed from the session/Bearer. The route is strict: unknown keys 422.", }); +// v1.10.2 — live capability / discovery response. Every list is sourced +// server-side from the canonical registry it documents, so the wire shape +// here is the contract; the runtime values are authoritative and never +// hand-duplicated. Used by the native client to gate its UI / decoder +// against what the server actually ships (retires the doc-vs-server +// enum-drift class). +const capabilitiesResponse = z + .object({ + apiContractVersion: z + .string() + .describe("Running build version — mirrors GET /api/version `version`."), + derivedMetricIds: z + .array(z.string()) + .describe("Closed derived-metric id set (GET /api/insights/derived)."), + vitalsBaselineTypes: z + .array(z.string()) + .describe("MeasurementTypes the typical-range baseline engine supports."), + layoutTileIds: z + .array(z.string()) + .describe("Canonical insights layout tile-id set (English ids)."), + metricStatusIds: z + .array(z.string()) + .describe("Closed metric-status / assessment id set."), + ingest: z + .object({ + quantityTypes: z + .array( + z.object({ + type: z.string().describe("HealthLog MeasurementType."), + hk: z.string().describe("HealthKit identifier."), + unit: z.string().describe("Canonical DB unit."), + }), + ) + .describe("Accepted HealthKit quantity-sample mappings."), + eventTypes: z + .array(z.string()) + .describe( + "MeasurementTypes for device-flagged EVENT-class HealthKit samples.", + ), + computedScores: z + .array(z.string()) + .describe("Server-owned nightly composite score types."), + writeAllowlist: z + .array(z.string()) + .describe( + "MeasurementSources a client may attribute on a write (others are server-owned).", + ), + }) + .describe("Ingest vocabularies the batch / single-write paths accept."), + fhir: z + .object({ + atcSystem: z.string().describe("WHO ATC CodeSystem URI."), + snomedRoute: z.string().describe("SNOMED CT CodeSystem URI."), + germanAtcDefaultLocales: z + .array(z.string()) + .describe("App locales that default the additive BfArM ATC coding on."), + }) + .describe("FHIR coding constants the health-record export emits."), + }) + .meta({ + id: "CapabilitiesResponse", + description: + "Live id vocabularies + contract version. Every list is derived server-side from the canonical registry it documents, so it cannot drift from the values the routes actually accept/emit.", + }); + // ── Standard 401 / 422 / 429 responses ─────────────────────────────── const stdResponses = { @@ -1874,6 +1939,28 @@ const stdResponses = { // ── Path table ─────────────────────────────────────────────────────── export const openApiPaths: NonNullable = { + "/api/meta/capabilities": { + get: { + tags: ["Meta"], + summary: "Live server capability / id-vocabulary discovery", + description: + "Returns the server's REAL id vocabularies (derived-metric ids, vitals-baseline types, layout tile-ids, metric-status ids, the HealthKit ingest mapping, the FHIR coding constants) plus the running API contract version. Every list is derived server-side from the canonical registry it documents, so a client can gate its UI / decoder against what the server actually ships rather than a hand-maintained copy. Auth via cookie or Bearer (not admin).", + responses: { + "200": { + description: "Capability snapshot.", + content: { + "application/json": { + schema: dataEnvelope( + capabilitiesResponse, + "CapabilitiesEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/export/health-record": { post: { tags: ["Export"],