feat(fhir): serve Observations as a time series, code RMSSD and workouts

GET /api/fhir/Observation keeps its snapshot answer when no date is
sent. A date range (FHIR prefixes eq/gt/ge/lt/le, UTC when no offset)
switches it to the time series: every reading in the range plus
workouts, newest first, paged in the database through one UNION id
query and a count, so a page costs what it holds. Series ids are the
row's own (m-<id>, w-<id>) and read back through /Observation/{id}.
The series replays the same saved report selection as the snapshot,
pairs systolic and diastolic recorded at one instant into the
blood-pressure panel, leaves sleep stage segments out, and reads an
hourly heart-rate bucket as a mean over its hour. A code parameter
narrows either answer; a malformed date is a 400 OperationOutcome.

A workout is an activity Observation after the Physical Activity IG:
LOINC 73985-4 with the sport as its value and duration (55411-3) and
energy (41981-2) as components. RMSSD gets its own registry coding,
text-only because LOINC has no RMSSD term, so it never borrows the SDNN
code. The capability statement lists the two new parameters.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 21:56:16 +02:00
1 parent 9e2a0681b2
commit 77619f8aa5
12 files changed
+1935 -26

No files matched your search

+25 -6
View File
@@ -1245,8 +1245,9 @@ paths:
summary: FHIR R4 CapabilityStatement (v1.11.0)
description: "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient,
Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type`
interactions, the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised
interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass)."
interactions, the search parameters (`_count`, `_offset`; `date` and `code` on Observation), the
`Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a
route. Auth: `fhir:read` scope (cookie sessions also pass)."
responses:
"200":
description: CapabilityStatement (application/fhir+json).
@@ -1291,7 +1292,16 @@ paths:
- 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`."
`fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. Without `date` the answer is the
snapshot it has always been: one latest reading per type plus the derived panels, ids `obs-N`. With `date`
(v1.44, repeatable, FHIR prefixes eq/gt/ge/lt/le, a value without an offset read as UTC) the answer is the time
series instead: every reading in the range plus workouts, newest first, paged in the database, ids `m-<id>` /
`w-<id>` that read back through `/api/fhir/Observation/{id}`. The series follows the same saved report selection
as the snapshot, leaves sleep out (stored as stage segments), pairs a systolic and diastolic recorded at the
same instant into the blood-pressure panel, and codes a workout as an `activity` Observation (LOINC 73985-4
Exercise activity, duration 55411-3 and energy 41981-2 as components). `code` (repeatable, `system|code` or a
bare code, comma-separated) narrows either answer; an unknown code matches nothing. A malformed `date` is a 400
OperationOutcome."
parameters:
- in: query
name: _count
@@ -1301,6 +1311,14 @@ paths:
name: _offset
schema:
type: number
- in: query
name: date
schema:
type: string
- in: query
name: code
schema:
type: string
responses:
"200":
description: searchset Bundle (application/fhir+json).
@@ -1463,9 +1481,10 @@ paths:
tags:
- FHIR
summary: FHIR R4 Observation read
description: "Read-only `Observation` by id, out of the caller's own record. An id belonging to someone else is
indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read`
scope; `userId` is narrowed from auth."
description: "Read-only `Observation` by id, out of the caller's own record: a snapshot id (`obs-N`) or a series id
(`m-<id>` / `w-<id>`, v1.44), the latter read inside the same saved selection the series search applies. An id
belonging to someone else is indistinguishable from one that never existed — both answer a `not-found`
OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth."
parameters:
- in: path
name: id
@@ -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: [
+375
View File
@@ -0,0 +1,375 @@
/**
* The Observation time series: the `date` and `code` grammar, the scope the
* saved selection allows, and the R4 shape of each emitted resource.
*/
import { describe, expect, it } from "vitest";
import {
CALORIES_BURNED_LOINC,
EXERCISE_ACTIVITY_LOINC,
EXERCISE_DURATION_LOINC,
matchesCodeTokens,
measurementSeriesObservation,
parseCodeParams,
parseDateParams,
resolveSeriesScope,
seriesCandidateTypes,
workoutSeriesObservation,
} from "@/lib/fhir/series";
import { MEASUREMENT_LOINC } from "@/lib/fhir/loinc-map";
import { isUcumCode } from "@/lib/fhir/ucum";
import type { FhirObservation } from "@/lib/fhir/types";
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
const iso = (d: Date | null) => d?.toISOString() ?? null;
describe("parseDateParams", () => {
it("is absent without a date parameter, so the snapshot stays the default", () => {
expect(parseDateParams([])).toBeNull();
});
it("reads prefixes and partial dates as half-open UTC ranges", () => {
const r = parseDateParams(["ge2026-01-01", "lt2026-02-01"]);
expect(r).not.toHaveProperty("error");
const range = r as { from: Date | null; to: Date | null };
expect(iso(range.from)).toBe("2026-01-01T00:00:00.000Z");
expect(iso(range.to)).toBe("2026-02-01T00:00:00.000Z");
});
it("covers a whole month for eq, and the day after for gt and le", () => {
const month = parseDateParams(["2026-02"]) as { from: Date; to: Date };
expect(iso(month.from)).toBe("2026-02-01T00:00:00.000Z");
expect(iso(month.to)).toBe("2026-03-01T00:00:00.000Z");
const gt = parseDateParams(["gt2026-02-10"]) as { from: Date; to: null };
expect(iso(gt.from)).toBe("2026-02-11T00:00:00.000Z");
expect(gt.to).toBeNull();
const le = parseDateParams(["le2026"]) as { from: null; to: Date };
expect(iso(le.to)).toBe("2027-01-01T00:00:00.000Z");
});
it("honours an offset on a dateTime value", () => {
const r = parseDateParams(["ge2026-03-01T10:00:00+02:00"]) as {
from: Date;
};
expect(iso(r.from)).toBe("2026-03-01T08:00:00.000Z");
});
it("rejects what it cannot read rather than ignoring it", () => {
expect(parseDateParams(["sa2026-01-01"])).toHaveProperty("error");
expect(parseDateParams(["2026-02-30"])).toHaveProperty("error");
expect(parseDateParams(["yesterday"])).toHaveProperty("error");
});
});
describe("parseCodeParams", () => {
it("maps LOINC codes to their types, with or without a system", () => {
const f = parseCodeParams(["http://loinc.org|80404-7", "29463-7"])!;
expect([...f.types].sort()).toEqual(["HEART_RATE_VARIABILITY", "WEIGHT"]);
expect(f.workouts).toBe(false);
});
it("maps any blood-pressure code to the paired panel stream", () => {
const f = parseCodeParams(["8480-6"])!;
expect([...f.types].sort()).toEqual([
"BLOOD_PRESSURE_DIA",
"BLOOD_PRESSURE_SYS",
]);
});
it("narrows glucose to the contexts its code names", () => {
const fasting = parseCodeParams(["1558-6"])!;
expect([...fasting.types]).toEqual(["BLOOD_GLUCOSE"]);
expect([...fasting.glucoseContexts!]).toEqual(["FASTING"]);
const blood = parseCodeParams(["2339-0"])!;
expect([...blood.glucoseContexts!].sort()).toEqual([
"BEDTIME",
"NONE",
"RANDOM",
]);
});
it("selects workouts by the exercise-activity code", () => {
expect(parseCodeParams([EXERCISE_ACTIVITY_LOINC])!.workouts).toBe(true);
});
it("does not match a LOINC code under a foreign system", () => {
expect(
parseCodeParams(["http://snomed.info/sct|80404-7"])!.types.size,
).toBe(0);
});
});
describe("resolveSeriesScope", () => {
const gateOf = (leaves: ReportLeafId[]) => ({
admits: (leaf: ReportLeafId) => leaves.includes(leaf),
});
it("carries only what the saved selection admits, never sleep", () => {
const s = resolveSeriesScope(
gateOf(["WEIGHT", "SLEEP_DURATION", "HRV_RMSSD"]),
null,
);
expect(s.types.sort()).toEqual(["HRV_RMSSD", "WEIGHT"]);
expect(s.workouts).toBe(false);
expect(seriesCandidateTypes()).not.toContain("SLEEP_DURATION");
});
it("pairs blood pressure only when both components are admitted", () => {
expect(
resolveSeriesScope(
gateOf(["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]),
null,
).pairBloodPressure,
).toBe(true);
expect(
resolveSeriesScope(gateOf(["BLOOD_PRESSURE_SYS"]), null)
.pairBloodPressure,
).toBe(false);
});
it("intersects the code filter with the gate", () => {
const s = resolveSeriesScope(
gateOf(["WEIGHT", "WORKOUTS"]),
parseCodeParams(["80404-7", EXERCISE_ACTIVITY_LOINC]),
);
expect(s.types).toEqual([]);
expect(s.workouts).toBe(true);
});
});
/** The structural R4 checks the conformance suite applies to every Observation. */
function assertR4Observation(o: FhirObservation) {
expect(o.resourceType).toBe("Observation");
expect(o.status).toBe("final");
expect(o.subject.reference).toBe("Patient/patient-1");
// effective[x]: exactly one of dateTime / period.
expect(Boolean(o.effectiveDateTime) !== Boolean(o.effectivePeriod)).toBe(
true,
);
// value[x]: at most one.
const values = [
o.valueQuantity,
o.valueCodeableConcept,
o.valueString,
o.valueInteger,
].filter((v) => v !== undefined);
expect(values.length).toBeLessThanOrEqual(1);
// A coded UCUM unit is a parseable one, and system + code travel together.
const quantities = [
o.valueQuantity,
...(o.component ?? []).map((c) => c.valueQuantity),
].filter(Boolean);
for (const q of quantities) {
if (q!.code !== undefined) expect(isUcumCode(q!.code)).toBe(true);
expect(q!.code === undefined).toBe(q!.system === undefined);
}
// vital-signs category promises a LOINC code.
if (o.category?.some((c) => c.coding?.[0]?.code === "vital-signs")) {
const loinc = o.code.coding?.some((c) => c.system === "http://loinc.org");
const textOnly = !o.code.coding && Boolean(o.code.text);
expect(loinc || textOnly).toBe(true);
}
expect(JSON.parse(JSON.stringify(o))).toEqual(o);
}
const AT = new Date("2026-03-04T07:30:00.000Z");
describe("measurementSeriesObservation", () => {
it("emits one reading per row with its own id and instant", () => {
const o = measurementSeriesObservation(
{
id: "abc",
type: "WEIGHT",
value: 79.45,
valueMin: null,
valueMax: null,
measuredAt: AT,
glucoseContext: null,
},
"mg/dL",
null,
)!;
assertR4Observation(o);
expect(o.id).toBe("m-abc");
expect(o.effectiveDateTime).toBe(AT.toISOString());
expect(o.valueQuantity?.value).toBe(79.45);
expect(o.code.coding?.[0]?.code).toBe(MEASUREMENT_LOINC.WEIGHT.loinc);
});
it("labels RMSSD as its own text-only statistic, never under SDNN's code", () => {
const o = measurementSeriesObservation(
{
id: "r1",
type: "HRV_RMSSD",
value: 38,
valueMin: null,
valueMax: null,
measuredAt: AT,
glucoseContext: null,
},
"mg/dL",
null,
)!;
assertR4Observation(o);
expect(o.code.text).toBe("Heart rate variability RMSSD");
expect(o.code.coding).toBeUndefined();
expect(o.valueQuantity).toMatchObject({ value: 38, unit: "ms" });
});
it("reads an hourly heart-rate bucket as a mean over its hour", () => {
const o = measurementSeriesObservation(
{
id: "h1",
type: "PULSE",
value: 72,
valueMin: 60,
valueMax: 95,
measuredAt: AT,
glucoseContext: null,
},
"mg/dL",
null,
)!;
assertR4Observation(o);
expect(o.effectivePeriod).toEqual({
start: AT.toISOString(),
end: "2026-03-04T08:30:00.000Z",
});
expect(o.note?.[0]?.text).toContain("60 to 95");
});
it("pairs a systolic reading with its diastolic partner as the BP panel", () => {
const o = measurementSeriesObservation(
{
id: "s1",
type: "BLOOD_PRESSURE_SYS",
value: 131,
valueMin: null,
valueMax: null,
measuredAt: AT,
glucoseContext: null,
},
"mg/dL",
84,
)!;
assertR4Observation(o);
expect(o.code.coding?.[0]?.code).toBe("85354-9");
expect(o.component?.map((c) => c.valueQuantity?.value)).toEqual([131, 84]);
});
it("emits a lone component under its own code and outside the vital-signs profile", () => {
const o = measurementSeriesObservation(
{
id: "d1",
type: "BLOOD_PRESSURE_DIA",
value: 84,
valueMin: null,
valueMax: null,
measuredAt: AT,
glucoseContext: null,
},
"mg/dL",
null,
)!;
assertR4Observation(o);
expect(o.code.coding?.[0]?.code).toBe("8462-4");
expect(o.category).toBeUndefined();
});
it("codes glucose by context and values it in the owner's unit", () => {
const o = measurementSeriesObservation(
{
id: "g1",
type: "BLOOD_GLUCOSE",
value: 99,
valueMin: null,
valueMax: null,
measuredAt: AT,
glucoseContext: "FASTING",
},
"mmol/L",
null,
)!;
assertR4Observation(o);
expect(o.code.coding?.[0]?.code).toBe("1558-6");
expect(o.valueQuantity?.unit).toBe("mmol/L");
expect(o.valueQuantity?.value).toBeCloseTo(5.5, 1);
});
it("emits nothing for sleep segments", () => {
expect(
measurementSeriesObservation(
{
id: "z",
type: "SLEEP_DURATION",
value: 90,
valueMin: null,
valueMax: null,
measuredAt: AT,
glucoseContext: null,
},
"mg/dL",
null,
),
).toBeNull();
});
});
describe("workoutSeriesObservation", () => {
it("emits an activity Observation per the Physical Activity IG pattern", () => {
const o = workoutSeriesObservation({
id: "w1",
sportType: "running",
startedAt: AT,
endedAt: new Date("2026-03-04T08:15:30.000Z"),
durationSec: 2730,
totalDistanceM: 8012.5,
totalEnergyKcal: 610,
avgHeartRate: 151,
});
assertR4Observation(o);
expect(o.id).toBe("w-w1");
expect(o.category?.[0]?.coding?.[0]?.code).toBe("activity");
expect(o.code.coding?.[0]?.code).toBe(EXERCISE_ACTIVITY_LOINC);
expect(o.valueCodeableConcept?.coding?.[0]?.code).toBe("running");
expect(o.effectivePeriod?.end).toBe("2026-03-04T08:15:30.000Z");
const byCode = Object.fromEntries(
(o.component ?? []).map((c) => [
c.code.coding?.[0]?.code ?? c.code.text,
c.valueQuantity,
]),
);
expect(byCode[EXERCISE_DURATION_LOINC]).toMatchObject({
value: 45.5,
code: "min",
});
expect(byCode[CALORIES_BURNED_LOINC]).toMatchObject({ value: 610 });
expect(byCode.Distance).toMatchObject({ value: 8012.5, unit: "m" });
});
it("leaves out the components a source did not report", () => {
const o = workoutSeriesObservation({
id: "w2",
sportType: "yoga",
startedAt: AT,
endedAt: AT,
durationSec: 600,
totalDistanceM: null,
totalEnergyKcal: null,
avgHeartRate: null,
});
expect(o.component).toHaveLength(1);
});
});
describe("matchesCodeTokens", () => {
it("matches a bare code on any system and a system|code pair exactly", () => {
const o = {
code: { coding: [{ system: "http://loinc.org", code: "29463-7" }] },
} as FhirObservation;
expect(matchesCodeTokens(o, ["29463-7"])).toBe(true);
expect(matchesCodeTokens(o, ["http://loinc.org|29463-7"])).toBe(true);
expect(matchesCodeTokens(o, ["urn:x|29463-7"])).toBe(false);
expect(matchesCodeTokens(o, ["8480-6,29463-7"])).toBe(true);
});
});
+46 -1
View File
@@ -24,7 +24,15 @@ import {
} from "@/lib/doctor-report-data";
import type { FhirRecordInputs } from "@/lib/fhir/build-bundle";
import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/resources";
import { isModuleEnabled } from "@/lib/modules/gate";
import { isModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { resolveReportGate } from "@/lib/doctor-report/selection-gate";
import {
resolveSeriesScope,
seriesGlucoseUnit,
type SeriesCodeFilter,
type SeriesScope,
} from "@/lib/fhir/series";
import type { GlucoseUnit } from "@/lib/glucose";
import { toAllergyDTO, toFamilyHistoryEntryDTO } from "@/lib/records/dto";
import type {
FhirBundleEntry,
@@ -65,6 +73,16 @@ export const FHIR_EVERYTHING_OPERATION = "$everything";
/** Search parameters honoured uniformly across the search routes. */
export const FHIR_SEARCH_PARAMS = ["_count", "_offset"] as const;
/**
* Further search parameters the Observation search honours (v1.44). `date`
* switches the answer from the snapshot to the time series of every reading
* in the range; `code` narrows either answer to the named codings.
*/
export const FHIR_OBSERVATION_SEARCH_PARAMS = [
{ name: "date", type: "date" },
{ name: "code", type: "token" },
] as const;
/** Canonical FHIR media type for every response (success or outcome). */
export const FHIR_CONTENT_TYPE = "application/fhir+json; charset=utf-8";
@@ -308,3 +326,30 @@ export async function loadFhirContext(userId: string): Promise<{
},
};
}
/**
* The series face's scope: the same saved selection and module map the
* snapshot replays, narrowed by an optional `code` filter, plus the owner's
* glucose unit. Reads two small rows and the selection — never the
* whole-record aggregate, so a series page costs what its page costs.
*
* Carries the same module backstop as {@link loadFhirContext}.
*/
export async function loadFhirSeriesScope(
userId: string,
codes: SeriesCodeFilter | null,
): Promise<{ scope: SeriesScope; glucoseUnit: GlucoseUnit }> {
if (!(await isModuleEnabled(userId, "doctorReport"))) {
throw new Error(
'loadFhirSeriesScope called with the "doctorReport" module disabled — ' +
"the calling route is missing its requireModuleEnabled gate",
);
}
const [selection, moduleMap, glucoseUnit] = await Promise.all([
resolveSavedSelection(userId),
resolveModuleMap(userId),
seriesGlucoseUnit(userId),
]);
const gate = resolveReportGate(selection, moduleMap);
return { scope: resolveSeriesScope(gate, codes), glucoseUnit };
}
+765
View File
@@ -0,0 +1,765 @@
/**
* The time-series face of `GET /api/fhir/Observation`: every recorded reading
* as its own Observation, newest first, paged in the database.
*
* The search route's default answer stays what it always was, a snapshot of
* the record (one latest reading per type plus the derived panels, ids
* `obs-N`). A client opts into the series by naming a `date` range, which is
* what a FHIR client does when it wants readings over time; the snapshot has
* no date range to filter, so the parameter never meant anything there
* before. Additive by construction: no existing request changes its answer.
*
* date=ge2026-01-01&date=lt2026-02-01 readings in January (UTC)
* code=http://loinc.org|80404-7 narrowed to SDNN (also in snapshot mode)
* _count / _offset paging, as on every search route
*
* Series ids are the row's own (`m-<id>` for a measurement, `w-<id>` for a
* workout) so a reading keeps its id across pages and windows, and
* `GET /Observation/{id}` reads it back.
*
* Scope is the snapshot's: the owner's saved report selection and module map
* decide which types may appear (`resolveReportGate`), so the series can
* never reach a type the snapshot would withhold. Memory is bounded by the
* page: one id query (a UNION over measurements and workouts, ordered and
* paged in SQL), one count, and a by-id read of at most `_count` rows.
*
* Every row as recorded. Two sources writing the same weighing are two
* readings here; the snapshot is the de-duplicated view. Sleep is not in the
* series: it is stored as per-stage segments, and a segment is not a night.
*/
import { Prisma } from "@/generated/prisma/client";
import type {
GlucoseContext,
MeasurementType,
} from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
convertGlucose,
resolveGlucoseUnit,
type GlucoseUnit,
} from "@/lib/glucose";
import {
BP_DIA_LOINC,
BP_PANEL_LOINC,
BP_SYS_LOINC,
BP_UNIT,
GLUCOSE_LOINC,
HEALTHKIT_CODESYSTEM,
LOINC_SYSTEM,
MEASUREMENT_LOINC,
} from "@/lib/fhir/loinc-map";
import { ucumQuantity } from "@/lib/fhir/ucum";
import type {
FhirObservation,
FhirObservationComponent,
} from "@/lib/fhir/types";
import {
categoryConcept,
codeableFromMapping,
patientRef,
} from "@/lib/fhir/resources/common";
import type { ReportGate } from "@/lib/doctor-report/selection-gate";
// ── date parameter ──────────────────────────────────────────────────────
/** A half-open instant range; either end may be open. */
export interface SeriesRange {
from: Date | null;
to: Date | null;
}
const DATE_RE =
/^(eq|gt|ge|lt|le)?(\d{4})(?:-(\d{2})(?:-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?)?)?$/;
/**
* The instant span one FHIR date value covers: a year, a month, a day, a
* minute or a second. A value without an offset is read as UTC.
*/
function dateSpan(match: RegExpMatchArray): { start: Date; end: Date } | null {
const [, , y, mo, d, h, mi, s, tz] = match;
const year = Number(y);
if (mo === undefined) {
return {
start: new Date(Date.UTC(year, 0, 1)),
end: new Date(Date.UTC(year + 1, 0, 1)),
};
}
const month = Number(mo) - 1;
if (month < 0 || month > 11) return null;
if (d === undefined) {
return {
start: new Date(Date.UTC(year, month, 1)),
end: new Date(Date.UTC(year, month + 1, 1)),
};
}
const day = Number(d);
const dayStart = new Date(Date.UTC(year, month, day));
if (dayStart.getUTCMonth() !== month || dayStart.getUTCDate() !== day) {
return null;
}
if (h === undefined) {
return { start: dayStart, end: new Date(dayStart.getTime() + 86_400_000) };
}
const iso = `${y}-${mo}-${d}T${h}:${mi}:${s ?? "00"}${tz ?? "Z"}`;
const start = new Date(iso);
if (Number.isNaN(start.getTime())) return null;
return { start, end: new Date(start.getTime() + (s ? 1000 : 60_000)) };
}
/**
* Parse every `date` value into one range (their intersection). Returns null
* when no `date` parameter was sent, and an error string for a value the
* server cannot read — a 400, never a silently ignored filter.
*/
export function parseDateParams(
values: readonly string[],
): SeriesRange | { error: string } | null {
if (values.length === 0) return null;
let from: Date | null = null;
let to: Date | null = null;
const later = (a: Date | null, b: Date) => (a && a > b ? a : b);
const earlier = (a: Date | null, b: Date) => (a && a < b ? a : b);
for (const raw of values) {
for (const value of raw.split(",")) {
const match = value.trim().match(DATE_RE);
const span = match ? dateSpan(match) : null;
if (!match || !span) {
return { error: `Unsupported date value: ${value.slice(0, 40)}` };
}
switch (match[1] ?? "eq") {
case "eq":
from = later(from, span.start);
to = earlier(to, span.end);
break;
case "ge":
from = later(from, span.start);
break;
case "gt":
from = later(from, span.end);
break;
case "le":
to = earlier(to, span.end);
break;
case "lt":
to = earlier(to, span.start);
break;
}
}
}
return { from, to };
}
// ── code parameter ──────────────────────────────────────────────────────
/** Exercise activity — the LOINC a workout Observation is coded with. */
export const EXERCISE_ACTIVITY_LOINC = "73985-4";
/** Exercise duration (min). */
export const EXERCISE_DURATION_LOINC = "55411-3";
/** Calories burned (kcal). */
export const CALORIES_BURNED_LOINC = "41981-2";
/** Local CodeSystem for the canonical workout sport keys. */
export const WORKOUT_SPORT_CODESYSTEM =
"https://healthlog.dev/fhir/CodeSystem/workout-sport";
/** What a set of codes selects from the series. */
export interface SeriesCodeFilter {
types: Set<MeasurementType>;
/** Glucose contexts a glucose code narrows to; null keeps every context. */
glucoseContexts: Set<GlucoseContext | "NONE"> | null;
workouts: boolean;
}
const GLUCOSE_CONTEXT_OF: Record<string, GlucoseContext | "NONE"> = {
FASTING: "FASTING",
POSTPRANDIAL: "POSTPRANDIAL",
RANDOM: "RANDOM",
BEDTIME: "BEDTIME",
UNSPECIFIED: "NONE",
};
/**
* Resolve `code` tokens (`system|code` or a bare code, comma-separated, any
* number of parameters) to the types they select. Returns null when no
* `code` was sent. An unknown code selects nothing — an empty Bundle, which
* is FHIR's answer to a search that matches nothing.
*/
export function parseCodeParams(
values: readonly string[],
): SeriesCodeFilter | null {
if (values.length === 0) return null;
const filter: SeriesCodeFilter = {
types: new Set(),
glucoseContexts: null,
workouts: false,
};
let glucoseContexts: Set<GlucoseContext | "NONE"> | null = null;
for (const raw of values) {
for (const token of raw.split(",")) {
const bar = token.indexOf("|");
const system = bar >= 0 ? token.slice(0, bar) : null;
const code = (bar >= 0 ? token.slice(bar + 1) : token).trim();
if (!code) continue;
const loinc = system === null || system === "" || system === LOINC_SYSTEM;
const healthkit = system === null || system === HEALTHKIT_CODESYSTEM;
if (loinc && code === EXERCISE_ACTIVITY_LOINC) filter.workouts = true;
if (
loinc &&
(code === BP_PANEL_LOINC ||
code === BP_SYS_LOINC ||
code === BP_DIA_LOINC)
) {
filter.types.add("BLOOD_PRESSURE_SYS");
filter.types.add("BLOOD_PRESSURE_DIA");
}
if (loinc) {
for (const [ctx, map] of Object.entries(GLUCOSE_LOINC)) {
if (map.loinc !== code) continue;
filter.types.add("BLOOD_GLUCOSE");
glucoseContexts ??= new Set();
glucoseContexts.add(GLUCOSE_CONTEXT_OF[ctx]);
}
}
for (const [type, mapping] of Object.entries(MEASUREMENT_LOINC)) {
if (mapping.loinc !== code) continue;
const isHk = mapping.loinc.startsWith("HKQuantityTypeIdentifier");
if ((isHk && healthkit) || (!isHk && loinc)) {
filter.types.add(type as MeasurementType);
}
}
}
}
filter.glucoseContexts = glucoseContexts;
return filter;
}
// ── scope ───────────────────────────────────────────────────────────────
/** Types the series can carry: every single-value coding, BP and glucose. Never sleep. */
export function seriesCandidateTypes(): MeasurementType[] {
const types = Object.keys(MEASUREMENT_LOINC).filter(
(t) => t !== "SLEEP_DURATION",
) as MeasurementType[];
return [
...types,
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"BLOOD_GLUCOSE",
];
}
export interface SeriesScope {
types: MeasurementType[];
/** Pair diastolic onto systolic (both admitted): the BP panel. */
pairBloodPressure: boolean;
glucoseContexts: Set<GlucoseContext | "NONE"> | null;
workouts: boolean;
}
/** The gate's answer, narrowed by an optional code filter. */
export function resolveSeriesScope(
gate: Pick<ReportGate, "admits">,
codes: SeriesCodeFilter | null,
): SeriesScope {
const types = seriesCandidateTypes().filter(
(t) => gate.admits(t) && (codes === null || codes.types.has(t)),
);
return {
types,
pairBloodPressure:
types.includes("BLOOD_PRESSURE_SYS") &&
types.includes("BLOOD_PRESSURE_DIA"),
glucoseContexts: codes?.glucoseContexts ?? null,
workouts: gate.admits("WORKOUTS") && (codes === null || codes.workouts),
};
}
// ── query ───────────────────────────────────────────────────────────────
/** One page of series row references, newest first. */
interface SeriesRef {
kind: "m" | "w";
id: string;
}
function rangeSql(column: string, range: SeriesRange): Prisma.Sql {
const col = Prisma.raw(column);
return Prisma.sql`${
range.from ? Prisma.sql`AND ${col} >= ${range.from}` : Prisma.empty
} ${range.to ? Prisma.sql`AND ${col} < ${range.to}` : Prisma.empty}`;
}
/**
* The UNION the page and the count share. Every value is a bound parameter;
* the only spliced text is the closed column names above.
*/
function seriesUnion(
userId: string,
scope: SeriesScope,
range: SeriesRange,
): Prisma.Sql | null {
const parts: Prisma.Sql[] = [];
if (scope.types.length > 0) {
const contexts = scope.glucoseContexts;
const glucoseFilter =
contexts === null
? Prisma.empty
: Prisma.sql`AND (m."type" <> 'BLOOD_GLUCOSE'::"measurement_type" OR m."glucose_context"::text = ANY(${[
...contexts,
].filter((c) => c !== "NONE")}::text[]) OR (${contexts.has(
"NONE",
)}::boolean AND m."glucose_context" IS NULL))`;
// A diastolic reading that has its systolic partner rides inside the
// systolic row's panel, never as a second entry.
const pairFilter = scope.pairBloodPressure
? Prisma.sql`AND NOT (m."type" = 'BLOOD_PRESSURE_DIA'::"measurement_type" AND EXISTS (
SELECT 1 FROM measurements s
WHERE s."user_id" = m."user_id"
AND s."type" = 'BLOOD_PRESSURE_SYS'::"measurement_type"
AND s."measured_at" = m."measured_at"
AND s."deleted_at" IS NULL))`
: Prisma.empty;
parts.push(Prisma.sql`
SELECT 'm' AS kind, m."id" AS id, m."measured_at" AS at
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."type"::text = ANY(${scope.types}::text[])
${rangeSql('m."measured_at"', range)}
${glucoseFilter}
${pairFilter}`);
}
if (scope.workouts) {
parts.push(Prisma.sql`
SELECT 'w' AS kind, w."id" AS id, w."started_at" AS at
FROM workouts w
WHERE w."user_id" = ${userId}
${rangeSql('w."started_at"', range)}`);
}
if (parts.length === 0) return null;
return Prisma.join(parts, " UNION ALL ");
}
/** The total match count and one page of references, newest first. */
export async function querySeriesPage(params: {
userId: string;
scope: SeriesScope;
range: SeriesRange;
count: number;
offset: number;
}): Promise<{ total: number; refs: SeriesRef[] }> {
const { userId, scope, range, count, offset } = params;
const union = seriesUnion(userId, scope, range);
if (!union) return { total: 0, refs: [] };
const [totals, refs] = await Promise.all([
prisma.$queryRaw<Array<{ total: bigint | number }>>`
SELECT COUNT(*) AS total FROM (${union}) AS series`,
prisma.$queryRaw<SeriesRef[]>`
SELECT kind, id FROM (${union}) AS series
ORDER BY at DESC, id DESC
LIMIT ${count} OFFSET ${offset}`,
]);
return { total: Number(totals[0]?.total ?? 0), refs };
}
// ── emit ────────────────────────────────────────────────────────────────
const MEASUREMENT_SELECT = {
id: true,
type: true,
value: true,
valueMin: true,
valueMax: true,
measuredAt: true,
glucoseContext: true,
} as const;
type SeriesMeasurementRow = {
id: string;
type: MeasurementType;
value: number;
valueMin: number | null;
valueMax: number | null;
measuredAt: Date;
glucoseContext: GlucoseContext | null;
};
type SeriesWorkoutRow = {
id: string;
sportType: string;
startedAt: Date;
endedAt: Date;
durationSec: number;
totalDistanceM: number | null;
totalEnergyKcal: number | null;
avgHeartRate: number | null;
};
const WORKOUT_SELECT = {
id: true,
sportType: true,
startedAt: true,
endedAt: true,
durationSec: true,
totalDistanceM: true,
totalEnergyKcal: true,
avgHeartRate: true,
} as const;
function bpComponent(
code: string,
display: string,
value: number,
): FhirObservationComponent {
return {
code: { coding: [{ system: LOINC_SYSTEM, code, display }] },
valueQuantity: ucumQuantity(value, BP_UNIT),
};
}
const HOUR_MS = 60 * 60 * 1000;
/** One measurement row as an Observation; null for a type the series does not code. */
export function measurementSeriesObservation(
row: SeriesMeasurementRow,
glucoseUnit: GlucoseUnit,
diastolic: number | null,
): FhirObservation | null {
const id = `m-${row.id}`;
const at = row.measuredAt.toISOString();
if (row.type === "BLOOD_PRESSURE_SYS" || row.type === "BLOOD_PRESSURE_DIA") {
const sys = row.type === "BLOOD_PRESSURE_SYS";
if (sys && diastolic !== null) {
return {
resourceType: "Observation",
id,
status: "final",
category: [categoryConcept("vital-signs")],
code: {
coding: [
{
system: LOINC_SYSTEM,
code: BP_PANEL_LOINC,
display: "Blood pressure panel with all children optional",
},
],
text: "Blood pressure",
},
subject: patientRef,
effectiveDateTime: at,
component: [
bpComponent(BP_SYS_LOINC, "Systolic blood pressure", row.value),
bpComponent(BP_DIA_LOINC, "Diastolic blood pressure", diastolic),
],
};
}
// A lone component (its partner withheld or never recorded) is still a
// reading, coded with its own LOINC. It carries no vital-signs category:
// the R4 blood-pressure profile wants both components, and a category
// that promises the profile would be a claim the resource cannot keep.
return {
resourceType: "Observation",
id,
status: "final",
code: {
coding: [
sys
? {
system: LOINC_SYSTEM,
code: BP_SYS_LOINC,
display: "Systolic blood pressure",
}
: {
system: LOINC_SYSTEM,
code: BP_DIA_LOINC,
display: "Diastolic blood pressure",
},
],
text: sys ? "Systolic blood pressure" : "Diastolic blood pressure",
},
subject: patientRef,
effectiveDateTime: at,
valueQuantity: ucumQuantity(row.value, BP_UNIT),
};
}
if (row.type === "BLOOD_GLUCOSE") {
const map = GLUCOSE_LOINC[row.glucoseContext ?? "UNSPECIFIED"];
if (!map) return null;
return {
resourceType: "Observation",
id,
status: "final",
category: [categoryConcept("laboratory")],
code: {
coding: [
{ system: LOINC_SYSTEM, code: map.loinc, display: map.display },
],
text: map.display,
},
subject: patientRef,
effectiveDateTime: at,
valueQuantity: ucumQuantity(
convertGlucose(row.value, glucoseUnit),
glucoseUnit,
),
};
}
const mapping = MEASUREMENT_LOINC[row.type];
if (!mapping || row.type === "SLEEP_DURATION") return null;
// An hourly heart-rate bucket (min and max stored beside the mean) is a
// mean over its hour, not a reading at its first instant.
const hourly = row.valueMin !== null && row.valueMax !== null;
return {
resourceType: "Observation",
id,
status: "final",
category: [categoryConcept(mapping.category)],
code: codeableFromMapping(mapping),
subject: patientRef,
...(hourly
? {
effectivePeriod: {
start: at,
end: new Date(row.measuredAt.getTime() + HOUR_MS).toISOString(),
},
note: [
{
text: `Mean over the hour; range ${row.valueMin} to ${row.valueMax} ${mapping.unit}.`,
},
],
}
: { effectiveDateTime: at }),
valueQuantity: ucumQuantity(row.value, mapping.unit),
};
}
/** The sport key in words, for `valueCodeableConcept.text`. */
function sportText(sportType: string): string {
return sportType
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/_/g, " ")
.toLowerCase();
}
/**
* A workout as an Observation, after the HL7 Physical Activity IG: the
* session is an `activity` Observation coded 73985-4 (Exercise activity) whose
* value is the kind of exercise, over the session's period, with duration and
* energy as LOINC-coded components. Not a Procedure: R4 reserves Procedure
* for something done to or for the patient by a care process, and a run is a
* measured behaviour.
*/
export function workoutSeriesObservation(
row: SeriesWorkoutRow,
): FhirObservation {
const component: FhirObservationComponent[] = [
{
code: {
coding: [
{
system: LOINC_SYSTEM,
code: EXERCISE_DURATION_LOINC,
display: "Exercise duration",
},
],
},
valueQuantity: ucumQuantity(
Math.round((row.durationSec / 60) * 100) / 100,
"min",
),
},
];
if (row.totalEnergyKcal !== null && row.totalEnergyKcal > 0) {
component.push({
code: {
coding: [
{
system: LOINC_SYSTEM,
code: CALORIES_BURNED_LOINC,
display: "Calories burned",
},
],
},
valueQuantity: ucumQuantity(row.totalEnergyKcal, "kcal"),
});
}
if (row.totalDistanceM !== null && row.totalDistanceM > 0) {
component.push({
code: { text: "Distance" },
valueQuantity: ucumQuantity(row.totalDistanceM, "m"),
});
}
if (row.avgHeartRate !== null && row.avgHeartRate > 0) {
component.push({
code: { text: "Average heart rate during exercise" },
valueQuantity: ucumQuantity(row.avgHeartRate, "/min"),
});
}
return {
resourceType: "Observation",
id: `w-${row.id}`,
status: "final",
category: [categoryConcept("activity")],
code: {
coding: [
{
system: LOINC_SYSTEM,
code: EXERCISE_ACTIVITY_LOINC,
display: "Exercise activity",
},
],
text: "Exercise activity",
},
subject: patientRef,
effectivePeriod: {
start: row.startedAt.toISOString(),
end: row.endedAt.toISOString(),
},
valueCodeableConcept: {
coding: [{ system: WORKOUT_SPORT_CODESYSTEM, code: row.sportType }],
text: sportText(row.sportType),
},
component,
};
}
/** The diastolic value recorded at the same instant as each systolic row. */
async function diastolicPartners(
userId: string,
systolicAt: Date[],
): Promise<Map<number, number>> {
if (systolicAt.length === 0) return new Map();
const rows = await prisma.measurement.findMany({
where: {
userId,
type: "BLOOD_PRESSURE_DIA",
deletedAt: null,
measuredAt: { in: systolicAt },
},
select: { value: true, measuredAt: true },
});
return new Map(rows.map((r) => [r.measuredAt.getTime(), r.value]));
}
/** Load and emit one page of references, keeping their order. */
export async function emitSeriesPage(params: {
userId: string;
refs: readonly SeriesRef[];
scope: SeriesScope;
glucoseUnit: GlucoseUnit;
}): Promise<FhirObservation[]> {
const { userId, refs, scope, glucoseUnit } = params;
const mIds = refs.filter((r) => r.kind === "m").map((r) => r.id);
const wIds = refs.filter((r) => r.kind === "w").map((r) => r.id);
const [measurements, workouts] = await Promise.all([
mIds.length > 0
? prisma.measurement.findMany({
where: { userId, id: { in: mIds }, deletedAt: null },
select: MEASUREMENT_SELECT,
})
: Promise.resolve([]),
wIds.length > 0
? prisma.workout.findMany({
where: { userId, id: { in: wIds } },
select: WORKOUT_SELECT,
})
: Promise.resolve([]),
]);
const partners = scope.pairBloodPressure
? await diastolicPartners(
userId,
measurements
.filter((m) => m.type === "BLOOD_PRESSURE_SYS")
.map((m) => m.measuredAt),
)
: new Map<number, number>();
const byM = new Map(measurements.map((m) => [m.id, m]));
const byW = new Map(workouts.map((w) => [w.id, w]));
const out: FhirObservation[] = [];
for (const ref of refs) {
if (ref.kind === "w") {
const w = byW.get(ref.id);
if (w) out.push(workoutSeriesObservation(w));
continue;
}
const m = byM.get(ref.id);
if (!m) continue;
const obs = measurementSeriesObservation(
m,
glucoseUnit,
m.type === "BLOOD_PRESSURE_SYS"
? (partners.get(m.measuredAt.getTime()) ?? null)
: null,
);
if (obs) out.push(obs);
}
return out;
}
/**
* Read one series Observation by its id (`m-…` / `w-…`), inside the same
* scope the search applies. Null for an id outside the caller's record or
* scope — the route answers both with the same `not-found`.
*/
export async function readSeriesObservation(params: {
userId: string;
id: string;
scope: SeriesScope;
glucoseUnit: GlucoseUnit;
}): Promise<FhirObservation | null> {
const { userId, id, scope, glucoseUnit } = params;
if (id.startsWith("w-")) {
if (!scope.workouts) return null;
const w = await prisma.workout.findFirst({
where: { userId, id: id.slice(2) },
select: WORKOUT_SELECT,
});
return w ? workoutSeriesObservation(w) : null;
}
if (!id.startsWith("m-")) return null;
const m = await prisma.measurement.findFirst({
where: { userId, id: id.slice(2), deletedAt: null },
select: MEASUREMENT_SELECT,
});
if (!m || !scope.types.includes(m.type)) return null;
const partners =
scope.pairBloodPressure && m.type === "BLOOD_PRESSURE_SYS"
? await diastolicPartners(userId, [m.measuredAt])
: new Map<number, number>();
return measurementSeriesObservation(
m,
glucoseUnit,
partners.get(m.measuredAt.getTime()) ?? null,
);
}
/** The owner's display glucose unit, for series values. */
export async function seriesGlucoseUnit(userId: string): Promise<GlucoseUnit> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { glucoseUnit: true },
});
return resolveGlucoseUnit(user?.glucoseUnit ?? null);
}
/**
* Snapshot-mode `code` filter: keep the Observations whose `code` carries one
* of the tokens (`system|code`, or a bare code matching any system).
*/
export function matchesCodeTokens(
observation: FhirObservation,
values: readonly string[],
): boolean {
const tokens = values.flatMap((v) => v.split(",")).map((t) => t.trim());
const codings = observation.code.coding ?? [];
return tokens.some((token) => {
if (!token) return false;
const bar = token.indexOf("|");
const system = bar >= 0 ? token.slice(0, bar) : null;
const code = bar >= 0 ? token.slice(bar + 1) : token;
return codings.some(
(c) =>
c.code === code &&
(system === null || system === "" || c.system === system),
);
});
}
+5 -3
View File
@@ -353,7 +353,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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, Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type` interactions, the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass).",
"Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type` interactions, the search parameters (`_count`, `_offset`; `date` and `code` on Observation), the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass).",
responses: {
"200": {
description: "CapabilityStatement (application/fhir+json).",
@@ -397,11 +397,13 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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`.",
"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`. Without `date` the answer is the snapshot it has always been: one latest reading per type plus the derived panels, ids `obs-N`. With `date` (v1.44, repeatable, FHIR prefixes eq/gt/ge/lt/le, a value without an offset read as UTC) the answer is the time series instead: every reading in the range plus workouts, newest first, paged in the database, ids `m-<id>` / `w-<id>` that read back through `/api/fhir/Observation/{id}`. The series follows the same saved report selection as the snapshot, leaves sleep out (stored as stage segments), pairs a systolic and diastolic recorded at the same instant into the blood-pressure panel, and codes a workout as an `activity` Observation (LOINC 73985-4 Exercise activity, duration 55411-3 and energy 41981-2 as components). `code` (repeatable, `system|code` or a bare code, comma-separated) narrows either answer; an unknown code matches nothing. A malformed `date` is a 400 OperationOutcome.",
requestParams: {
query: z.object({
_count: z.coerce.number().optional(),
_offset: z.coerce.number().optional(),
date: z.string().optional(),
code: z.string().optional(),
}),
},
responses: {
@@ -557,7 +559,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["FHIR"],
summary: "FHIR R4 Observation read",
description:
"Read-only `Observation` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.",
"Read-only `Observation` by id, out of the caller's own record: a snapshot id (`obs-N`) or a series id (`m-<id>` / `w-<id>`, v1.44), the latter read inside the same saved selection the series search applies. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.",
requestParams: {
path: z.object({ id: z.string() }),
},
@@ -59,6 +59,12 @@
"unit": "ms",
"category": "vital-signs"
},
"HRV_RMSSD": {
"loinc": null,
"display": "Heart rate variability RMSSD",
"unit": "ms",
"category": "vital-signs"
},
"ACTIVITY_STEPS": {
"loinc": "41950-7",
"display": "Number of steps in 24 hour Measured",
+28
View File
@@ -211,6 +211,34 @@ export const SIGNALS: Record<string, SignalDefinition> = {
category: "vital-signs",
},
},
// v1.44 — RMSSD, the heart-rate-variability statistic rings and bands
// report (Oura, WHOOP, Polar, Fitbit, Google Health). Registered for its FHIR
// coding only: every other surface reads it through its own tables today, so
// all surface flags stay off and nothing else derives from this entry.
// LOINC publishes an SDNN term (80404-7, above) and none for RMSSD, so the
// coding is text-only rather than borrowing SDNN's code for a different
// statistic.
HRV_RMSSD: {
key: "HRV_RMSSD",
kind: "measurement",
source: { measurementType: "HRV_RMSSD" },
displayName: "Heart-rate variability (RMSSD)",
unit: "ms",
direction: "higher-better",
archetype: "physiological-vital",
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
fhir: {
loinc: null,
display: "Heart rate variability RMSSD",
unit: "ms",
category: "vital-signs",
},
},
OXYGEN_SATURATION: {
key: "OXYGEN_SATURATION",
kind: "measurement",
@@ -0,0 +1,362 @@
/**
* v1.44 against a real Postgres: the FHIR Observation time series (the UNION
* page query, its count, the BP pairing, the glucose-context filter, owner and
* soft-delete scoping), the segmented medication effect read end to end, and
* the doctor report's workout and effect blocks.
*
* The effect fixture: an ACE inhibitor started on 2026-03-01 with systolic
* readings of exactly 140 for 60 days before and 130 − 0.1·t after, so the
* fit must recover a level change of −10 mmHg and a slope change of
* −0.7 mmHg/week, and a second medication starting inside the window must be
* listed as a confounder.
*/
import { beforeAll, describe, expect, it } from "vitest";
import type { Prisma } from "@/generated/prisma/client";
import {
emitSeriesPage,
querySeriesPage,
resolveSeriesScope,
} from "@/lib/fhir/series";
import {
EFFECT_MEDICATION_INCLUDE,
loadSegmentedEffects,
} from "@/lib/medications/efficacy/load-segmented-effects";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
import { selectionFromLeaves } from "@/lib/report-selection/selection";
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
import { getPrismaClient, truncateAllTables } from "./setup";
const DAY = 86_400_000;
const PIVOT = Date.parse("2026-03-01T00:00:00.000Z");
let userId = "";
let otherId = "";
let medId = "";
const MODULES = {
cycle: false,
mood: false,
sleep: true,
glucose: true,
workouts: true,
recovery: true,
labs: true,
illness: true,
achievements: true,
coach: true,
insights: true,
medications: true,
doctorReport: true,
environment: true,
mcp: true,
inboundDocuments: true,
mentalHealth: true,
nutrients: true,
vaccinations: true,
timeline: true,
} as const;
const gateOf = (leaves: ReportLeafId[]) => ({
admits: (leaf: ReportLeafId) => leaves.includes(leaf),
});
beforeAll(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
const user = await prisma.user.create({
data: {
username: "fhir-series-effect",
email: "fhir-series-effect@example.test",
timezone: "UTC",
},
});
userId = user.id;
const other = await prisma.user.create({
data: {
username: "fhir-series-other",
email: "fhir-series-other@example.test",
timezone: "UTC",
},
});
otherId = other.id;
const rows: Prisma.MeasurementCreateManyInput[] = [];
// Systolic: 140 before, 130 − 0.1·t after (one reading a day at 08:00).
for (let t = -60; t < 60; t++) {
rows.push({
userId,
type: "BLOOD_PRESSURE_SYS",
value: t < 0 ? 140 : 130 - 0.1 * t,
unit: "mmHg",
source: "MANUAL",
measuredAt: new Date(PIVOT + t * DAY + 8 * 3_600_000),
});
}
// Two paired diastolic readings and one lone diastolic.
rows.push(
{
userId,
type: "BLOOD_PRESSURE_DIA",
value: 85,
unit: "mmHg",
source: "MANUAL",
measuredAt: new Date(PIVOT + 10 * DAY + 8 * 3_600_000),
},
{
userId,
type: "BLOOD_PRESSURE_DIA",
value: 84,
unit: "mmHg",
source: "MANUAL",
measuredAt: new Date(PIVOT + 11 * DAY + 8 * 3_600_000),
},
{
userId,
type: "BLOOD_PRESSURE_DIA",
value: 80,
unit: "mmHg",
source: "MANUAL",
measuredAt: new Date(PIVOT + 11 * DAY + 20 * 3_600_000),
},
);
rows.push(
{
userId,
type: "BLOOD_GLUCOSE",
value: 95,
unit: "mg/dL",
source: "MANUAL",
glucoseContext: "FASTING",
measuredAt: new Date(PIVOT + 12 * DAY),
},
{
userId,
type: "BLOOD_GLUCOSE",
value: 130,
unit: "mg/dL",
source: "MANUAL",
glucoseContext: null,
measuredAt: new Date(PIVOT + 12 * DAY + 3_600_000),
},
{
userId,
type: "WEIGHT",
value: 80,
unit: "kg",
source: "MANUAL",
measuredAt: new Date(PIVOT + 13 * DAY),
deletedAt: new Date(PIVOT + 14 * DAY),
},
{
userId: otherId,
type: "WEIGHT",
value: 70,
unit: "kg",
source: "MANUAL",
measuredAt: new Date(PIVOT + 13 * DAY),
},
);
await prisma.measurement.createMany({ data: rows });
await prisma.workout.createMany({
data: [
{
userId,
source: "MANUAL",
sportType: "running",
externalId: "run-1",
startedAt: new Date(PIVOT + 11 * DAY + 12 * 3_600_000),
endedAt: new Date(PIVOT + 11 * DAY + 13 * 3_600_000),
durationSec: 3600,
totalDistanceM: 10_000,
totalEnergyKcal: 700,
avgHeartRate: 150,
},
{
userId,
source: "MANUAL",
sportType: "running",
externalId: "run-2",
startedAt: new Date(PIVOT + 20 * DAY),
endedAt: new Date(PIVOT + 20 * DAY + 1_800_000),
durationSec: 1800,
},
],
});
const med = await prisma.medication.create({
data: {
userId,
name: "Ramipril",
dose: "5 mg",
treatmentClass: "GENERIC",
atcCode: "C09AA05",
trackIntake: false,
startsOn: new Date(PIVOT),
},
});
medId = med.id;
await prisma.medication.create({
data: {
userId,
name: "Second drug",
dose: "1 mg",
treatmentClass: "GENERIC",
startsOn: new Date(PIVOT + 20 * DAY),
},
});
});
describe("FHIR Observation series in Postgres", () => {
const range = {
from: new Date(PIVOT + 10 * DAY),
to: new Date(PIVOT + 21 * DAY),
};
it("pages newest first, pairs BP, scopes to the owner and drops soft-deleted rows", async () => {
const scope = resolveSeriesScope(
gateOf([
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"BLOOD_GLUCOSE",
"WEIGHT",
"WORKOUTS",
]),
null,
);
const { total, refs } = await querySeriesPage({
userId,
scope,
range,
count: 200,
offset: 0,
});
// 11 systolic (days 10..20), 1 lone diastolic, 2 glucose, 2 workouts.
// The two paired diastolic rows ride inside their systolic panels; the
// deleted weight and the other account's weight never appear.
expect(total).toBe(16);
expect(refs).toHaveLength(16);
const page = await emitSeriesPage({
userId,
refs,
scope,
glucoseUnit: "mg/dL",
});
expect(page).toHaveLength(16);
// Newest first: the day-20 systolic (08:00, no diastolic partner, so a
// lone 8480-6 reading), then the day-20 run (00:00).
expect(page[0].code.coding?.[0]?.code).toBe("8480-6");
expect(page[1].id.startsWith("w-")).toBe(true);
const panels = page.filter(
(o) =>
o.code.coding?.[0]?.code === "85354-9" && o.component?.length === 2,
);
expect(
panels.map((p) => p.component?.[1]?.valueQuantity?.value).sort(),
).toEqual([84, 85]);
expect(page.some((o) => o.code.coding?.[0]?.code === "8462-4")).toBe(true);
expect(page.some((o) => o.code.coding?.[0]?.code === "29463-7")).toBe(
false,
);
});
it("pages with offset and agrees with the count", async () => {
const scope = resolveSeriesScope(gateOf(["BLOOD_PRESSURE_SYS"]), null);
const first = await querySeriesPage({
userId,
scope,
range,
count: 5,
offset: 0,
});
const second = await querySeriesPage({
userId,
scope,
range,
count: 5,
offset: 5,
});
expect(first.total).toBe(11);
expect(first.refs).toHaveLength(5);
expect(second.refs).toHaveLength(5);
const ids = new Set([...first.refs, ...second.refs].map((r) => r.id));
expect(ids.size).toBe(10);
});
it("narrows glucose to a code's contexts", async () => {
const scope = resolveSeriesScope(gateOf(["BLOOD_GLUCOSE"]), {
types: new Set(["BLOOD_GLUCOSE"]),
glucoseContexts: new Set(["FASTING"]),
workouts: false,
});
const { total } = await querySeriesPage({
userId,
scope,
range,
count: 50,
offset: 0,
});
expect(total).toBe(1);
});
});
describe("segmented medication effect in Postgres", () => {
it("recovers the known level and slope change and lists the confounder", async () => {
const prisma = getPrismaClient();
const med = await prisma.medication.findFirstOrThrow({
where: { id: medId },
include: EFFECT_MEDICATION_INCLUDE,
});
const effects = await loadSegmentedEffects({
userId,
med,
targets: ["BLOOD_PRESSURE_SYS"],
timezone: "UTC",
now: new Date(PIVOT + 59 * DAY + 12 * 3_600_000),
});
const e = effects.get("BLOOD_PRESSURE_SYS")!;
expect(e.present).toBe(true);
expect(e.levelChange!.estimate).toBeCloseTo(-10, 6);
expect(e.slopeChangePerWeek!.estimate).toBeCloseTo(-0.7, 6);
expect(e.days.before).toBe(60);
expect(e.days.after).toBe(60);
expect(e.adherence.status).toBe("not_tracked");
expect(e.confounders).toEqual([
{ medicationName: "Second drug", event: "started", date: "2026-03-21" },
]);
});
it("reaches the doctor report with its workout block", async () => {
const data = await collectDoctorReportData(
userId,
{
start: new Date(PIVOT),
end: new Date(PIVOT + 59 * DAY + 12 * 3_600_000),
days: 60,
},
selectionFromLeaves([
"MEDICATION_LIST",
"MEDICATION_EFFECT",
"BLOOD_PRESSURE_SYS",
"WORKOUTS",
]),
{ moduleMap: MODULES as never },
);
const effect = data.medicationEffects?.find(
(m) => m.medicationName === "Ramipril",
);
// Diastolic was not selected, so only the systolic target rides.
expect(effect?.targets.map((t) => t.type)).toEqual(["BLOOD_PRESSURE_SYS"]);
expect(effect?.targets[0].effect.present).toBe(true);
expect(data.workouts?.sessions).toBe(2);
expect(data.workouts?.bySport[0]).toMatchObject({
sportType: "running",
sessions: 2,
durationSec: 5400,
distanceM: 10_000,
distanceSessions: 1,
});
});
});