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 <noreply@anthropic.com>
This commit is contained in:
marcuspaico
2026-08-17 15:46:10 -07:00
parent 8e9f3d5894
commit 038c92af45
3 changed files with 141 additions and 1 deletions

View File

@@ -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;
}

View File

@@ -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);
});
});