From 038c92af453aa84f3baadcbfccd47c9b5df433a6 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 15:46:10 -0700 Subject: [PATCH] feat(labs): draft review, confirm with normalization, discard - Add ConfirmDraftBody schema to shared types - Implement GET /api/labs/drafts/:id (retrieves draft with extracted data) - Implement POST /api/labs/drafts/:id/confirm (normalizes markers, inserts lab draw + biomarkers in transaction, marks draft confirmed) - Implement POST /api/labs/drafts/:id/discard (marks draft discarded) - Add comprehensive test suite with 3 new tests Co-Authored-By: Claude Fable 5 --- server/src/routes/labs.ts | 49 +++++++++++++++++- server/test/labs-confirm.test.ts | 86 ++++++++++++++++++++++++++++++++ shared/src/types.ts | 7 +++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 server/test/labs-confirm.test.ts diff --git a/server/src/routes/labs.ts b/server/src/routes/labs.ts index cc77273..193dbfe 100644 --- a/server/src/routes/labs.ts +++ b/server/src/routes/labs.ts @@ -4,9 +4,11 @@ import { randomUUID } from "node:crypto"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; import type { Db } from "../db"; -import { labDrafts } from "../db/schema"; +import { biomarkers, labDrafts, labDraws } from "../db/schema"; import { extractFromText } from "../lib/extract"; +import { normalizeMarker } from "../lib/normalize"; import { pdfToText } from "../lib/pdf"; +import { ConfirmDraftBody } from "@helios/shared"; export type LabsDeps = { db: Db; key: Buffer; dataDir: string; llmFetch?: typeof fetch }; @@ -64,5 +66,50 @@ export function labsRoutes(deps: LabsDeps) { }); }); + app.get("/labs/drafts/:id", async (c) => { + const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0); + if (!row) return c.json({ error: "not found" }, 404); + return c.json({ + id: row.id, filename: row.filename, status: row.status, error: row.error, + draft: row.extracted ? JSON.parse(row.extracted) : null, + }); + }); + + app.post("/labs/drafts/:id/confirm", async (c) => { + const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0); + if (!row) return c.json({ error: "not found" }, 404); + if (row.status !== "pending") return c.json({ error: "draft is not pending" }, 409); + const body = ConfirmDraftBody.safeParse(await c.req.json().catch(() => null)); + if (!body.success) return c.json({ error: body.error.issues[0]?.message ?? "invalid body" }, 400); + + const drawId = randomUUID(); + const norm = body.data.markers.map((m) => normalizeMarker({ + panel: m.panel, name: m.name, value: m.value, unit: m.unit, + referenceRange: m.referenceRange, flagged: m.flagged, + })); + await deps.db.transaction(async (tx) => { + await tx.insert(labDraws).values({ + id: drawId, collectedAt: body.data.collectedDate, labName: body.data.labName, + draftId: row.id, createdAt: Date.now(), + }); + for (const n of norm) { + await tx.insert(biomarkers).values({ + drawId, panel: n.panel, name: n.name, marker: n.marker, analyteKey: n.analyteKey, + value: n.value, valueNum: n.valueNum, unit: n.unit, referenceRange: n.referenceRange, + flagged: n.flagged ? 1 : 0, valueCanonical: n.valueCanonical, canonicalUnit: n.canonicalUnit, + }); + } + await tx.update(labDrafts).set({ status: "confirmed" }).where(eq(labDrafts.id, row.id)); + }); + return c.json({ drawId }, 201); + }); + + app.post("/labs/drafts/:id/discard", async (c) => { + const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0); + if (!row) return c.json({ error: "not found" }, 404); + await deps.db.update(labDrafts).set({ status: "discarded" }).where(eq(labDrafts.id, row.id)); + return c.body(null, 204); + }); + return app; } diff --git a/server/test/labs-confirm.test.ts b/server/test/labs-confirm.test.ts new file mode 100644 index 0000000..4640f6c --- /dev/null +++ b/server/test/labs-confirm.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { createApp } from "../src/app"; +import { openDb } from "../src/db"; +import { biomarkers, labDrafts } from "../src/db/schema"; +import { loadOrCreateKey } from "../src/lib/crypto"; + +async function setup() { + 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(labDrafts).values({ + id: "draft1", filename: "r.pdf", filePath: "/tmp/none.pdf", status: "pending", + extracted: JSON.stringify({ collectedDate: "2026-01-15", labName: "Sample Diagnostics", markers: [ + { panel: "chemistry", name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true }, + ] }), + error: null, createdAt: 1, + }); + return { app, db, cookie }; +} + +describe("draft review", () => { + test("get, confirm with edits → normalized biomarkers, draft confirmed", async () => { + const { app, db, cookie } = await setup(); + const h = { cookie, "content-type": "application/json" }; + + const got = await (await app.request("/api/labs/drafts/draft1", { headers: { cookie } })).json(); + expect(got.draft.markers).toHaveLength(1); + + const confirm = await app.request("/api/labs/drafts/draft1/confirm", { + method: "POST", headers: h, + body: JSON.stringify({ + collectedDate: "2026-01-15", + labName: "Sample Diagnostics", + markers: [ + { panel: "chemistry", name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true }, + { panel: null, name: "Ferritin", value: "30", unit: "ng/mL", referenceRange: null, flagged: false }, // user-added row + ], + }), + }); + expect(confirm.status).toBe(201); + + const rows = await db.select().from(biomarkers); + expect(rows).toHaveLength(2); + const glucose = rows.find((r) => r.analyteKey === "glucose")!; + expect(glucose.valueCanonical).toBeCloseTo(5.551, 2); + expect(glucose.flagged).toBe(1); + const ferritin = rows.find((r) => r.analyteKey === "ferritin")!; + expect(ferritin.valueCanonical).toBeCloseTo(30); // ng/mL → µg/L 1:1 + expect(ferritin.canonicalUnit).toBe("µg/L"); + + const draft = (await db.select().from(labDrafts).where(eq(labDrafts.id, "draft1")))[0]; + expect(draft.status).toBe("confirmed"); + + // second confirm → 409 + const again = await app.request("/api/labs/drafts/draft1/confirm", { + method: "POST", headers: h, + body: JSON.stringify({ collectedDate: "2026-01-15", labName: null, markers: [{ panel: null, name: "X", value: "1", unit: null, referenceRange: null, flagged: false }] }), + }); + expect(again.status).toBe(409); + }); + + test("discard marks draft discarded", async () => { + const { app, db, cookie } = await setup(); + const r = await app.request("/api/labs/drafts/draft1/discard", { method: "POST", headers: { cookie } }); + expect(r.status).toBe(204); + const draft = (await db.select().from(labDrafts).where(eq(labDrafts.id, "draft1")))[0]; + expect(draft.status).toBe("discarded"); + }); + + test("unknown draft 404; bad date 400", async () => { + const { app, cookie } = await setup(); + expect((await app.request("/api/labs/drafts/nope", { headers: { cookie } })).status).toBe(404); + const bad = await app.request("/api/labs/drafts/draft1/confirm", { + method: "POST", headers: { cookie, "content-type": "application/json" }, + body: JSON.stringify({ collectedDate: "15/01/2026", labName: null, markers: [{ panel: null, name: "X", value: "1", unit: null, referenceRange: null, flagged: false }] }), + }); + expect(bad.status).toBe(400); + }); +}); diff --git a/shared/src/types.ts b/shared/src/types.ts index fd352b8..9654240 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -37,3 +37,10 @@ export const ExtractedDraft = z.object({ markers: z.array(ExtractedMarker).default([]), }); export type ExtractedDraft = z.infer; + +export const ConfirmDraftBody = z.object({ + collectedDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + labName: z.string().nullable().default(null), + markers: z.array(ExtractedMarker).min(1), +}); +export type ConfirmDraftBody = z.infer;