Merge branch 'feat/v144-report' into release/v1.44.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-21 22:18:50 +02:00
commit 4e71cf4dc2
58 files changed
+5703 -91

No files matched your search

@@ -38,6 +38,8 @@ vi.mock("@/lib/db", () => ({
healthProfileFactRevision: { findMany: vi.fn() },
// v1.43 — the anamnesis leaf also carries the newest AUDIT-C entry.
mentalHealthAssessment: { findMany: vi.fn(async () => []) },
// v1.44 — the per-sport workout block.
workout: { findMany: vi.fn(async () => []) },
user: { findUnique: vi.fn(), update: vi.fn() },
// The whole-record gate reads the session's own stamps: a sign-in a
// moment ago is the proof, on an account without a second factor.
@@ -2,6 +2,9 @@
* GET /api/fhir/Observation/{id} — FHIR R4 `read` of one Observation from the
* caller's own record.
*
* A series id (`m-…` / `w-…`, v1.44) reads that one row inside the same scope
* the series search applies; every other id is a snapshot id.
*
* The id space is the export's own (`obs-3`), stable for a given record and
* reporting window. A `read` is a filter over the same emitter the search
* route pages, so a resource can never differ between the two faces; an id the
@@ -16,10 +19,13 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { observationsFromReportData } from "@/lib/fhir/resources";
import {
FHIR_READ_SCOPE,
fhirJsonResponse,
loadFhirContext,
loadFhirSeriesScope,
operationOutcome,
readResponse,
} from "@/lib/fhir/rest";
import { readSeriesObservation } from "@/lib/fhir/series";
export const GET = apiHandler(
async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
@@ -41,6 +47,19 @@ export const GET = apiHandler(
return operationOutcome(429, "throttled", "Rate limit exceeded");
}
const { id } = await ctx.params;
if (id.startsWith("m-") || id.startsWith("w-")) {
const { scope, glucoseUnit } = await loadFhirSeriesScope(user.id, null);
annotate({ meta: { resourceId: id, mode: "series" } });
const observation = await readSeriesObservation({
userId: user.id,
id,
scope,
glucoseUnit,
});
return observation
? fhirJsonResponse(observation)
: operationOutcome(404, "not-found", "No such resource");
}
const context = await loadFhirContext(user.id);
annotate({ meta: { resourceId: id } });
return readResponse(observationsFromReportData(context.data), id);
+60 -11
View File
@@ -1,12 +1,19 @@
/**
* 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 the wellness composites.
* GET /api/fhir/Observation — FHIR R4 `searchset` of the caller's own Observations.
*
* Read-only. `userId` is narrowed from `requireAuth`; the shared emitters in
* `@/lib/fhir/resources` are the single source of the coding, so this face and
* the document export can never describe the same record differently. Offset
* paging via `_count` (clamped ≤200) / `_offset`.
* Two answers, chosen by the request:
*
* - no `date` parameter: the snapshot it has always been — one latest
* reading per type plus the BP panel, BMI, glucose, adherence, mood and the
* wellness composites (`obs-N` ids), offset-paged in memory.
* - with `date` (v1.44): the time series — every reading in the range, plus
* workouts, newest first, paged in the database (`m-…` / `w-…` ids). See
* `@/lib/fhir/series`.
*
* `code` narrows either answer. Read-only. `userId` is narrowed from
* `requireAuth`; both answers read the owner's saved report selection, so the
* series cannot reach a type the snapshot withholds. Paging via `_count`
* (clamped ≤200) / `_offset`.
*/
import { NextRequest } from "next/server";
@@ -18,10 +25,18 @@ import { observationsFromReportData } from "@/lib/fhir/resources";
import {
FHIR_READ_SCOPE,
loadFhirContext,
loadFhirSeriesScope,
operationOutcome,
parsePaging,
searchsetResponse,
} from "@/lib/fhir/rest";
import {
emitSeriesPage,
matchesCodeTokens,
parseCodeParams,
parseDateParams,
querySeriesPage,
} from "@/lib/fhir/series";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
@@ -41,11 +56,45 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
}
const { count, offset } = parsePaging(request.nextUrl.searchParams);
const context = await loadFhirContext(user.id);
const params = request.nextUrl.searchParams;
const { count, offset } = parsePaging(params);
const codeValues = params.getAll("code");
const range = parseDateParams(params.getAll("date"));
if (range && "error" in range) {
return operationOutcome(400, "invalid", range.error);
}
const all = observationsFromReportData(context.data);
if (range) {
const { scope, glucoseUnit } = await loadFhirSeriesScope(
user.id,
parseCodeParams(codeValues),
);
const { total, refs } = await querySeriesPage({
userId: user.id,
scope,
range,
count,
offset,
});
const page = await emitSeriesPage({
userId: user.id,
refs,
scope,
glucoseUnit,
});
annotate({
meta: { mode: "series", total, count, offset, types: scope.types.length },
});
return searchsetResponse(request.nextUrl, page, total, count, offset);
}
const context = await loadFhirContext(user.id);
const snapshot = observationsFromReportData(context.data);
const all =
codeValues.length > 0
? snapshot.filter((o) => matchesCodeTokens(o, codeValues))
: snapshot;
const page = all.slice(offset, offset + count);
annotate({ meta: { total: all.length, count, offset } });
annotate({ meta: { mode: "snapshot", total: all.length, count, offset } });
return searchsetResponse(request.nextUrl, page, all.length, count, offset);
});
@@ -0,0 +1,228 @@
/**
* v1.44 — the Observation search's two answers. Without `date` the route
* serves the snapshot exactly as before (the whole-record loader runs, the
* series loader does not); with `date` it serves the paged time series and
* never assembles the whole record. A malformed `date` is a 400, `code`
* narrows the snapshot, and series ids read back through `/{id}`.
*/
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/modules/gate", () => ({
requireModuleEnabled: vi.fn(),
isModuleEnabled: vi.fn(),
}));
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/fhir/rest")>();
return {
...actual,
loadFhirContext: vi.fn(),
loadFhirSeriesScope: vi.fn(),
};
});
vi.mock("@/lib/fhir/resources", () => ({
observationsFromReportData: vi.fn(),
}));
vi.mock("@/lib/fhir/series", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/fhir/series")>();
return {
...actual,
querySeriesPage: vi.fn(),
emitSeriesPage: vi.fn(),
readSeriesObservation: 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 { GET as observationRead } from "../Observation/[id]/route";
import {
emitSeriesPage,
querySeriesPage,
readSeriesObservation,
} from "@/lib/fhir/series";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { loadFhirContext, loadFhirSeriesScope } from "@/lib/fhir/rest";
import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate";
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: {
coding: [
{ system: "http://loinc.org", code: id === 1 ? "29463-7" : "x" },
],
},
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);
// The doctorReport module is ON for the contract assertions below; the
// OFF behaviour is pinned in `module-gate.test.ts`.
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(isModuleEnabled).mockResolvedValue(true);
vi.mocked(loadFhirContext).mockResolvedValue({
data: {} as never,
identity: { insuranceNumber: null },
germanAtc: false,
records: {},
});
vi.mocked(loadFhirSeriesScope).mockResolvedValue({
scope: {
types: ["WEIGHT"],
pairBloodPressure: false,
glucoseContexts: null,
workouts: true,
},
glucoseUnit: "mg/dL",
});
});
describe("GET /api/fhir/Observation — snapshot stays the default", () => {
it("serves the snapshot without a date and never touches the series", async () => {
vi.mocked(observationsFromReportData).mockReturnValue([
obs(1),
obs(2),
] as never);
const res = await observationGet(req());
expect(res.status).toBe(200);
const bundle = (await res.json()) as { total: number };
expect(bundle.total).toBe(2);
expect(loadFhirContext).toHaveBeenCalledTimes(1);
expect(loadFhirSeriesScope).not.toHaveBeenCalled();
expect(querySeriesPage).not.toHaveBeenCalled();
});
it("narrows the snapshot by code", async () => {
vi.mocked(observationsFromReportData).mockReturnValue([
obs(1),
obs(2),
] as never);
const res = await observationGet(req("?code=http://loinc.org|29463-7"));
const bundle = (await res.json()) as {
total: number;
entry: Array<{ resource: { id: string } }>;
};
expect(bundle.total).toBe(1);
expect(bundle.entry[0].resource.id).toBe("obs-1");
});
});
describe("GET /api/fhir/Observation?date= — time series", () => {
it("pages in the database and never assembles the whole record", async () => {
vi.mocked(querySeriesPage).mockResolvedValue({
total: 340,
refs: [{ kind: "m", id: "a" }],
} as never);
vi.mocked(emitSeriesPage).mockResolvedValue([
{ ...obs(9), id: "m-a" },
] as never);
const res = await observationGet(
req("?date=ge2026-01-01&date=lt2026-02-01&_count=50&_offset=100"),
);
expect(res.status).toBe(200);
const bundle = (await res.json()) as {
total: number;
link: Array<{ relation: string; url: string }>;
entry: Array<{ fullUrl: string }>;
};
expect(loadFhirContext).not.toHaveBeenCalled();
expect(querySeriesPage).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
count: 50,
offset: 100,
range: {
from: new Date("2026-01-01T00:00:00.000Z"),
to: new Date("2026-02-01T00:00:00.000Z"),
},
}),
);
expect(bundle.total).toBe(340);
expect(bundle.entry[0].fullUrl).toContain("/api/fhir/Observation/m-a");
const next = bundle.link.find((l) => l.relation === "next");
expect(next?.url).toContain("_offset=150");
expect(next?.url).toContain("date=ge2026-01-01");
});
it("hands the code filter to the scope", async () => {
vi.mocked(querySeriesPage).mockResolvedValue({ total: 0, refs: [] });
vi.mocked(emitSeriesPage).mockResolvedValue([]);
await observationGet(req("?date=2026&code=80404-7"));
const codes = vi.mocked(loadFhirSeriesScope).mock.calls[0][1];
expect([...(codes?.types ?? [])]).toEqual(["HEART_RATE_VARIABILITY"]);
});
it("answers a malformed date with a 400 OperationOutcome", async () => {
const res = await observationGet(req("?date=last-week"));
expect(res.status).toBe(400);
const body = (await res.json()) as {
resourceType: string;
issue: Array<{ code: string }>;
};
expect(body.resourceType).toBe("OperationOutcome");
expect(body.issue[0].code).toBe("invalid");
expect(querySeriesPage).not.toHaveBeenCalled();
});
});
describe("GET /api/fhir/Observation/{id} — series ids", () => {
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
it("reads a series id through the series scope", async () => {
vi.mocked(readSeriesObservation).mockResolvedValue({
...obs(1),
id: "w-x",
} as never);
const res = await observationRead(
new NextRequest("http://localhost/api/fhir/Observation/w-x"),
ctx("w-x"),
);
expect(res.status).toBe(200);
expect(((await res.json()) as { id: string }).id).toBe("w-x");
expect(loadFhirContext).not.toHaveBeenCalled();
});
it("answers an unknown series id with not-found", async () => {
vi.mocked(readSeriesObservation).mockResolvedValue(null);
const res = await observationRead(
new NextRequest("http://localhost/api/fhir/Observation/m-nope"),
ctx("m-nope"),
);
expect(res.status).toBe(404);
});
});
+16 -5
View File
@@ -3,7 +3,8 @@
*
* Declares the read-only REST face: the resource types served, the `read` +
* `search-type` interactions on each, the search parameters honoured
* (`_count`, `_offset`), the `Patient-everything` operation, and the
* (`_count`, `_offset`; `date` and `code` on Observation), the
* `Patient-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.
@@ -22,6 +23,7 @@ import { annotate } from "@/lib/logging/context";
import {
FHIR_READ_SCOPE,
FHIR_REST_RESOURCE_TYPES,
FHIR_OBSERVATION_SEARCH_PARAMS,
FHIR_SEARCH_PARAMS,
fhirJsonResponse,
} from "@/lib/fhir/rest";
@@ -65,10 +67,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
resource: FHIR_REST_RESOURCE_TYPES.map((type) => ({
type,
interaction: [{ code: "read" }, { code: "search-type" }],
searchParam: FHIR_SEARCH_PARAMS.map((name) => ({
name,
type: "number",
})),
searchParam: [
...FHIR_SEARCH_PARAMS.map((name) => ({
name,
type: "number",
})),
// v1.44: the Observation search's time-series range and code filter.
...(type === "Observation"
? FHIR_OBSERVATION_SEARCH_PARAMS.map((p) => ({
name: p.name,
type: p.type,
}))
: []),
],
...(type === "Patient"
? {
operation: [