diff --git a/server/src/lib/analytes.ts b/server/src/lib/analytes.ts index 7aa262f..8a917cd 100644 --- a/server/src/lib/analytes.ts +++ b/server/src/lib/analytes.ts @@ -508,6 +508,12 @@ for (const a of ANALYTES) { for (const al of a.aliases) INDEX.set(norm(al), a); } +const BY_KEY = new Map(ANALYTES.map((a) => [a.key, a])); + export function resolveAnalyte(name: string): Analyte | null { return INDEX.get(norm(name)) ?? null; } + +export function getAnalyte(key: string): Analyte | null { + return BY_KEY.get(key) ?? null; +} diff --git a/server/src/routes/labs.ts b/server/src/routes/labs.ts index bfffc62..2228626 100644 --- a/server/src/routes/labs.ts +++ b/server/src/routes/labs.ts @@ -1,13 +1,14 @@ import { desc, eq } from "drizzle-orm"; import { Hono } from "hono"; import { randomUUID } from "node:crypto"; -import { mkdirSync } from "node:fs"; +import { existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import type { Db } from "../db"; import { biomarkers, labDrafts, labDraws } from "../db/schema"; import { extractFromText } from "../lib/extract"; import { normalizeMarker } from "../lib/normalize"; import { pdfToText } from "../lib/pdf"; +import { getAnalyte } from "../lib/analytes"; import { ConfirmDraftBody } from "@helios/shared"; export type LabsDeps = { db: Db; key: Buffer; dataDir: string; llmFetch?: typeof fetch }; @@ -111,5 +112,58 @@ export function labsRoutes(deps: LabsDeps) { return c.body(null, 204); }); + app.get("/labs/draws", async (c) => { + const draws = await deps.db.select().from(labDraws).orderBy(desc(labDraws.collectedAt)); + const rows = await deps.db.select().from(biomarkers); + return c.json({ + draws: draws.map((d) => ({ + id: d.id, collectedAt: d.collectedAt, labName: d.labName, + markerCount: rows.filter((r) => r.drawId === d.id).length, + flaggedCount: rows.filter((r) => r.drawId === d.id && r.flagged === 1).length, + })), + }); + }); + + app.get("/labs/draws/:id", async (c) => { + const d = (await deps.db.select().from(labDraws).where(eq(labDraws.id, c.req.param("id")))).at(0); + if (!d) return c.json({ error: "not found" }, 404); + const rows = await deps.db.select().from(biomarkers).where(eq(biomarkers.drawId, d.id)); + const byPanel = new Map(); + for (const r of rows) byPanel.set(r.panel, [...(byPanel.get(r.panel) ?? []), r]); + return c.json({ + id: d.id, collectedAt: d.collectedAt, labName: d.labName, + panels: [...byPanel.keys()].sort().map((panel) => ({ + panel, + markers: byPanel.get(panel)! + .sort((a, b) => a.name.localeCompare(b.name)) + .map((r) => ({ ...r, flagged: r.flagged === 1 })), + })), + }); + }); + + app.get("/labs/markers/:key/history", async (c) => { + const analyte = getAnalyte(c.req.param("key")); + if (!analyte) return c.json({ error: "unknown marker" }, 404); + const draws = await deps.db.select().from(labDraws); + const dates = new Map(draws.map((d) => [d.id, d.collectedAt])); + const rows = await deps.db.select().from(biomarkers).where(eq(biomarkers.analyteKey, analyte.key)); + const points = rows + .filter((r) => r.valueCanonical !== null) + .map((r) => ({ drawId: r.drawId, collectedAt: dates.get(r.drawId) ?? "", value: r.valueCanonical! })) + .sort((a, b) => a.collectedAt.localeCompare(b.collectedAt)); + return c.json({ key: analyte.key, display: analyte.display, canonicalUnit: analyte.canonicalUnit, points }); + }); + + app.get("/labs/drafts/:id/file", async (c) => { + const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0); + if (!row || !existsSync(row.filePath)) return c.json({ error: "not found" }, 404); + return new Response(Bun.file(row.filePath), { + headers: { + "content-type": "application/pdf", + "content-disposition": `inline; filename="${row.filename.replace(/[^\w.-]/g, "_")}"`, + }, + }); + }); + return app; } diff --git a/server/test/labs-query.test.ts b/server/test/labs-query.test.ts new file mode 100644 index 0000000..67106ca --- /dev/null +++ b/server/test/labs-query.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "../src/app"; +import { openDb } from "../src/db"; +import { biomarkers, labDraws } from "../src/db/schema"; +import { loadOrCreateKey } from "../src/lib/crypto"; + +async function seeded() { + const dir = mkdtempSync(join(tmpdir(), "helios-")); + const db = openDb(dir); + const app = createApp({ db, key: loadOrCreateKey(dir), dataDir: dir }); + const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) }); + await app.request("/api/setup", j({ password: "hunter2hunter2" })); + const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!; + + await db.insert(labDraws).values([ + { id: "d1", collectedAt: "2025-06-01", labName: "A", draftId: null, createdAt: 1 }, + { id: "d2", collectedAt: "2026-01-15", labName: "B", draftId: null, createdAt: 2 }, + ]); + await db.insert(biomarkers).values([ + { drawId: "d1", panel: "metabolic", name: "Glucose", marker: "glucose", analyteKey: "glucose", value: "90", valueNum: 90, unit: "mg/dL", referenceRange: null, flagged: 0, valueCanonical: 4.996, canonicalUnit: "mmol/L" }, + { drawId: "d2", panel: "metabolic", name: "Glucose", marker: "glucose", analyteKey: "glucose", value: "100", valueNum: 100, unit: "mg/dL", referenceRange: "70-99", flagged: 1, valueCanonical: 5.551, canonicalUnit: "mmol/L" }, + { drawId: "d2", panel: "lipids", name: "LDL Cholesterol", marker: "ldl", analyteKey: "ldl", value: "3.1", valueNum: 3.1, unit: "mmol/L", referenceRange: "<3.4", flagged: 0, valueCanonical: 3.1, canonicalUnit: "mmol/L" }, + ]); + return { app, cookie }; +} + +describe("labs queries", () => { + test("draw list newest first with counts", async () => { + const { app, cookie } = await seeded(); + const { draws } = await (await app.request("/api/labs/draws", { headers: { cookie } })).json(); + expect(draws.map((d: any) => d.id)).toEqual(["d2", "d1"]); + expect(draws[0].markerCount).toBe(2); + expect(draws[0].flaggedCount).toBe(1); + }); + + test("draw detail grouped by panel", async () => { + const { app, cookie } = await seeded(); + const detail = await (await app.request("/api/labs/draws/d2", { headers: { cookie } })).json(); + expect(detail.panels.map((p: any) => p.panel)).toEqual(["lipids", "metabolic"]); + expect(detail.panels[1].markers[0].flagged).toBe(true); + expect((await app.request("/api/labs/draws/nope", { headers: { cookie } })).status).toBe(404); + }); + + test("marker history ascending with registry metadata", async () => { + const { app, cookie } = await seeded(); + const h = await (await app.request("/api/labs/markers/glucose/history", { headers: { cookie } })).json(); + expect(h.display).toBe("Glucose"); + expect(h.canonicalUnit).toBe("mmol/L"); + expect(h.points.map((p: any) => p.value)).toEqual([4.996, 5.551]); + expect((await app.request("/api/labs/markers/unknown_thing/history", { headers: { cookie } })).status).toBe(404); + }); +});