1534 lines
68 KiB
Markdown
1534 lines
68 KiB
Markdown
# Helios M2 — Labs Pipeline Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Blood-test core: upload a lab PDF → LLM extracts markers → user reviews/edits/confirms → normalized biomarkers (canonical units via analyte registry) → Labs dashboard with flags and per-marker history.
|
||
|
||
**Architecture:** New `lab_drafts` (pending extractions), `lab_draws` + `biomarkers` tables. A provider-agnostic `chatJSON` LLM helper (OpenAI-compatible, fetch-injectable for tests). PDF→text via `unpdf` (pure JS, works in Bun). Normalization re-implements the health-api concepts cleanly: analyte registry (canonical unit + molar mass + aliases) and two-kind unit conversion (dimensional scaling is analyte-independent; mass↔mole bridging needs molar mass). Nothing writes to `biomarkers` until the user confirms.
|
||
|
||
**Tech Stack:** Existing (Bun, Hono, drizzle, Zod, React). New deps: `unpdf` (server).
|
||
|
||
**Spec:** docs/superpowers/specs/2026-08-18-helios-design.md (Labs section; milestone 4 pulled forward by Marcus 2026-08-18 to be milestone 2).
|
||
|
||
## Global Constraints
|
||
|
||
- TS strict; `bun run typecheck`, `bun test server`, `bun run build:web` green at every commit. Conventional Commits.
|
||
- Secrets only via `getSetting`; LLM calls go only to the user-configured endpoint; no telemetry.
|
||
- Nothing writes to `lab_draws`/`biomarkers` except the confirm endpoint, after user review.
|
||
- Uploads: PDF only, ≤ 15 MB, stored under `<DATA_DIR>/uploads/`, served only through authed API with `Content-Type: application/pdf` and `Content-Disposition: inline; filename=...`.
|
||
- All new API bodies validated with Zod schemas in `shared/src/types.ts`.
|
||
- Raw extraction values are preserved verbatim (`value` as text); canonical values are additive, never destructive.
|
||
- `~/health-api` is reference only — re-implement, fresh comments, no Marcus-specific data or lab names in code or fixtures.
|
||
|
||
---
|
||
|
||
### Task 1: Schema migration — drafts, draws, biomarkers
|
||
|
||
**Files:**
|
||
- Modify: `server/src/db/schema.ts`
|
||
- Create: `server/drizzle/0001_labs.sql` (generated)
|
||
- Test: `server/test/db-labs.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces tables (used by Tasks 4–6):
|
||
- `labDrafts`: `id` text PK, `filename` text notNull, `filePath` text notNull, `status` text notNull ('pending' | 'confirmed' | 'discarded'), `extracted` text (JSON string, nullable), `error` text nullable, `createdAt` integer notNull.
|
||
- `labDraws`: `id` text PK, `collectedAt` text notNull (ISO date `YYYY-MM-DD`), `labName` text nullable, `draftId` text nullable, `createdAt` integer notNull.
|
||
- `biomarkers`: `id` integer PK autoincrement, `drawId` text notNull, `panel` text notNull, `name` text notNull, `marker` text notNull, `analyteKey` text nullable, `value` text notNull, `valueNum` real nullable, `unit` text nullable, `referenceRange` text nullable, `flagged` integer notNull default 0, `valueCanonical` real nullable, `canonicalUnit` text nullable.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`server/test/db-labs.test.ts`:
|
||
```ts
|
||
import { describe, expect, test } from "bun:test";
|
||
import { mkdtempSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import { openDb } from "../src/db";
|
||
import { biomarkers, labDraws } from "../src/db/schema";
|
||
|
||
describe("labs schema", () => {
|
||
test("draw + biomarker round-trip", async () => {
|
||
const db = openDb(mkdtempSync(join(tmpdir(), "helios-")));
|
||
await db.insert(labDraws).values({ id: "d1", collectedAt: "2026-01-15", labName: "Acme Lab", draftId: null, createdAt: 1 });
|
||
await db.insert(biomarkers).values({
|
||
drawId: "d1", 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",
|
||
});
|
||
const rows = await db.select().from(biomarkers);
|
||
expect(rows).toHaveLength(1);
|
||
expect(rows[0].marker).toBe("ldl");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `bun test server/test/db-labs.test.ts` — Expected: FAIL (tables missing).
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
Append to `server/src/db/schema.ts`:
|
||
```ts
|
||
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||
// (merge with the existing import line — one import statement total)
|
||
|
||
export const labDrafts = sqliteTable("lab_drafts", {
|
||
id: text("id").primaryKey(),
|
||
filename: text("filename").notNull(),
|
||
filePath: text("file_path").notNull(),
|
||
status: text("status").notNull(), // pending | confirmed | discarded
|
||
extracted: text("extracted"),
|
||
error: text("error"),
|
||
createdAt: integer("created_at").notNull(),
|
||
});
|
||
|
||
export const labDraws = sqliteTable("lab_draws", {
|
||
id: text("id").primaryKey(),
|
||
collectedAt: text("collected_at").notNull(), // ISO date YYYY-MM-DD
|
||
labName: text("lab_name"),
|
||
draftId: text("draft_id"),
|
||
createdAt: integer("created_at").notNull(),
|
||
});
|
||
|
||
export const biomarkers = sqliteTable("biomarkers", {
|
||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||
drawId: text("draw_id").notNull(),
|
||
panel: text("panel").notNull(),
|
||
name: text("name").notNull(),
|
||
marker: text("marker").notNull(),
|
||
analyteKey: text("analyte_key"),
|
||
value: text("value").notNull(),
|
||
valueNum: real("value_num"),
|
||
unit: text("unit"),
|
||
referenceRange: text("reference_range"),
|
||
flagged: integer("flagged").notNull().default(0),
|
||
valueCanonical: real("value_canonical"),
|
||
canonicalUnit: text("canonical_unit"),
|
||
});
|
||
```
|
||
|
||
Run: `cd server && bunx drizzle-kit generate --name labs` → commit generated files.
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `bun test server/test/db-labs.test.ts` — Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(db): lab_drafts, lab_draws, biomarkers tables
|
||
|
||
Refs PAI-91."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Unit conversion + analyte registry + normalization
|
||
|
||
**Files:**
|
||
- Create: `server/src/lib/units.ts`, `server/src/lib/analytes.ts`, `server/src/lib/normalize.ts`
|
||
- Test: `server/test/normalize.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces (consumed by Task 5's confirm endpoint and Task 6's queries):
|
||
- `convert(value: number, fromUnit: string, toUnit: string, molarMass?: number | null): number | null`
|
||
- `resolveAnalyte(name: string): Analyte | null` where `Analyte = { key, display, canonicalUnit, molarMass, panel, aliases }`
|
||
- `normalizeMarker(raw: RawMarker): NormMarker` — `RawMarker = { panel?, name, value: string, unit?, referenceRange?, flagged? }`; `NormMarker` mirrors the `biomarkers` columns (panel, name, marker, analyteKey, value, valueNum, unit, referenceRange, flagged (boolean), valueCanonical, canonicalUnit).
|
||
|
||
Concepts re-implemented from the reference design: unit conversion splits into analyte-independent dimensional scaling (ng/mL→µg/L) and an analyte-dependent mass↔mole bridge that requires molar mass (mg/dL→mmol/L for glucose needs 180.16 g/mol). Non-bridgeable kinds (%, ratio, IU, counts) convert only within their kind.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`server/test/normalize.test.ts`:
|
||
```ts
|
||
import { describe, expect, test } from "bun:test";
|
||
import { resolveAnalyte } from "../src/lib/analytes";
|
||
import { normalizeMarker } from "../src/lib/normalize";
|
||
import { convert } from "../src/lib/units";
|
||
|
||
describe("units", () => {
|
||
test("dimensional scaling: ng/mL → µg/L is 1:1", () => {
|
||
expect(convert(30, "ng/mL", "µg/L")).toBeCloseTo(30);
|
||
});
|
||
test("mass→mole bridge needs molar mass", () => {
|
||
expect(convert(100, "mg/dL", "mmol/L")).toBeNull();
|
||
expect(convert(100, "mg/dL", "mmol/L", 180.16)).toBeCloseTo(5.551, 2); // glucose
|
||
});
|
||
test("mole→mass: testosterone 20 nmol/L → ng/dL", () => {
|
||
expect(convert(20, "nmol/L", "ng/dL", 288.42)).toBeCloseTo(576.8, 0);
|
||
});
|
||
test("percent only converts to percent", () => {
|
||
expect(convert(42, "%", "%")).toBe(42);
|
||
expect(convert(42, "%", "mg/dL")).toBeNull();
|
||
});
|
||
test("count units: x10^9/L → thousand/uL is 1:1", () => {
|
||
expect(convert(6.1, "x10^9/L", "thousand/uL")).toBeCloseTo(6.1);
|
||
});
|
||
});
|
||
|
||
describe("analytes", () => {
|
||
test("alias resolution is case/space-insensitive", () => {
|
||
expect(resolveAnalyte("Total Testosterone")?.key).toBe("testosterone_total");
|
||
expect(resolveAnalyte("HbA1c")?.key).toBe("hba1c");
|
||
expect(resolveAnalyte("definitely not a marker")).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("normalizeMarker", () => {
|
||
test("known analyte converts to canonical unit and registry panel", () => {
|
||
const n = normalizeMarker({ panel: "chemistry", name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true });
|
||
expect(n.analyteKey).toBe("glucose");
|
||
expect(n.panel).toBe("metabolic"); // registry panel wins for consistent grouping
|
||
expect(n.valueCanonical).toBeCloseTo(5.551, 2);
|
||
expect(n.canonicalUnit).toBe("mmol/L");
|
||
expect(n.value).toBe("100"); // raw preserved
|
||
expect(n.flagged).toBe(true);
|
||
});
|
||
test("unknown marker keeps raw fields, no canonical value", () => {
|
||
const n = normalizeMarker({ name: "Exotic Marker X", value: "1.2", unit: "u/L" });
|
||
expect(n.analyteKey).toBeNull();
|
||
expect(n.marker).toBe("Exotic Marker X");
|
||
expect(n.valueCanonical).toBeNull();
|
||
});
|
||
test("comparator values parse numerically", () => {
|
||
const n = normalizeMarker({ name: "hs-CRP", value: "<0.3", unit: "mg/L" });
|
||
expect(n.valueNum).toBeCloseTo(0.3);
|
||
expect(n.value).toBe("<0.3");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `bun test server/test/normalize.test.ts` — Expected: FAIL (modules missing).
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`server/src/lib/units.ts`:
|
||
```ts
|
||
// Lab unit conversion. Two transform kinds: dimensional scaling (prefix/volume,
|
||
// analyte-independent) and the mass<->mole bridge, which requires the analyte's
|
||
// molar mass. %, ratios, IU, and cell counts never bridge to mass or mole.
|
||
type Kind = "mass" | "mole" | "count" | "iu" | "percent" | "ratio" | "other";
|
||
|
||
const AMOUNT: Record<string, [Kind, number]> = {
|
||
g: ["mass", 1], mg: ["mass", 1e-3], ug: ["mass", 1e-6], mcg: ["mass", 1e-6],
|
||
"µg": ["mass", 1e-6], ng: ["mass", 1e-9], pg: ["mass", 1e-12],
|
||
mol: ["mole", 1], mmol: ["mole", 1e-3], umol: ["mole", 1e-6], "µmol": ["mole", 1e-6],
|
||
nmol: ["mole", 1e-9], pmol: ["mole", 1e-12],
|
||
cells: ["count", 1], k: ["count", 1e3], thousand: ["count", 1e3],
|
||
million: ["count", 1e6], "x10^9": ["count", 1e9], "x10^12": ["count", 1e12],
|
||
iu: ["iu", 1], miu: ["iu", 1e-3], uiu: ["iu", 1e-6], "µiu": ["iu", 1e-6], u: ["iu", 1],
|
||
};
|
||
const VOLUME: Record<string, number> = { l: 1, dl: 0.1, ml: 1e-3, ul: 1e-6, "µl": 1e-6 };
|
||
|
||
const clean = (u: string) => u.toLowerCase().replace(/\s+/g, "").replace("μ", "µ");
|
||
|
||
interface Parsed { kind: Kind; amountFactor: number; volumeFactor: number }
|
||
|
||
export function parseUnit(unit: string): Parsed | null {
|
||
const u = clean(unit);
|
||
if (u === "%") return { kind: "percent", amountFactor: 1, volumeFactor: 1 };
|
||
if (u === "" || u === "ratio" || u === "index") return { kind: "ratio", amountFactor: 1, volumeFactor: 1 };
|
||
const m = u.match(/^([a-zµ0-9^]+)\/([a-zµ0-9.]+)$/);
|
||
if (m) {
|
||
const amtEntry = AMOUNT[m[1]];
|
||
const volFactor = VOLUME[m[2]];
|
||
if (amtEntry && volFactor) return { kind: amtEntry[0], amountFactor: amtEntry[1], volumeFactor: volFactor };
|
||
}
|
||
return { kind: "other", amountFactor: 1, volumeFactor: 1 };
|
||
}
|
||
|
||
export function convert(value: number, fromUnit: string, toUnit: string, molarMass?: number | null): number | null {
|
||
const from = parseUnit(fromUnit);
|
||
const to = parseUnit(toUnit);
|
||
if (!from || !to) return null;
|
||
if (clean(fromUnit) === clean(toUnit)) return value;
|
||
if (from.kind === "other" || to.kind === "other") return null;
|
||
|
||
const fromBasePerL = (value * from.amountFactor) / from.volumeFactor;
|
||
let toBasePerL = fromBasePerL;
|
||
if (from.kind !== to.kind) {
|
||
const bridge = (from.kind === "mass" && to.kind === "mole") || (from.kind === "mole" && to.kind === "mass");
|
||
if (!bridge || !molarMass) return null;
|
||
toBasePerL = from.kind === "mass" ? fromBasePerL / molarMass : fromBasePerL * molarMass;
|
||
}
|
||
return (toBasePerL * to.volumeFactor) / to.amountFactor;
|
||
}
|
||
```
|
||
|
||
`server/src/lib/analytes.ts` — registry with `key, display, canonicalUnit, molarMass, panel, aliases`. Canonical units are SI/molar where labs split between conventions. Include at minimum these analytes (panels: iron, hormones, thyroid, lipids, metabolic, inflammation, liver, kidney, cbc, vitamins, electrolytes):
|
||
|
||
iron, ferritin, transferrin, tibc, transferrin_saturation; testosterone_total (ng/dL, 288.42), testosterone_free, shbg, dhea_s (µmol/L, 368.49), cortisol (nmol/L, 362.46), estradiol (pmol/L, 272.38), prolactin, fsh, lh, insulin (µIU/mL), igf1; tsh (mIU/L), free_t4 (pmol/L), free_t3 (pmol/L); cholesterol_total (mmol/L, 386.65), hdl (mmol/L, 386.65), ldl (mmol/L, 386.65), triglycerides (mmol/L, 885.4), non_hdl, apob (g/L), lipoprotein_a (nmol/L); glucose (mmol/L, 180.16), hba1c (%), uric_acid (mmol/L, 168.11); hs_crp (mg/L); alt (U/L), ast (U/L), ggt (U/L), alp (U/L), bilirubin_total (µmol/L, 584.66), albumin (g/L), total_protein (g/L); creatinine (µmol/L, 113.12), egfr (mL/min/1.73m2), urea (mmol/L, 60.06); hemoglobin (g/L), hematocrit (%), wbc (x10^9/L), rbc (x10^12/L), platelets (x10^9/L), mcv (fL — kind "other", no conversion), neutrophils (x10^9/L), lymphocytes (x10^9/L); vitamin_d (nmol/L, 400.64), vitamin_b12 (pmol/L, 1355.4), folate (nmol/L, 441.4); sodium (mmol/L), potassium (mmol/L), calcium (mmol/L, 40.08), magnesium (mmol/L, 24.31), zinc (µmol/L, 65.38).
|
||
|
||
Aliases: include common lab spellings per analyte (e.g. glucose: "glucose", "glucose fasting", "fasting glucose"; hba1c: "hba1c", "hemoglobin a1c", "haemoglobin a1c"; testosterone_total: "testosterone", "total testosterone", "testosterone total"; vitamin_d: "vitamin d", "25-oh vitamin d", "vitamin d 25-hydroxy", "25-hydroxyvitamin d"; etc. — one sensible alias set per analyte, lowercased).
|
||
|
||
```ts
|
||
export interface Analyte {
|
||
key: string;
|
||
display: string;
|
||
canonicalUnit: string;
|
||
molarMass: number | null;
|
||
panel: string;
|
||
aliases: string[];
|
||
}
|
||
export const ANALYTES: Analyte[] = [ /* registry as specified above */ ];
|
||
|
||
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9%]+/g, " ").trim();
|
||
const INDEX = new Map<string, Analyte>();
|
||
for (const a of ANALYTES) {
|
||
INDEX.set(norm(a.display), a);
|
||
for (const al of a.aliases) INDEX.set(norm(al), a);
|
||
}
|
||
export function resolveAnalyte(name: string): Analyte | null {
|
||
return INDEX.get(norm(name)) ?? null;
|
||
}
|
||
```
|
||
|
||
`server/src/lib/normalize.ts`:
|
||
```ts
|
||
import { resolveAnalyte } from "./analytes";
|
||
import { convert } from "./units";
|
||
|
||
export interface RawMarker {
|
||
panel?: string | null;
|
||
name: string;
|
||
value: string;
|
||
unit?: string | null;
|
||
referenceRange?: string | null;
|
||
flagged?: boolean;
|
||
}
|
||
|
||
export interface NormMarker {
|
||
panel: string;
|
||
name: string;
|
||
marker: string;
|
||
analyteKey: string | null;
|
||
value: string;
|
||
valueNum: number | null;
|
||
unit: string | null;
|
||
referenceRange: string | null;
|
||
flagged: boolean;
|
||
valueCanonical: number | null;
|
||
canonicalUnit: string | null;
|
||
}
|
||
|
||
const num = (v: string): number | null => {
|
||
const n = parseFloat(v.replace(/[<>≤≥]/g, "").trim());
|
||
return Number.isFinite(n) ? n : null;
|
||
};
|
||
|
||
export function normalizeMarker(raw: RawMarker): NormMarker {
|
||
const analyte = resolveAnalyte(raw.name);
|
||
const valueNum = num(raw.value);
|
||
const unit = raw.unit ?? null;
|
||
|
||
let valueCanonical: number | null = null;
|
||
let canonicalUnit: string | null = null;
|
||
if (analyte && valueNum !== null && unit) {
|
||
canonicalUnit = analyte.canonicalUnit;
|
||
valueCanonical = convert(valueNum, unit, analyte.canonicalUnit, analyte.molarMass);
|
||
if (valueCanonical === null && unit.toLowerCase().replace(/\s/g, "") === analyte.canonicalUnit.toLowerCase().replace(/\s/g, "")) {
|
||
valueCanonical = valueNum;
|
||
}
|
||
}
|
||
|
||
return {
|
||
// Registry panel wins for mapped analytes so a marker groups the same way
|
||
// no matter which section a particular lab filed it under.
|
||
panel: analyte?.panel ?? raw.panel ?? "other",
|
||
name: raw.name,
|
||
marker: analyte?.key ?? raw.name,
|
||
analyteKey: analyte?.key ?? null,
|
||
value: raw.value,
|
||
valueNum,
|
||
unit,
|
||
referenceRange: raw.referenceRange ?? null,
|
||
flagged: raw.flagged === true,
|
||
valueCanonical,
|
||
canonicalUnit,
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `bun test server/test/normalize.test.ts` — Expected: PASS (all cases).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(labs): unit conversion, analyte registry, marker normalization"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: LLM client (`chatJSON`)
|
||
|
||
**Files:**
|
||
- Create: `server/src/lib/llm.ts`
|
||
- Modify: `server/src/routes/settings.ts` (export `DEFAULTS`)
|
||
- Test: `server/test/llm.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces (consumed by Task 4, later by chat milestone): `chatJSON(deps: LlmDeps, opts: { system: string; user: string }): Promise<unknown>` where `LlmDeps = { db: Db; key: Buffer; fetchImpl?: typeof fetch }`. Reads `llm_base_url`/`llm_model` (falling back to exported `DEFAULTS`) and decrypted `llm_key` via `getSetting`. Sends OpenAI-compatible `POST {base}/chat/completions` with `response_format: { type: "json_object" }`; `Authorization: Bearer <key>` only when a key is set (Ollama needs none). Parses `choices[0].message.content` as JSON, stripping ```json fences if present. Throws `Error("llm_error: <detail>")` on non-2xx or unparseable content.
|
||
|
||
- [ ] **Step 1: Export DEFAULTS**
|
||
|
||
In `server/src/routes/settings.ts` change `const DEFAULTS = {...} as const;` to `export const DEFAULTS = {...} as const;`.
|
||
|
||
- [ ] **Step 2: Write the failing test**
|
||
|
||
`server/test/llm.test.ts`:
|
||
```ts
|
||
import { describe, expect, test } from "bun:test";
|
||
import { mkdtempSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import { openDb } from "../src/db";
|
||
import { settings } from "../src/db/schema";
|
||
import { encrypt, loadOrCreateKey } from "../src/lib/crypto";
|
||
import { chatJSON } from "../src/lib/llm";
|
||
|
||
function deps(fetchImpl: typeof fetch) {
|
||
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
||
const db = openDb(dir);
|
||
const key = loadOrCreateKey(dir);
|
||
return { db, key, fetchImpl };
|
||
}
|
||
|
||
describe("chatJSON", () => {
|
||
test("posts OpenAI-compatible request with bearer key and parses JSON content", async () => {
|
||
let captured: { url: string; init: RequestInit } | null = null;
|
||
const mock = (async (url: any, init: any) => {
|
||
captured = { url: String(url), init };
|
||
return new Response(JSON.stringify({ choices: [{ message: { content: '{"ok":1}' } }] }), { status: 200 });
|
||
}) as typeof fetch;
|
||
const d = deps(mock);
|
||
await d.db.insert(settings).values({ key: "llm_key", value: encrypt(d.key, "sk-test-1234") });
|
||
|
||
const out = await chatJSON(d, { system: "sys", user: "usr" });
|
||
expect(out).toEqual({ ok: 1 });
|
||
expect(captured!.url).toBe("https://openrouter.ai/api/v1/chat/completions");
|
||
const body = JSON.parse(String(captured!.init.body));
|
||
expect(body.response_format).toEqual({ type: "json_object" });
|
||
expect(body.messages).toEqual([{ role: "system", content: "sys" }, { role: "user", content: "usr" }]);
|
||
expect((captured!.init.headers as Record<string, string>).authorization).toBe("Bearer sk-test-1234");
|
||
});
|
||
|
||
test("no key → no auth header (Ollama)", async () => {
|
||
let headers: Record<string, string> = {};
|
||
const mock = (async (_: any, init: any) => {
|
||
headers = init.headers;
|
||
return new Response(JSON.stringify({ choices: [{ message: { content: "```json\n{\"a\":2}\n```" } }] }), { status: 200 });
|
||
}) as typeof fetch;
|
||
const out = await chatJSON(deps(mock), { system: "s", user: "u" });
|
||
expect(out).toEqual({ a: 2 }); // fenced JSON stripped
|
||
expect(headers.authorization).toBeUndefined();
|
||
});
|
||
|
||
test("non-2xx throws llm_error", async () => {
|
||
const mock = (async () => new Response("nope", { status: 401 })) as typeof fetch;
|
||
expect(chatJSON(deps(mock), { system: "s", user: "u" })).rejects.toThrow(/llm_error/);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run test to verify it fails**
|
||
|
||
Run: `bun test server/test/llm.test.ts` — Expected: FAIL.
|
||
|
||
- [ ] **Step 4: Implement**
|
||
|
||
`server/src/lib/llm.ts`:
|
||
```ts
|
||
import type { Db } from "../db";
|
||
import { DEFAULTS, getSetting } from "../routes/settings";
|
||
|
||
export type LlmDeps = { db: Db; key: Buffer; fetchImpl?: typeof fetch };
|
||
|
||
export async function chatJSON(deps: LlmDeps, opts: { system: string; user: string }): Promise<unknown> {
|
||
const f = deps.fetchImpl ?? fetch;
|
||
const base = (await getSetting(deps.db, "llm_base_url", deps.key)) ?? DEFAULTS.llm_base_url;
|
||
const model = (await getSetting(deps.db, "llm_model", deps.key)) ?? DEFAULTS.llm_model;
|
||
const apiKey = await getSetting(deps.db, "llm_key", deps.key);
|
||
|
||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
||
|
||
const res = await f(`${base.replace(/\/$/, "")}/chat/completions`, {
|
||
method: "POST",
|
||
headers,
|
||
body: JSON.stringify({
|
||
model,
|
||
response_format: { type: "json_object" },
|
||
messages: [
|
||
{ role: "system", content: opts.system },
|
||
{ role: "user", content: opts.user },
|
||
],
|
||
}),
|
||
});
|
||
if (!res.ok) throw new Error(`llm_error: provider returned ${res.status}`);
|
||
|
||
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
||
const content = data.choices?.[0]?.message?.content;
|
||
if (!content) throw new Error("llm_error: empty completion");
|
||
const stripped = content.replace(/^\s*```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "");
|
||
try {
|
||
return JSON.parse(stripped);
|
||
} catch {
|
||
throw new Error("llm_error: completion was not valid JSON");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests, commit**
|
||
|
||
Run: `bun test server/test/llm.test.ts` then `bun test server` — Expected: all PASS.
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(llm): OpenAI-compatible chatJSON helper with injectable fetch"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: PDF upload + LLM extraction → draft
|
||
|
||
**Files:**
|
||
- Create: `server/src/lib/pdf.ts`, `server/src/lib/extract.ts`, `server/src/routes/labs.ts`
|
||
- Modify: `server/src/app.ts` (mount `labsRoutes(deps)`), `server/package.json` (add `unpdf`), `shared/src/types.ts`
|
||
- Test: `server/test/extract.test.ts`, `server/test/labs-upload.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `pdfToText(data: Uint8Array): Promise<string>` (unpdf; merged pages)
|
||
- `extractFromText(deps: LlmDeps, text: string): Promise<ExtractedDraft>` — `ExtractedDraft = { collectedDate: string | null; labName: string | null; markers: ExtractedMarker[] }`
|
||
- `POST /api/labs/upload` (multipart field `file`) → 201 `{ id }`; rejects non-PDF (400 `not_a_pdf`), > 15 MB (400 `too_large`), missing LLM outcome recorded on the draft (`status` stays "pending", `error` set, markers empty — never a 500).
|
||
- `GET /api/labs/drafts` → `{ drafts: { id, filename, status, error, markerCount, createdAt }[] }`
|
||
- shared Zod: `ExtractedMarker = { panel: string|null, name: string, value: string, unit: string|null, referenceRange: string|null, flagged: boolean }` (all nullable fields defaulted), `ExtractedDraft`.
|
||
- Consumes: `chatJSON` (T3), `labDrafts` (T1).
|
||
|
||
- [ ] **Step 1: Add shared schemas**
|
||
|
||
Append to `shared/src/types.ts`:
|
||
```ts
|
||
export const ExtractedMarker = z.object({
|
||
panel: z.string().nullable().default(null),
|
||
name: z.string().min(1),
|
||
value: z.string().min(1),
|
||
unit: z.string().nullable().default(null),
|
||
referenceRange: z.string().nullable().default(null),
|
||
flagged: z.boolean().default(false),
|
||
});
|
||
export type ExtractedMarker = z.infer<typeof ExtractedMarker>;
|
||
|
||
export const ExtractedDraft = z.object({
|
||
collectedDate: z.string().nullable().default(null),
|
||
labName: z.string().nullable().default(null),
|
||
markers: z.array(ExtractedMarker).default([]),
|
||
});
|
||
export type ExtractedDraft = z.infer<typeof ExtractedDraft>;
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing extraction test**
|
||
|
||
`server/test/extract.test.ts`:
|
||
```ts
|
||
import { describe, expect, test } from "bun:test";
|
||
import { mkdtempSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import { openDb } from "../src/db";
|
||
import { loadOrCreateKey } from "../src/lib/crypto";
|
||
import { extractFromText } from "../src/lib/extract";
|
||
|
||
const FIXTURE_TEXT = `
|
||
Sample Diagnostics — Final Report
|
||
Collected: 15 Jan 2026
|
||
CHEMISTRY
|
||
Glucose (Fasting) 100 mg/dL (70-99) H
|
||
LIPIDS
|
||
LDL Cholesterol 3.1 mmol/L (<3.4)
|
||
`;
|
||
|
||
describe("extractFromText", () => {
|
||
test("passes text to LLM and validates the draft shape", async () => {
|
||
let userPrompt = "";
|
||
const mock = (async (_: any, init: any) => {
|
||
userPrompt = JSON.parse(String(init.body)).messages[1].content;
|
||
return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({
|
||
collectedDate: "2026-01-15",
|
||
labName: "Sample Diagnostics",
|
||
markers: [
|
||
{ panel: "chemistry", name: "Glucose (Fasting)", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true },
|
||
{ panel: "lipids", name: "LDL Cholesterol", value: "3.1", unit: "mmol/L", referenceRange: "<3.4", flagged: false },
|
||
],
|
||
}) } }] }), { status: 200 });
|
||
}) as typeof fetch;
|
||
|
||
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
||
const draft = await extractFromText({ db: openDb(dir), key: loadOrCreateKey(dir), fetchImpl: mock }, FIXTURE_TEXT);
|
||
expect(userPrompt).toContain("Glucose (Fasting)");
|
||
expect(draft.markers).toHaveLength(2);
|
||
expect(draft.collectedDate).toBe("2026-01-15");
|
||
expect(draft.markers[0].flagged).toBe(true);
|
||
});
|
||
|
||
test("malformed LLM output → llm_error, not a crash", async () => {
|
||
const mock = (async () => new Response(JSON.stringify({ choices: [{ message: { content: '{"markers": "not an array"}' } }] }), { status: 200 })) as typeof fetch;
|
||
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
||
expect(extractFromText({ db: openDb(dir), key: loadOrCreateKey(dir), fetchImpl: mock }, "x")).rejects.toThrow(/llm_error/);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run to verify it fails, then implement extract + pdf**
|
||
|
||
Run: `bun test server/test/extract.test.ts` — FAIL.
|
||
|
||
`cd server && bun add unpdf`
|
||
|
||
`server/src/lib/pdf.ts`:
|
||
```ts
|
||
import { extractText, getDocumentProxy } from "unpdf";
|
||
|
||
export async function pdfToText(data: Uint8Array): Promise<string> {
|
||
const doc = await getDocumentProxy(data);
|
||
const { text } = await extractText(doc, { mergePages: true });
|
||
return text;
|
||
}
|
||
```
|
||
|
||
`server/src/lib/extract.ts`:
|
||
```ts
|
||
import { ExtractedDraft } from "@helios/shared";
|
||
import { chatJSON, type LlmDeps } from "./llm";
|
||
|
||
const SYSTEM = `You extract structured blood-test results from the raw text of a lab report.
|
||
Return ONLY a JSON object: {"collectedDate": "YYYY-MM-DD" or null, "labName": string or null, "markers": [{"panel": string or null, "name": string, "value": string, "unit": string or null, "referenceRange": string or null, "flagged": boolean}]}.
|
||
Rules: copy names, values, units, and reference ranges EXACTLY as printed — do not convert units or round values. "value" is always a string (keep comparators like "<0.3"). Set "flagged" true only when the report marks the result abnormal (H, L, *, bold, out-of-range annotation). Use the specimen collection date, not the report date. Skip commentary, footers, and reference-only rows with no result.`;
|
||
|
||
const MAX_CHARS = 40_000;
|
||
|
||
export async function extractFromText(deps: LlmDeps, text: string): Promise<ExtractedDraft> {
|
||
const out = await chatJSON(deps, { system: SYSTEM, user: text.slice(0, MAX_CHARS) });
|
||
const parsed = ExtractedDraft.safeParse(out);
|
||
if (!parsed.success) throw new Error("llm_error: draft failed schema validation");
|
||
return parsed.data;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Write the failing upload-route test**
|
||
|
||
`server/test/labs-upload.test.ts`:
|
||
```ts
|
||
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 { loadOrCreateKey } from "../src/lib/crypto";
|
||
|
||
// Minimal one-page PDF with a text object — enough for unpdf to open and read.
|
||
const TINY_PDF = `%PDF-1.4
|
||
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj
|
||
4 0 obj<</Length 60>>stream
|
||
BT /F1 12 Tf 72 720 Td (Glucose 100 mg/dL 70-99 H) Tj ET
|
||
endstream
|
||
endobj
|
||
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
|
||
trailer<</Root 1 0 R>>`;
|
||
|
||
async function authedApp(fetchImpl: typeof fetch) {
|
||
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
||
const app = createApp({ db: openDb(dir), key: loadOrCreateKey(dir), dataDir: dir, llmFetch: fetchImpl });
|
||
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")!;
|
||
return { app, cookie };
|
||
}
|
||
|
||
const llmOk = (async () => new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({
|
||
collectedDate: "2026-01-15", labName: "Sample Diagnostics",
|
||
markers: [{ panel: null, name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true }],
|
||
}) } }] }), { status: 200 })) as typeof fetch;
|
||
|
||
describe("labs upload", () => {
|
||
test("PDF upload → pending draft with extracted markers", async () => {
|
||
const { app, cookie } = await authedApp(llmOk);
|
||
const fd = new FormData();
|
||
fd.append("file", new File([TINY_PDF], "results.pdf", { type: "application/pdf" }));
|
||
const up = await app.request("/api/labs/upload", { method: "POST", headers: { cookie }, body: fd });
|
||
expect(up.status).toBe(201);
|
||
const { id } = await up.json();
|
||
|
||
const list = await (await app.request("/api/labs/drafts", { headers: { cookie } })).json();
|
||
expect(list.drafts).toHaveLength(1);
|
||
expect(list.drafts[0].id).toBe(id);
|
||
expect(list.drafts[0].status).toBe("pending");
|
||
expect(list.drafts[0].markerCount).toBe(1);
|
||
});
|
||
|
||
test("non-PDF rejected", async () => {
|
||
const { app, cookie } = await authedApp(llmOk);
|
||
const fd = new FormData();
|
||
fd.append("file", new File(["hi"], "notes.txt", { type: "text/plain" }));
|
||
const up = await app.request("/api/labs/upload", { method: "POST", headers: { cookie }, body: fd });
|
||
expect(up.status).toBe(400);
|
||
});
|
||
|
||
test("LLM failure → draft stored with error, not a 500", async () => {
|
||
const llmDown = (async () => new Response("x", { status: 500 })) as typeof fetch;
|
||
const { app, cookie } = await authedApp(llmDown);
|
||
const fd = new FormData();
|
||
fd.append("file", new File([TINY_PDF], "r.pdf", { type: "application/pdf" }));
|
||
const up = await app.request("/api/labs/upload", { method: "POST", headers: { cookie }, body: fd });
|
||
expect(up.status).toBe(201);
|
||
const list = await (await app.request("/api/labs/drafts", { headers: { cookie } })).json();
|
||
expect(list.drafts[0].error).toMatch(/llm_error/);
|
||
expect(list.drafts[0].markerCount).toBe(0);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: Run to verify it fails, then implement route + deps threading**
|
||
|
||
`server/src/routes/labs.ts`:
|
||
```ts
|
||
import { desc, eq } from "drizzle-orm";
|
||
import { Hono } from "hono";
|
||
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 { extractFromText } from "../lib/extract";
|
||
import { pdfToText } from "../lib/pdf";
|
||
|
||
export type LabsDeps = { db: Db; key: Buffer; dataDir: string; llmFetch?: typeof fetch };
|
||
|
||
const MAX_UPLOAD = 15 * 1024 * 1024;
|
||
|
||
export function labsRoutes(deps: LabsDeps) {
|
||
const app = new Hono();
|
||
|
||
app.post("/labs/upload", async (c) => {
|
||
const body = await c.req.parseBody();
|
||
const file = body.file;
|
||
if (!(file instanceof File)) return c.json({ error: "file field required" }, 400);
|
||
if (!file.name.toLowerCase().endsWith(".pdf") && file.type !== "application/pdf") {
|
||
return c.json({ error: "not_a_pdf" }, 400);
|
||
}
|
||
if (file.size > MAX_UPLOAD) return c.json({ error: "too_large" }, 400);
|
||
|
||
const id = randomUUID();
|
||
const uploadsDir = join(deps.dataDir, "uploads");
|
||
mkdirSync(uploadsDir, { recursive: true });
|
||
const filePath = join(uploadsDir, `${id}.pdf`);
|
||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||
await Bun.write(filePath, bytes);
|
||
|
||
// Extraction failures land on the draft row so the user sees them in the
|
||
// review UI instead of the upload 500ing.
|
||
let extracted: string | null = null;
|
||
let error: string | null = null;
|
||
try {
|
||
const text = await pdfToText(bytes);
|
||
if (text.trim().length < 20) throw new Error("pdf_error: no text layer (scanned PDFs are not supported yet)");
|
||
const draft = await extractFromText({ db: deps.db, key: deps.key, fetchImpl: deps.llmFetch }, text);
|
||
extracted = JSON.stringify(draft);
|
||
} catch (e) {
|
||
error = e instanceof Error ? e.message : "extraction failed";
|
||
}
|
||
|
||
await deps.db.insert(labDrafts).values({
|
||
id, filename: file.name, filePath, status: "pending", extracted, error, createdAt: Date.now(),
|
||
});
|
||
return c.json({ id }, 201);
|
||
});
|
||
|
||
app.get("/labs/drafts", async (c) => {
|
||
const rows = await deps.db.select().from(labDrafts).orderBy(desc(labDrafts.createdAt));
|
||
return c.json({
|
||
drafts: rows.map((r) => ({
|
||
id: r.id,
|
||
filename: r.filename,
|
||
status: r.status,
|
||
error: r.error,
|
||
markerCount: r.extracted ? (JSON.parse(r.extracted).markers?.length ?? 0) : 0,
|
||
createdAt: r.createdAt,
|
||
})),
|
||
});
|
||
});
|
||
|
||
return app;
|
||
}
|
||
```
|
||
|
||
In `server/src/app.ts`: extend `Deps` to `{ db: Db; key: Buffer; dataDir: string; llmFetch?: typeof fetch }`, mount `app.route("/api", labsRoutes(deps));` next to the other mounts, and update `server/src/index.ts` to pass `dataDir`. Update the two existing test helpers that call `createApp` (`auth.test.ts`, `settings.test.ts`) to pass `dataDir: dir`.
|
||
|
||
- [ ] **Step 6: Run tests, commit**
|
||
|
||
Run: `bun test server` — Expected: all PASS (including the tiny-PDF path through unpdf).
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(labs): PDF upload, text extraction, LLM draft pipeline"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Draft review + confirm
|
||
|
||
**Files:**
|
||
- Modify: `server/src/routes/labs.ts`, `shared/src/types.ts`
|
||
- Test: `server/test/labs-confirm.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `GET /api/labs/drafts/:id` → `{ id, filename, status, error, draft: ExtractedDraft | null }`
|
||
- `POST /api/labs/drafts/:id/confirm` body `ConfirmDraftBody = { collectedDate: string (YYYY-MM-DD), labName: string | null, markers: ExtractedMarker[] (min 1) }` → normalizes every marker via `normalizeMarker`, inserts one `lab_draws` row + `biomarkers` rows in a transaction, marks draft `confirmed`, returns 201 `{ drawId }`. 409 if draft not `pending`. 404 unknown id.
|
||
- `POST /api/labs/drafts/:id/discard` → marks `discarded`, 204.
|
||
- shared Zod: `ConfirmDraftBody`.
|
||
- Consumes: `normalizeMarker` (T2), tables (T1).
|
||
|
||
- [ ] **Step 1: Add shared schema**
|
||
|
||
Append to `shared/src/types.ts`:
|
||
```ts
|
||
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<typeof ConfirmDraftBody>;
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing test**
|
||
|
||
`server/test/labs-confirm.test.ts`:
|
||
```ts
|
||
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);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run to verify it fails, then implement**
|
||
|
||
Add to `labsRoutes` in `server/src/routes/labs.ts`:
|
||
```ts
|
||
// additional imports at top: labDraws, biomarkers from ../db/schema;
|
||
// ConfirmDraftBody from "@helios/shared"; normalizeMarker from "../lib/normalize";
|
||
|
||
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);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, commit**
|
||
|
||
Run: `bun test server` — all PASS.
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(labs): draft review, confirm with normalization, discard"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Labs query APIs (draws, detail, history) + original-PDF serving
|
||
|
||
**Files:**
|
||
- Modify: `server/src/routes/labs.ts`
|
||
- Test: `server/test/labs-query.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces (consumed by web UI):
|
||
- `GET /api/labs/draws` → `{ draws: { id, collectedAt, labName, markerCount, flaggedCount }[] }` sorted newest first.
|
||
- `GET /api/labs/draws/:id` → `{ id, collectedAt, labName, panels: { panel: string, markers: BiomarkerRow[] }[] }` where `BiomarkerRow` mirrors the table (flagged as boolean). Panels sorted alphabetically, markers by name. 404 unknown.
|
||
- `GET /api/labs/markers/:key/history` → `{ key, display, canonicalUnit, points: { drawId, collectedAt, value: number }[] }` using `valueCanonical` (skip nulls), sorted by collectedAt ascending; `display`/`canonicalUnit` from the registry, 404 for unknown analyte key.
|
||
- `GET /api/labs/drafts/:id/file` → the stored PDF (Content-Type application/pdf, Content-Disposition inline), 404 if missing.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`server/test/labs-query.test.ts`:
|
||
```ts
|
||
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);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails, then implement**
|
||
|
||
Add to `labsRoutes` (imports: `ANALYTES` or a `getAnalyte(key)` helper from `../lib/analytes` — add `export function getAnalyte(key: string): Analyte | null` there; `existsSync` from node:fs; `asc` from drizzle-orm):
|
||
```ts
|
||
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<string, typeof rows>();
|
||
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, "_")}"`,
|
||
},
|
||
});
|
||
});
|
||
```
|
||
|
||
In `server/src/lib/analytes.ts` add:
|
||
```ts
|
||
const BY_KEY = new Map(ANALYTES.map((a) => [a.key, a]));
|
||
export function getAnalyte(key: string): Analyte | null {
|
||
return BY_KEY.get(key) ?? null;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests, commit**
|
||
|
||
Run: `bun test server` — all PASS.
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(labs): draw list/detail, marker history, PDF file serving"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Web UI — Labs upload, review, dashboard
|
||
|
||
**Files:**
|
||
- Create: `web/src/pages/Labs.tsx`, `web/src/pages/LabDraft.tsx`, `web/src/pages/LabDraw.tsx`, `web/src/components/Sparkline.tsx`
|
||
- Modify: `web/src/App.tsx` (routes + nav), `web/src/api.ts`, `web/src/styles.css`
|
||
|
||
**Interfaces:**
|
||
- Consumes every Task 4–6 endpoint. Routes: `/labs` (draw list + pending drafts + upload), `/labs/draft/:id` (review/edit/confirm), `/labs/draw/:id` (panel-grouped detail; tapping a mapped marker shows its history sparkline inline).
|
||
|
||
- [ ] **Step 1: API client additions**
|
||
|
||
Append to the `api` object in `web/src/api.ts` (and add a small `upload` helper):
|
||
```ts
|
||
async function uploadFile<T>(path: string, file: File): Promise<T> {
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
const res = await fetch(path, { method: "POST", body: fd });
|
||
if (!res.ok) throw new Error((await res.json().catch(() => ({ error: res.statusText }))).error ?? res.statusText);
|
||
return res.json();
|
||
}
|
||
|
||
// inside `api`:
|
||
uploadLab: (file: File) => uploadFile<{ id: string }>("/api/labs/upload", file),
|
||
labDrafts: () => req<{ drafts: { id: string; filename: string; status: string; error: string | null; markerCount: number; createdAt: number }[] }>("/api/labs/drafts"),
|
||
labDraft: (id: string) => req<{ id: string; filename: string; status: string; error: string | null; draft: ExtractedDraft | null }>(`/api/labs/drafts/${id}`),
|
||
confirmDraft: (id: string, body: ConfirmDraftBody) => req<{ drawId: string }>(`/api/labs/drafts/${id}/confirm`, { method: "POST", body: JSON.stringify(body) }),
|
||
discardDraft: (id: string) => req<void>(`/api/labs/drafts/${id}/discard`, { method: "POST" }),
|
||
labDraws: () => req<{ draws: { id: string; collectedAt: string; labName: string | null; markerCount: number; flaggedCount: number }[] }>("/api/labs/draws"),
|
||
labDraw: (id: string) => req<LabDrawDetail>(`/api/labs/draws/${id}`),
|
||
markerHistory: (key: string) => req<{ key: string; display: string; canonicalUnit: string; points: { drawId: string; collectedAt: string; value: number }[] }>(`/api/labs/markers/${key}/history`),
|
||
```
|
||
Import `ExtractedDraft`, `ConfirmDraftBody` types from `@helios/shared`. Define locally:
|
||
```ts
|
||
export interface LabMarkerRow {
|
||
id: number; drawId: string; panel: string; name: string; marker: string; analyteKey: string | null;
|
||
value: string; valueNum: number | null; unit: string | null; referenceRange: string | null;
|
||
flagged: boolean; valueCanonical: number | null; canonicalUnit: string | null;
|
||
}
|
||
export interface LabDrawDetail { id: string; collectedAt: string; labName: string | null; panels: { panel: string; markers: LabMarkerRow[] }[] }
|
||
```
|
||
|
||
- [ ] **Step 2: Sparkline**
|
||
|
||
`web/src/components/Sparkline.tsx`:
|
||
```tsx
|
||
export function Sparkline({ points, width = 220, height = 48 }: { points: number[]; width?: number; height?: number }) {
|
||
if (points.length < 2) return null;
|
||
const min = Math.min(...points);
|
||
const max = Math.max(...points);
|
||
const span = max - min || 1;
|
||
const step = width / (points.length - 1);
|
||
const path = points.map((v, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(height - 4 - ((v - min) / span) * (height - 8)).toFixed(1)}`).join(" ");
|
||
return (
|
||
<svg width={width} height={height} className="sparkline" aria-hidden>
|
||
<path d={path} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
|
||
<circle cx={(points.length - 1) * step} cy={height - 4 - ((points[points.length - 1] - min) / span) * (height - 8)} r={3} fill="currentColor" />
|
||
</svg>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Pages**
|
||
|
||
`web/src/pages/Labs.tsx` — draw list + pending drafts + upload:
|
||
```tsx
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { Link, useNavigate } from "react-router";
|
||
import { api } from "../api";
|
||
|
||
export function Labs() {
|
||
const nav = useNavigate();
|
||
const fileInput = useRef<HTMLInputElement>(null);
|
||
const [draws, setDraws] = useState<Awaited<ReturnType<typeof api.labDraws>>["draws"]>([]);
|
||
const [drafts, setDrafts] = useState<Awaited<ReturnType<typeof api.labDrafts>>["drafts"]>([]);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const refresh = useCallback(() => {
|
||
api.labDraws().then((r) => setDraws(r.draws)).catch((e) => setError(e.message));
|
||
api.labDrafts().then((r) => setDrafts(r.drafts.filter((d) => d.status === "pending"))).catch(() => {});
|
||
}, []);
|
||
useEffect(refresh, [refresh]);
|
||
|
||
const onFile = async (file: File) => {
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const { id } = await api.uploadLab(file);
|
||
nav(`/labs/draft/${id}`);
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "upload failed");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<section>
|
||
<div className="row-between">
|
||
<h2>Labs</h2>
|
||
<button disabled={busy} onClick={() => fileInput.current?.click()}>
|
||
{busy ? "Extracting…" : "Upload lab PDF"}
|
||
</button>
|
||
<input ref={fileInput} type="file" accept="application/pdf" hidden
|
||
onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
|
||
</div>
|
||
{error && <p className="error">{error}</p>}
|
||
<p className="hint">Upload a blood-test PDF. The AI extracts the values; nothing is saved until you review and confirm them.</p>
|
||
|
||
{drafts.length > 0 && (
|
||
<>
|
||
<h3>Awaiting review</h3>
|
||
<ul className="list">
|
||
{drafts.map((d) => (
|
||
<li key={d.id}>
|
||
<Link to={`/labs/draft/${d.id}`}>{d.filename}</Link>
|
||
{d.error ? <span className="error"> extraction failed</span> : <span className="muted"> {d.markerCount} markers</span>}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</>
|
||
)}
|
||
|
||
<h3>Draws</h3>
|
||
{draws.length === 0 && <p className="muted">No confirmed draws yet.</p>}
|
||
<ul className="list">
|
||
{draws.map((d) => (
|
||
<li key={d.id}>
|
||
<Link to={`/labs/draw/${d.id}`}>{d.collectedAt}</Link>
|
||
<span className="muted"> {d.labName ?? ""} · {d.markerCount} markers</span>
|
||
{d.flaggedCount > 0 && <span className="flag"> {d.flaggedCount} flagged</span>}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
`web/src/pages/LabDraft.tsx` — editable review table:
|
||
```tsx
|
||
import { useEffect, useState } from "react";
|
||
import { useNavigate, useParams } from "react-router";
|
||
import type { ExtractedMarker } from "@helios/shared";
|
||
import { api } from "../api";
|
||
|
||
export function LabDraft() {
|
||
const { id } = useParams<{ id: string }>();
|
||
const nav = useNavigate();
|
||
const [meta, setMeta] = useState<{ filename: string; status: string; error: string | null } | null>(null);
|
||
const [collectedDate, setCollectedDate] = useState("");
|
||
const [labName, setLabName] = useState("");
|
||
const [markers, setMarkers] = useState<ExtractedMarker[]>([]);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!id) return;
|
||
api.labDraft(id).then((r) => {
|
||
setMeta({ filename: r.filename, status: r.status, error: r.error });
|
||
if (r.draft) {
|
||
setCollectedDate(r.draft.collectedDate ?? "");
|
||
setLabName(r.draft.labName ?? "");
|
||
setMarkers(r.draft.markers);
|
||
}
|
||
}).catch((e) => setError(e.message));
|
||
}, [id]);
|
||
|
||
const edit = (i: number, patch: Partial<ExtractedMarker>) =>
|
||
setMarkers((m) => m.map((row, j) => (j === i ? { ...row, ...patch } : row)));
|
||
const remove = (i: number) => setMarkers((m) => m.filter((_, j) => j !== i));
|
||
const addRow = () => setMarkers((m) => [...m, { panel: null, name: "", value: "", unit: null, referenceRange: null, flagged: false }]);
|
||
|
||
const confirm = async () => {
|
||
if (!id) return;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const { drawId } = await api.confirmDraft(id, { collectedDate, labName: labName || null, markers });
|
||
nav(`/labs/draw/${drawId}`);
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "confirm failed");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
if (!meta) return <p>Loading…</p>;
|
||
return (
|
||
<section>
|
||
<h2>Review: {meta.filename}</h2>
|
||
{meta.error && <p className="error">Extraction failed: {meta.error}. You can still enter values manually below.</p>}
|
||
{meta.status !== "pending" && <p className="muted">This draft is already {meta.status}.</p>}
|
||
<p className="hint">Check every value against the PDF (<a href={`/api/labs/drafts/${id}/file`} target="_blank" rel="noreferrer">open original</a>). Nothing is saved until you confirm.</p>
|
||
|
||
<div className="row">
|
||
<label>Collected <input type="date" value={collectedDate} onChange={(e) => setCollectedDate(e.target.value)} /></label>
|
||
<label>Lab <input value={labName} onChange={(e) => setLabName(e.target.value)} placeholder="lab name" /></label>
|
||
</div>
|
||
|
||
<table className="review-table">
|
||
<thead><tr><th>Name</th><th>Value</th><th>Unit</th><th>Range</th><th>Flag</th><th /></tr></thead>
|
||
<tbody>
|
||
{markers.map((m, i) => (
|
||
<tr key={i}>
|
||
<td><input value={m.name} onChange={(e) => edit(i, { name: e.target.value })} /></td>
|
||
<td><input value={m.value} onChange={(e) => edit(i, { value: e.target.value })} /></td>
|
||
<td><input value={m.unit ?? ""} onChange={(e) => edit(i, { unit: e.target.value || null })} /></td>
|
||
<td><input value={m.referenceRange ?? ""} onChange={(e) => edit(i, { referenceRange: e.target.value || null })} /></td>
|
||
<td><input type="checkbox" checked={m.flagged} onChange={(e) => edit(i, { flagged: e.target.checked })} /></td>
|
||
<td><button className="link" onClick={() => remove(i)}>✕</button></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
<button className="link" onClick={addRow}>+ add marker</button>
|
||
|
||
{error && <p className="error">{error}</p>}
|
||
{meta.status === "pending" && (
|
||
<div className="row">
|
||
<button disabled={busy || !collectedDate || markers.length === 0} onClick={confirm}>
|
||
{busy ? "Saving…" : `Confirm ${markers.length} markers`}
|
||
</button>
|
||
<button className="danger" onClick={() => id && api.discardDraft(id).then(() => nav("/labs"))}>Discard</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
`web/src/pages/LabDraw.tsx` — panel-grouped detail + history on tap:
|
||
```tsx
|
||
import { useEffect, useState } from "react";
|
||
import { useParams } from "react-router";
|
||
import { api, type LabDrawDetail } from "../api";
|
||
import { Sparkline } from "../components/Sparkline";
|
||
|
||
export function LabDraw() {
|
||
const { id } = useParams<{ id: string }>();
|
||
const [detail, setDetail] = useState<LabDrawDetail | null>(null);
|
||
const [history, setHistory] = useState<Record<string, { display: string; canonicalUnit: string; points: { collectedAt: string; value: number }[] }>>({});
|
||
|
||
useEffect(() => {
|
||
if (id) api.labDraw(id).then(setDetail).catch(() => setDetail(null));
|
||
}, [id]);
|
||
|
||
const toggleHistory = async (key: string) => {
|
||
if (history[key]) {
|
||
setHistory((h) => { const { [key]: _, ...rest } = h; return rest; });
|
||
return;
|
||
}
|
||
const h = await api.markerHistory(key);
|
||
setHistory((prev) => ({ ...prev, [key]: h }));
|
||
};
|
||
|
||
if (!detail) return <p>Loading…</p>;
|
||
return (
|
||
<section>
|
||
<h2>{detail.collectedAt}{detail.labName ? ` — ${detail.labName}` : ""}</h2>
|
||
{detail.panels.map((p) => (
|
||
<div key={p.panel}>
|
||
<h3 className="panel-title">{p.panel}</h3>
|
||
<table className="lab-table">
|
||
<tbody>
|
||
{p.markers.map((m) => (
|
||
<>
|
||
<tr key={m.id} className={m.flagged ? "flagged" : undefined}>
|
||
<td>
|
||
{m.analyteKey
|
||
? <button className="link" onClick={() => toggleHistory(m.analyteKey!)}>{m.name}</button>
|
||
: m.name}
|
||
</td>
|
||
<td className="num">{m.value} {m.unit ?? ""}</td>
|
||
<td className="muted">{m.referenceRange ?? ""}</td>
|
||
<td>{m.flagged ? <span className="flag">●</span> : null}</td>
|
||
</tr>
|
||
{m.analyteKey && history[m.analyteKey] && (
|
||
<tr key={`${m.id}-h`}>
|
||
<td colSpan={4}>
|
||
<Sparkline points={history[m.analyteKey].points.map((pt) => pt.value)} />
|
||
<span className="muted"> {history[m.analyteKey].points.length} draws, {history[m.analyteKey].canonicalUnit}</span>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
))}
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Wire routes + nav + styles**
|
||
|
||
In `web/src/App.tsx`: add `<Link to="/labs">Labs</Link>` to nav; add routes:
|
||
```tsx
|
||
<Route path="/labs" element={<Labs />} />
|
||
<Route path="/labs/draft/:id" element={<LabDraft />} />
|
||
<Route path="/labs/draw/:id" element={<LabDraw />} />
|
||
```
|
||
|
||
Append to `web/src/styles.css`:
|
||
```css
|
||
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||
.row-between { display: flex; justify-content: space-between; align-items: center; }
|
||
.list { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||
.muted { color: color-mix(in srgb, currentColor 55%, transparent); }
|
||
.hint { color: color-mix(in srgb, currentColor 55%, transparent); font-size: 0.9em; }
|
||
.flag { color: crimson; }
|
||
.flagged td { color: crimson; }
|
||
.review-table, .lab-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||
.review-table td, .review-table th, .lab-table td { padding: 6px 8px; text-align: left; }
|
||
.lab-table tr { border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent); }
|
||
.review-table input:not([type="checkbox"]) { width: 100%; box-sizing: border-box; }
|
||
.num { font-variant-numeric: tabular-nums; }
|
||
.panel-title { text-transform: capitalize; margin-bottom: 4px; }
|
||
button.link { background: none; border: none; color: inherit; text-decoration: underline; cursor: pointer; padding: 0; font: inherit; }
|
||
button.danger { background: none; border: 1px solid crimson; color: crimson; }
|
||
.sparkline { color: #4a9eda; display: block; margin: 4px 0; }
|
||
```
|
||
|
||
- [ ] **Step 5: Verify + manual smoke**
|
||
|
||
Run: `bun run typecheck && bun run build:web && bun test server` — all green.
|
||
Manual smoke (optional if no OpenRouter key set): `bun server/src/index.ts` + `cd web && bunx vite`, visit /labs, upload any PDF — without a configured key the draft shows the extraction error path, which is itself a feature under test.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(web): labs upload, draft review, draw dashboard with history sparklines"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Security hardening shipping with uploads — CSP + headers
|
||
|
||
**Files:**
|
||
- Modify: `server/src/app.ts`
|
||
- Test: `server/test/security-headers.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: every non-API response carries `Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'` plus `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. API responses get `X-Content-Type-Options: nosniff`. (Spec Security: "CSP on the SPA; uploads served with safe content-type handling" — lands now because uploads land now.)
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`server/test/security-headers.test.ts`:
|
||
```ts
|
||
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/db-app-helper"; // NO — use the same inline helper as other tests:
|
||
```
|
||
Use the same pattern as the other tests (no new helper file):
|
||
```ts
|
||
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 { loadOrCreateKey } from "../src/lib/crypto";
|
||
|
||
function makeApp() {
|
||
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
||
return createApp({ db: openDb(dir), key: loadOrCreateKey(dir), dataDir: dir });
|
||
}
|
||
|
||
describe("security headers", () => {
|
||
test("API responses: nosniff", async () => {
|
||
const res = await makeApp().request("/api/health");
|
||
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
||
});
|
||
test("non-API responses: CSP + nosniff + referrer policy", async () => {
|
||
const res = await makeApp().request("/anything");
|
||
expect(res.headers.get("content-security-policy")).toContain("default-src 'self'");
|
||
expect(res.headers.get("content-security-policy")).toContain("frame-ancestors 'none'");
|
||
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
||
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
|
||
});
|
||
});
|
||
```
|
||
(The first snippet above is a deliberate strike-through of a wrong approach; implement only the second.)
|
||
|
||
- [ ] **Step 2: Run to verify it fails, then implement**
|
||
|
||
In `server/src/app.ts`, register FIRST (before the API middleware):
|
||
```ts
|
||
const CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'";
|
||
app.use("*", async (c, next) => {
|
||
await next();
|
||
c.header("x-content-type-options", "nosniff");
|
||
if (!c.req.path.startsWith("/api/")) {
|
||
c.header("content-security-policy", CSP);
|
||
c.header("referrer-policy", "no-referrer");
|
||
}
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run all gates, commit, push**
|
||
|
||
Run: `bun run typecheck && bun test server && bun run build:web` — all green.
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(security): CSP and hardening headers with upload serving"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review (done at write time)
|
||
|
||
- **Spec coverage:** Labs section of the spec fully covered — upload → LLM extraction → review/confirm gate → normalized biomarkers with canonical units/flags → dashboard + history. CSP item lands with uploads as the final review triaged. Scanned-PDF OCR explicitly out (error path tells the user).
|
||
- **Placeholders:** none; every step has code. Task 2's registry lists exact analytes/units/molar masses to implement rather than full literal rows — the values are all specified; transcription is the task.
|
||
- **Type consistency:** `createApp` deps grow to `{ db, key, dataDir, llmFetch? }` in Task 4 and stay that shape; `flagged` is integer in DB, boolean at API/UI edges (converted in draw detail + confirm); `ExtractedMarker`/`ExtractedDraft`/`ConfirmDraftBody` shared across Tasks 4/5/7.
|
||
- **Known risks:** unpdf under Bun (verified working in similar setups; Task 4's tiny-PDF test proves it in CI); `Date.now()` in routes is fine (server code, not workflow).
|