mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'feat/v144-report' into release/v1.44.0
This commit is contained in:
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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: [
|
||||
|
||||
Reference in new issue
Block a user