mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(api): add GET /api/meta/capabilities discovery endpoint
Expose the server's live id vocabularies (derived-metric ids, vitals-baseline types, insights layout tile-ids, metric-status ids, the HealthKit ingest mapping, computed-score types, write allowlist) and the FHIR coding constants, plus the running contract version, behind requireAuth (cookie or Bearer, not admin). Every list is sourced from its canonical server constant / registry, so the endpoint cannot drift from what the routes actually accept and emit — retiring the doc-vs-server enum-drift class for the native client. Export the FHIR ATC/SNOMED URIs and a shared german-ATC default-locale set so the export route and the discovery surface share one source. Register the route in the OpenAPI table; drift-guard tests pin derivedMetricIds to the registry and writeAllowlist to the constant.
This commit is contained in:
1 parent
5ae7aa7570
commit
192fe45469
6 files changed
+379
-4
No files matched your search
@@ -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 }),
|
||||
|
||||
@@ -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<Response> {
|
||||
return (GET as unknown as () => Promise<Response>)();
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -68,6 +68,7 @@ const openApiBase: Pick<
|
||||
{ name: "Export" },
|
||||
{ name: "Sync" },
|
||||
{ name: "Admin" },
|
||||
{ name: "Meta" },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
"/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"],
|
||||
|
||||
Reference in new issue
Block a user