diff --git a/bun.lock b/bun.lock index 802fcd1..db2ad2b 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "@helios/shared": "workspace:*", "drizzle-orm": "^0.44.0", "hono": "^4.6.0", + "unpdf": "^1.8.1", "zod": "^3.24.0", }, "devDependencies": { @@ -322,6 +323,8 @@ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "unpdf": ["unpdf@1.8.1", "", { "peerDependencies": { "@napi-rs/canvas": "^0.1.69 || ^1.0.0" }, "optionalPeers": ["@napi-rs/canvas"] }, "sha512-xkURhy2SoGpOIH0a1gLHNkASPIQYonadDJs2AQwPEfUakafeD9EA1WTWWsaR++gfTCXJpV27W7tU1nXuk82UKQ=="], + "update-browserslist-db": ["update-browserslist-db@1.3.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ=="], "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], diff --git a/docs/superpowers/plans/2026-08-18-m2-labs.md b/docs/superpowers/plans/2026-08-18-m2-labs.md index 43d2e93..9b2f58b 100644 --- a/docs/superpowers/plans/2026-08-18-m2-labs.md +++ b/docs/superpowers/plans/2026-08-18-m2-labs.md @@ -260,7 +260,7 @@ export function convert(value: number, fromUnit: string, toUnit: string, molarMa `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, 384.5), 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). +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). diff --git a/server/drizzle/0001_labs.sql b/server/drizzle/0001_labs.sql new file mode 100644 index 0000000..ae1ed3e --- /dev/null +++ b/server/drizzle/0001_labs.sql @@ -0,0 +1,33 @@ +CREATE TABLE `biomarkers` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `draw_id` text NOT NULL, + `panel` text NOT NULL, + `name` text NOT NULL, + `marker` text NOT NULL, + `analyte_key` text, + `value` text NOT NULL, + `value_num` real, + `unit` text, + `reference_range` text, + `flagged` integer DEFAULT 0 NOT NULL, + `value_canonical` real, + `canonical_unit` text +); +--> statement-breakpoint +CREATE TABLE `lab_drafts` ( + `id` text PRIMARY KEY NOT NULL, + `filename` text NOT NULL, + `file_path` text NOT NULL, + `status` text NOT NULL, + `extracted` text, + `error` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `lab_draws` ( + `id` text PRIMARY KEY NOT NULL, + `collected_at` text NOT NULL, + `lab_name` text, + `draft_id` text, + `created_at` integer NOT NULL +); diff --git a/server/drizzle/meta/0001_snapshot.json b/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..3678871 --- /dev/null +++ b/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,279 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8d0cf0e5-668d-4526-a0cb-5a264d813be3", + "prevId": "383d7bd7-3ebf-456e-8c5c-f39cebab14c4", + "tables": { + "biomarkers": { + "name": "biomarkers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "draw_id": { + "name": "draw_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "panel": { + "name": "panel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "marker": { + "name": "marker", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "analyte_key": { + "name": "analyte_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_num": { + "name": "value_num", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_range": { + "name": "reference_range", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "flagged": { + "name": "flagged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "value_canonical": { + "name": "value_canonical", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "canonical_unit": { + "name": "canonical_unit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "lab_drafts": { + "name": "lab_drafts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "extracted": { + "name": "extracted", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "lab_draws": { + "name": "lab_draws", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collected_at": { + "name": "collected_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lab_name": { + "name": "lab_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "draft_id": { + "name": "draft_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 99f3b33..5424167 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1787000672259, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1787005028172, + "tag": "0001_labs", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/package.json b/server/package.json index 1958201..9d3da56 100644 --- a/server/package.json +++ b/server/package.json @@ -5,7 +5,11 @@ "@helios/shared": "workspace:*", "drizzle-orm": "^0.44.0", "hono": "^4.6.0", + "unpdf": "^1.8.1", "zod": "^3.24.0" }, - "devDependencies": { "bun-types": "latest", "drizzle-kit": "^0.31.0" } + "devDependencies": { + "bun-types": "latest", + "drizzle-kit": "^0.31.0" + } } diff --git a/server/src/app.ts b/server/src/app.ts index 81f913f..89682e0 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -3,13 +3,23 @@ import { getCookie } from "hono/cookie"; import { serveStatic } from "hono/bun"; import type { Db } from "./db"; import { authRoutes, isAuthenticated } from "./routes/auth"; +import { labsRoutes } from "./routes/labs"; import { settingsRoutes } from "./routes/settings"; -export type Deps = { db: Db; key: Buffer }; +export type Deps = { db: Db; key: Buffer; dataDir: string; llmFetch?: typeof fetch }; const PUBLIC = new Set(["/api/health", "/api/me", "/api/setup", "/api/login"]); +const CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'"; export function createApp(deps: Deps) { const app = new Hono(); + 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"); + } + }); app.get("/api/health", (c) => c.json({ ok: true })); app.use("/api/*", async (c, next) => { if (PUBLIC.has(c.req.path)) return next(); @@ -20,6 +30,7 @@ export function createApp(deps: Deps) { }); app.route("/api", authRoutes({ db: deps.db })); app.route("/api", settingsRoutes(deps)); + app.route("/api", labsRoutes(deps)); // Later route groups (connectors, chat) mount here. app.all("/api/*", (c) => c.json({ error: "not found" }, 404)); app.use("/*", serveStatic({ root: "./web/dist" })); diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index e27527b..178221e 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -1,4 +1,4 @@ -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const settings = sqliteTable("settings", { key: text("key").primaryKey(), @@ -10,3 +10,37 @@ export const sessions = sqliteTable("sessions", { createdAt: integer("created_at").notNull(), expiresAt: integer("expires_at").notNull(), }); + +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"), +}); diff --git a/server/src/index.ts b/server/src/index.ts index a589232..64cfba9 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,6 +3,6 @@ import { loadOrCreateKey } from "./lib/crypto"; import { createApp } from "./app"; const dataDir = process.env.DATA_DIR ?? "./data"; -const app = createApp({ db: openDb(dataDir), key: loadOrCreateKey(dataDir) }); +const app = createApp({ db: openDb(dataDir), key: loadOrCreateKey(dataDir), dataDir }); export default { port: Number(process.env.PORT ?? 3000), fetch: app.fetch }; diff --git a/server/src/lib/analytes.ts b/server/src/lib/analytes.ts new file mode 100644 index 0000000..8a917cd --- /dev/null +++ b/server/src/lib/analytes.ts @@ -0,0 +1,519 @@ +// Analyte registry: canonical units, molar masses, and panel groupings for +// lab markers. `molarMass` is only set when a mass<->mole bridge is actually +// used in practice for that analyte (see units.ts); it stays null for +// analytes reported purely by mass, by count, as a percentage/ratio, in +// enzyme-activity units (U/L, IU/L), or in units the converter treats as +// "other" (e.g. eGFR's mL/min/1.73m2, MCV's fL) — those never convert. +export interface Analyte { + key: string; + display: string; + canonicalUnit: string; + molarMass: number | null; + panel: string; + aliases: string[]; +} + +export const ANALYTES: Analyte[] = [ + // --- iron --- + { + key: "iron", + display: "Iron", + canonicalUnit: "µmol/L", + molarMass: 55.845, + panel: "iron", + aliases: ["iron", "serum iron", "fe"], + }, + { + key: "ferritin", + display: "Ferritin", + canonicalUnit: "µg/L", + molarMass: null, + panel: "iron", + aliases: ["ferritin", "serum ferritin"], + }, + { + key: "transferrin", + display: "Transferrin", + canonicalUnit: "g/L", + molarMass: null, + panel: "iron", + aliases: ["transferrin"], + }, + { + key: "tibc", + display: "Total Iron Binding Capacity", + canonicalUnit: "µmol/L", + molarMass: 55.845, + panel: "iron", + aliases: ["tibc", "total iron binding capacity", "total iron-binding capacity"], + }, + { + key: "transferrin_saturation", + display: "Transferrin Saturation", + canonicalUnit: "%", + molarMass: null, + panel: "iron", + aliases: ["transferrin saturation", "iron saturation", "tsat", "% saturation", "percent saturation"], + }, + + // --- hormones --- + { + key: "testosterone_total", + display: "Total Testosterone", + canonicalUnit: "ng/dL", + molarMass: 288.42, + panel: "hormones", + aliases: ["testosterone", "total testosterone", "testosterone total", "testosterone, total"], + }, + { + key: "testosterone_free", + display: "Free Testosterone", + canonicalUnit: "pg/mL", + molarMass: 288.42, + panel: "hormones", + aliases: ["free testosterone", "testosterone free", "testosterone, free"], + }, + { + key: "shbg", + display: "SHBG", + canonicalUnit: "nmol/L", + molarMass: null, + panel: "hormones", + aliases: ["shbg", "sex hormone binding globulin", "sex hormone-binding globulin"], + }, + { + key: "dhea_s", + display: "DHEA-S", + canonicalUnit: "µmol/L", + molarMass: 368.49, + panel: "hormones", + aliases: ["dhea-s", "dhea s", "dheas", "dehydroepiandrosterone sulfate"], + }, + { + key: "cortisol", + display: "Cortisol", + canonicalUnit: "nmol/L", + molarMass: 362.46, + panel: "hormones", + aliases: ["cortisol", "cortisol am", "cortisol, am", "morning cortisol"], + }, + { + key: "estradiol", + display: "Estradiol", + canonicalUnit: "pmol/L", + molarMass: 272.38, + panel: "hormones", + aliases: ["estradiol", "oestradiol", "e2"], + }, + { + key: "prolactin", + display: "Prolactin", + canonicalUnit: "µg/L", + molarMass: null, + panel: "hormones", + aliases: ["prolactin", "prl"], + }, + { + key: "fsh", + display: "FSH", + canonicalUnit: "IU/L", + molarMass: null, + panel: "hormones", + aliases: ["fsh", "follicle stimulating hormone", "follicle-stimulating hormone"], + }, + { + key: "lh", + display: "LH", + canonicalUnit: "IU/L", + molarMass: null, + panel: "hormones", + aliases: ["lh", "luteinizing hormone"], + }, + { + key: "insulin", + display: "Insulin", + canonicalUnit: "µIU/mL", + molarMass: null, + panel: "hormones", + aliases: ["insulin", "fasting insulin", "insulin fasting", "insulin, fasting"], + }, + { + key: "igf1", + display: "IGF-1", + canonicalUnit: "µg/L", + molarMass: null, + panel: "hormones", + aliases: ["igf-1", "igf1", "igf 1", "insulin-like growth factor 1", "insulin like growth factor 1"], + }, + + // --- thyroid --- + { + key: "tsh", + display: "TSH", + canonicalUnit: "mIU/L", + molarMass: null, + panel: "thyroid", + aliases: ["tsh", "thyroid stimulating hormone", "thyroid-stimulating hormone"], + }, + { + key: "free_t4", + display: "Free T4", + canonicalUnit: "pmol/L", + molarMass: null, + panel: "thyroid", + aliases: ["free t4", "ft4", "free thyroxine", "t4 free", "thyroxine free", "thyroxine, free"], + }, + { + key: "free_t3", + display: "Free T3", + canonicalUnit: "pmol/L", + molarMass: null, + panel: "thyroid", + aliases: ["free t3", "ft3", "free triiodothyronine", "t3 free", "triiodothyronine free", "triiodothyronine, free"], + }, + + // --- lipids --- + { + key: "cholesterol_total", + display: "Total Cholesterol", + canonicalUnit: "mmol/L", + molarMass: 386.65, + panel: "lipids", + aliases: ["cholesterol", "total cholesterol", "cholesterol total", "cholesterol, total"], + }, + { + key: "hdl", + display: "HDL Cholesterol", + canonicalUnit: "mmol/L", + molarMass: 386.65, + panel: "lipids", + aliases: ["hdl", "hdl cholesterol", "hdl-c", "cholesterol hdl"], + }, + { + key: "ldl", + display: "LDL Cholesterol", + canonicalUnit: "mmol/L", + molarMass: 386.65, + panel: "lipids", + aliases: ["ldl", "ldl cholesterol", "ldl-c", "cholesterol ldl", "ldl calculated", "ldl, calculated"], + }, + { + key: "triglycerides", + display: "Triglycerides", + canonicalUnit: "mmol/L", + molarMass: 885.4, + panel: "lipids", + aliases: ["triglycerides", "trig", "trigs", "tg"], + }, + { + key: "non_hdl", + display: "Non-HDL Cholesterol", + canonicalUnit: "mmol/L", + molarMass: 386.65, + panel: "lipids", + aliases: ["non-hdl", "non hdl", "non-hdl cholesterol", "non hdl cholesterol"], + }, + { + key: "apob", + display: "Apolipoprotein B", + canonicalUnit: "g/L", + molarMass: null, + panel: "lipids", + aliases: ["apob", "apo b", "apolipoprotein b"], + }, + { + key: "lipoprotein_a", + display: "Lipoprotein(a)", + canonicalUnit: "nmol/L", + molarMass: null, + panel: "lipids", + aliases: ["lipoprotein a", "lipoprotein(a)", "lp(a)", "lpa"], + }, + + // --- metabolic --- + { + key: "glucose", + display: "Glucose", + canonicalUnit: "mmol/L", + molarMass: 180.16, + panel: "metabolic", + aliases: ["glucose", "glucose fasting", "fasting glucose", "blood glucose"], + }, + { + key: "hba1c", + display: "HbA1c", + canonicalUnit: "%", + molarMass: null, + panel: "metabolic", + aliases: ["hba1c", "hemoglobin a1c", "haemoglobin a1c", "a1c", "glycated hemoglobin", "glycated haemoglobin"], + }, + { + key: "uric_acid", + display: "Uric Acid", + canonicalUnit: "mmol/L", + molarMass: 168.11, + panel: "metabolic", + aliases: ["uric acid", "urate"], + }, + + // --- inflammation --- + { + key: "hs_crp", + display: "hs-CRP", + canonicalUnit: "mg/L", + molarMass: null, + panel: "inflammation", + aliases: ["hs-crp", "hscrp", "hs crp", "high sensitivity crp", "high-sensitivity crp", "c-reactive protein", "crp"], + }, + + // --- liver --- + { + key: "alt", + display: "ALT", + canonicalUnit: "U/L", + molarMass: null, + panel: "liver", + aliases: ["alt", "sgpt", "alanine aminotransferase", "alanine transaminase"], + }, + { + key: "ast", + display: "AST", + canonicalUnit: "U/L", + molarMass: null, + panel: "liver", + aliases: ["ast", "sgot", "aspartate aminotransferase", "aspartate transaminase"], + }, + { + key: "ggt", + display: "GGT", + canonicalUnit: "U/L", + molarMass: null, + panel: "liver", + aliases: ["ggt", "gamma gt", "gamma-glutamyl transferase", "gamma glutamyl transferase"], + }, + { + key: "alp", + display: "ALP", + canonicalUnit: "U/L", + molarMass: null, + panel: "liver", + aliases: ["alp", "alkaline phosphatase"], + }, + { + key: "bilirubin_total", + display: "Total Bilirubin", + canonicalUnit: "µmol/L", + molarMass: 584.66, + panel: "liver", + aliases: ["bilirubin", "total bilirubin", "bilirubin total", "bilirubin, total"], + }, + { + key: "albumin", + display: "Albumin", + canonicalUnit: "g/L", + molarMass: null, + panel: "liver", + aliases: ["albumin"], + }, + { + key: "total_protein", + display: "Total Protein", + canonicalUnit: "g/L", + molarMass: null, + panel: "liver", + aliases: ["total protein", "protein total", "protein, total"], + }, + + // --- kidney --- + { + key: "creatinine", + display: "Creatinine", + canonicalUnit: "µmol/L", + molarMass: 113.12, + panel: "kidney", + aliases: ["creatinine", "serum creatinine"], + }, + { + key: "egfr", + display: "eGFR", + canonicalUnit: "mL/min/1.73m2", + molarMass: null, + panel: "kidney", + aliases: ["egfr", "estimated gfr", "estimated glomerular filtration rate", "gfr estimated", "gfr, estimated"], + }, + { + key: "urea", + display: "Urea", + canonicalUnit: "mmol/L", + molarMass: 60.06, + panel: "kidney", + // Deliberately no "bun"/"blood urea nitrogen" aliases: BUN mg/dL -> urea + // mmol/L needs the nitrogen divisor (2N = 28.01 g/mol), not urea's own + // molar mass (60.06). Mapping BUN here would silently under-convert by + // ~2.14x. BUN rows stay unmapped (raw value preserved) until a proper + // BUN analyte with its own divisor is added. + aliases: ["urea"], + }, + + // --- cbc --- + { + key: "hemoglobin", + display: "Hemoglobin", + canonicalUnit: "g/L", + molarMass: null, + panel: "cbc", + aliases: ["hemoglobin", "haemoglobin", "hgb", "hb"], + }, + { + key: "hematocrit", + display: "Hematocrit", + canonicalUnit: "%", + molarMass: null, + panel: "cbc", + aliases: ["hematocrit", "haematocrit", "hct", "pcv", "packed cell volume"], + }, + { + key: "wbc", + display: "White Blood Cells", + canonicalUnit: "x10^9/L", + molarMass: null, + panel: "cbc", + aliases: ["wbc", "white blood cells", "white blood cell count", "white cell count", "leukocytes", "leucocytes"], + }, + { + key: "rbc", + display: "Red Blood Cells", + canonicalUnit: "x10^12/L", + molarMass: null, + panel: "cbc", + aliases: ["rbc", "red blood cells", "red blood cell count", "red cell count", "erythrocytes"], + }, + { + key: "platelets", + display: "Platelets", + canonicalUnit: "x10^9/L", + molarMass: null, + panel: "cbc", + aliases: ["platelets", "platelet count", "plt"], + }, + { + key: "mcv", + display: "MCV", + canonicalUnit: "fL", + molarMass: null, + panel: "cbc", + aliases: ["mcv", "mean corpuscular volume", "mean cell volume"], + }, + { + key: "neutrophils", + display: "Neutrophils", + canonicalUnit: "x10^9/L", + molarMass: null, + panel: "cbc", + aliases: ["neutrophils", "neutrophil count", "neutrophils absolute", "absolute neutrophils", "anc"], + }, + { + key: "lymphocytes", + display: "Lymphocytes", + canonicalUnit: "x10^9/L", + molarMass: null, + panel: "cbc", + aliases: ["lymphocytes", "lymphocyte count", "lymphocytes absolute", "absolute lymphocytes", "alc"], + }, + + // --- vitamins --- + { + key: "vitamin_d", + display: "Vitamin D", + canonicalUnit: "nmol/L", + molarMass: 400.64, + panel: "vitamins", + aliases: [ + "vitamin d", + "vitamin d3", + "25-oh vitamin d", + "vitamin d 25-hydroxy", + "25-hydroxyvitamin d", + "25-hydroxy vitamin d", + "25 oh vitamin d", + ], + }, + { + key: "vitamin_b12", + display: "Vitamin B12", + canonicalUnit: "pmol/L", + molarMass: 1355.4, + panel: "vitamins", + aliases: ["vitamin b12", "b12", "cobalamin"], + }, + { + key: "folate", + display: "Folate", + canonicalUnit: "nmol/L", + molarMass: 441.4, + panel: "vitamins", + aliases: ["folate", "folic acid", "vitamin b9"], + }, + + // --- electrolytes --- + { + key: "sodium", + display: "Sodium", + canonicalUnit: "mmol/L", + molarMass: null, + panel: "electrolytes", + aliases: ["sodium", "na"], + }, + { + key: "potassium", + display: "Potassium", + canonicalUnit: "mmol/L", + molarMass: null, + panel: "electrolytes", + aliases: ["potassium", "k"], + }, + { + key: "calcium", + display: "Calcium", + canonicalUnit: "mmol/L", + molarMass: 40.08, + panel: "electrolytes", + aliases: ["calcium", "calcium total", "total calcium", "ca"], + }, + { + key: "magnesium", + display: "Magnesium", + canonicalUnit: "mmol/L", + molarMass: 24.31, + panel: "electrolytes", + aliases: ["magnesium", "mg"], + }, + { + key: "zinc", + display: "Zinc", + canonicalUnit: "µmol/L", + molarMass: 65.38, + panel: "electrolytes", + aliases: ["zinc", "zn"], + }, +]; + +// Resolution is alias/display lookup only — normalized to lowercase with +// punctuation collapsed to spaces so "Total Testosterone", "total-testosterone", +// and "TOTAL TESTOSTERONE" all hit the same registry entry. +const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9%]+/g, " ").trim(); +const INDEX = new Map(); +for (const a of ANALYTES) { + INDEX.set(norm(a.display), a); + 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/lib/extract.ts b/server/src/lib/extract.ts new file mode 100644 index 0000000..6a3c91e --- /dev/null +++ b/server/src/lib/extract.ts @@ -0,0 +1,15 @@ +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 { + 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; +} diff --git a/server/src/lib/llm.ts b/server/src/lib/llm.ts new file mode 100644 index 0000000..a3f1cb0 --- /dev/null +++ b/server/src/lib/llm.ts @@ -0,0 +1,38 @@ +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 { + 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 = { "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"); + } +} diff --git a/server/src/lib/normalize.ts b/server/src/lib/normalize.ts new file mode 100644 index 0000000..eb12484 --- /dev/null +++ b/server/src/lib/normalize.ts @@ -0,0 +1,80 @@ +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 THOUSANDS_GROUPED = /^\d{1,3}(,\d{3})+(\.\d+)?$/; +const PLAIN_NUMBER = /^-?\d+(\.\d+)?$/; + +// Strict numeric parsing: parseFloat alone stops at the first non-numeric +// character, so "1,200" silently became 1 (a 1000x error) and "5,5" (a +// European decimal) silently became 5. Instead: strip comparators/whitespace, +// then only accept (a) US thousands-grouping, comma-stripped, or (b) a plain +// number that spans the ENTIRE remaining string. Anything else — including +// ambiguous "5,5" — returns null so no canonical value is computed rather +// than a silently wrong one. +const num = (v: string): number | null => { + const stripped = v.replace(/[<>≤≥\s]/g, ""); + if (THOUSANDS_GROUPED.test(stripped)) { + const n = parseFloat(stripped.replace(/,/g, "")); + return Number.isFinite(n) ? n : null; + } + if (PLAIN_NUMBER.test(stripped)) { + const n = parseFloat(stripped); + return Number.isFinite(n) ? n : null; + } + return 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, + }; +} diff --git a/server/src/lib/pdf.ts b/server/src/lib/pdf.ts new file mode 100644 index 0000000..cf48d54 --- /dev/null +++ b/server/src/lib/pdf.ts @@ -0,0 +1,7 @@ +import { extractText, getDocumentProxy } from "unpdf"; + +export async function pdfToText(data: Uint8Array): Promise { + const doc = await getDocumentProxy(data); + const { text } = await extractText(doc, { mergePages: true }); + return text; +} diff --git a/server/src/lib/units.ts b/server/src/lib/units.ts new file mode 100644 index 0000000..7a1e5a1 --- /dev/null +++ b/server/src/lib/units.ts @@ -0,0 +1,49 @@ +// 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 = { + 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 = { 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; +} diff --git a/server/src/routes/labs.ts b/server/src/routes/labs.ts new file mode 100644 index 0000000..e552a94 --- /dev/null +++ b/server/src/routes/labs.ts @@ -0,0 +1,189 @@ +import type { Changes } from "bun:sqlite"; +import { and, desc, eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { randomUUID } from "node:crypto"; +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 }; + +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, + })), + }); + }); + + 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, + })); + try { + deps.db.transaction((tx) => { + // Guarded status transition (pending -> confirmed) inside the same + // synchronous transaction as the inserts below, so two concurrent + // confirms of the same draft can't both pass the earlier status + // check (before the request body was even read) and double-insert. + // Only the request that actually flips the row gets to write rows. + // drizzle-orm's bun-sqlite types pin TRunResult to `void`, but at + // runtime bun:sqlite's Statement.run() actually returns a + // `{ changes, lastInsertRowid }` Changes object — confirmed in + // node_modules/bun-types/sqlite.d.ts. Cast to the real runtime type. + const updated = tx.update(labDrafts).set({ status: "confirmed" }) + .where(and(eq(labDrafts.id, row.id), eq(labDrafts.status, "pending"))).run() as unknown as Changes; + if (updated.changes === 0) throw new Error("draft_not_pending"); + + tx.insert(labDraws).values({ + id: drawId, collectedAt: body.data.collectedDate, labName: body.data.labName, + draftId: row.id, createdAt: Date.now(), + }).run(); + for (const n of norm) { + 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, + }).run(); + } + }); + } catch (e) { + if (e instanceof Error && e.message === "draft_not_pending") { + return c.json({ error: "draft is not pending" }, 409); + } + throw e; + } + 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); + }); + + 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/src/routes/settings.ts b/server/src/routes/settings.ts index 081abb7..00872a0 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -5,7 +5,7 @@ import type { Db } from "../db"; import { settings } from "../db/schema"; import { decrypt, encrypt, mask } from "../lib/crypto"; -const DEFAULTS = { +export const DEFAULTS = { llm_base_url: "https://openrouter.ai/api/v1", llm_model: "anthropic/claude-sonnet-4.5", } as const; diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index a0e3f55..2f4cf2c 100644 --- a/server/test/auth.test.ts +++ b/server/test/auth.test.ts @@ -8,7 +8,7 @@ import { loadOrCreateKey } from "../src/lib/crypto"; function makeApp() { const dir = mkdtempSync(join(tmpdir(), "helios-")); - return createApp({ db: openDb(dir), key: loadOrCreateKey(dir) }); + return createApp({ db: openDb(dir), key: loadOrCreateKey(dir), dataDir: dir }); } const json = (body: unknown) => ({ method: "POST", @@ -54,7 +54,7 @@ describe("auth", () => { // The global window applies to everyone, including a request with the correct password. const blocked = await app.request("/api/login", json({ password: "hunter2hunter2" })); expect(blocked.status).toBe(429); - }); + }, 30000); // 10 sequential argon2id verifies can exceed the 5s default timeout test("unknown /api/* returns 404 when authed, 401 when unauthed", async () => { const app = makeApp(); diff --git a/server/test/db-labs.test.ts b/server/test/db-labs.test.ts new file mode 100644 index 0000000..5aa613f --- /dev/null +++ b/server/test/db-labs.test.ts @@ -0,0 +1,21 @@ +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"); + }); +}); diff --git a/server/test/extract.test.ts b/server/test/extract.test.ts new file mode 100644 index 0000000..3118e57 --- /dev/null +++ b/server/test/extract.test.ts @@ -0,0 +1,46 @@ +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 unknown as typeof fetch; + const dir = mkdtempSync(join(tmpdir(), "helios-")); + await expect(extractFromText({ db: openDb(dir), key: loadOrCreateKey(dir), fetchImpl: mock }, "x")).rejects.toThrow(/llm_error/); + }); +}); diff --git a/server/test/labs-confirm.test.ts b/server/test/labs-confirm.test.ts new file mode 100644 index 0000000..a126b54 --- /dev/null +++ b/server/test/labs-confirm.test.ts @@ -0,0 +1,112 @@ +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, labDraws } 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); + + // Second (rejected) confirm must not have inserted a second draw or any + // extra biomarker rows — exactly one draw, one set of biomarkers. + const drawsAfter = await db.select().from(labDraws); + expect(drawsAfter).toHaveLength(1); + const rowsAfter = await db.select().from(biomarkers); + expect(rowsAfter).toHaveLength(2); + }); + + 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); + }); + + test("sync transaction rolls back on mid-loop failure", () => { + const dir = mkdtempSync(join(tmpdir(), "helios-")); + const db = openDb(dir); + + expect(() => + db.transaction((tx) => { + tx.insert(labDraws).values({ id: "dX", collectedAt: "2026-01-01", labName: null, draftId: null, createdAt: 1 }).run(); + tx.insert(biomarkers).values({ drawId: "dX", panel: "p", name: "n", marker: "m", analyteKey: null, value: "1", valueNum: 1, unit: null, referenceRange: null, flagged: 0, valueCanonical: null, canonicalUnit: null }).run(); + throw new Error("boom"); + }), + ).toThrow("boom"); + + // Verify both tables are empty after rollback + const drawRows = db.select().from(labDraws).all(); + const bioRows = db.select().from(biomarkers).all(); + expect(drawRows).toHaveLength(0); + expect(bioRows).toHaveLength(0); + }); +}); 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); + }); +}); diff --git a/server/test/labs-upload.test.ts b/server/test/labs-upload.test.ts new file mode 100644 index 0000000..12f69cf --- /dev/null +++ b/server/test/labs-upload.test.ts @@ -0,0 +1,70 @@ +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<>endobj +2 0 obj<>endobj +3 0 obj<>>>>>endobj +4 0 obj<>stream +BT /F1 12 Tf 72 720 Td (Glucose 100 mg/dL 70-99 H) Tj ET +endstream +endobj +5 0 obj<>endobj +trailer<>`; + +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 unknown 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 unknown 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); + }); +}); diff --git a/server/test/llm.test.ts b/server/test/llm.test.ts new file mode 100644 index 0000000..eeea086 --- /dev/null +++ b/server/test/llm.test.ts @@ -0,0 +1,51 @@ +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).authorization).toBe("Bearer sk-test-1234"); + }); + + test("no key → no auth header (Ollama)", async () => { + let headers: Record = {}; + 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 unknown as typeof fetch; + await expect(chatJSON(deps(mock), { system: "s", user: "u" })).rejects.toThrow(/llm_error/); + }); +}); diff --git a/server/test/normalize.test.ts b/server/test/normalize.test.ts new file mode 100644 index 0000000..fe13987 --- /dev/null +++ b/server/test/normalize.test.ts @@ -0,0 +1,81 @@ +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"); + }); + test("BUN is not mapped onto urea (needs N-divisor, not molar mass)", () => { + expect(resolveAnalyte("BUN")).toBeNull(); + expect(resolveAnalyte("blood urea nitrogen")).toBeNull(); + expect(resolveAnalyte("urea")?.key).toBe("urea"); + }); + test("DHEA-S µg/dL converts with corrected molar mass", () => { + const n = normalizeMarker({ name: "DHEA-S", value: "250", unit: "ug/dL" }); + expect(n.valueCanonical).toBeCloseTo(6.78, 1); // 250 * 0.02713 + }); + test("thousands-grouped value parses as 1200, not 1 (parseFloat truncation bug)", () => { + const n = normalizeMarker({ name: "Vitamin B12", value: "1,200", unit: "pg/mL" }); + expect(n.valueNum).toBe(1200); + expect(n.analyteKey).toBe("vitamin_b12"); + expect(n.valueCanonical).toBeCloseTo(885.35, 1); + expect(n.canonicalUnit).toBe("pmol/L"); + }); + test("ambiguous European-decimal-looking value is left unmapped, not silently wrong", () => { + const n = normalizeMarker({ name: "Glucose", value: "5,5", unit: "mg/dL" }); + expect(n.valueNum).toBeNull(); + expect(n.valueCanonical).toBeNull(); + expect(n.value).toBe("5,5"); // raw value preserved + }); + test("comparator value still parses after strict-parsing rewrite", () => { + const n = normalizeMarker({ name: "hs-CRP", value: "<0.3", unit: "mg/L" }); + expect(n.valueNum).toBeCloseTo(0.3); + }); +}); diff --git a/server/test/security-headers.test.ts b/server/test/security-headers.test.ts new file mode 100644 index 0000000..d9c9d13 --- /dev/null +++ b/server/test/security-headers.test.ts @@ -0,0 +1,26 @@ +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"); + }); +}); diff --git a/server/test/settings.test.ts b/server/test/settings.test.ts index 56832fb..dbc4b45 100644 --- a/server/test/settings.test.ts +++ b/server/test/settings.test.ts @@ -11,7 +11,7 @@ import { loadOrCreateKey } from "../src/lib/crypto"; async function authedApp() { const dir = mkdtempSync(join(tmpdir(), "helios-")); const db = openDb(dir); - const app = createApp({ db, key: loadOrCreateKey(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")!; diff --git a/shared/src/types.ts b/shared/src/types.ts index 1557441..9654240 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -20,3 +20,27 @@ export const SettingsUpdate = z.object({ llmKey: z.string().min(1).optional(), }); export type SettingsUpdate = z.infer; + +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; + +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; + +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; diff --git a/web/src/App.tsx b/web/src/App.tsx index 7ff7936..210f885 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,9 @@ import { api } from "./api"; import { Gate } from "./pages/Gate"; import { Settings } from "./pages/Settings"; import { Today } from "./pages/Today"; +import { Labs } from "./pages/Labs"; +import { LabDraft } from "./pages/LabDraft"; +import { LabDraw } from "./pages/LabDraw"; export function App() { const [me, setMe] = useState(null); @@ -18,11 +21,15 @@ export function App() {
} /> + } /> + } /> + } /> } />
diff --git a/web/src/api.ts b/web/src/api.ts index 82acad3..81f99a5 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,4 +1,4 @@ -import type { MeResponse, SettingsResponse, SettingsUpdate } from "@helios/shared"; +import type { ConfirmDraftBody, ExtractedDraft, ExtractedMarker, MeResponse, SettingsResponse, SettingsUpdate } from "@helios/shared"; async function req(path: string, init?: RequestInit): Promise { const res = await fetch(path, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); @@ -6,6 +6,24 @@ async function req(path: string, init?: RequestInit): Promise { return res.status === 204 ? (undefined as T) : res.json(); } +async function uploadFile(path: string, file: File): Promise { + 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(); +} + +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[] }[]; +} + export const api = { me: () => req("/api/me"), setup: (password: string) => req("/api/setup", { method: "POST", body: JSON.stringify({ password }) }), @@ -13,4 +31,12 @@ export const api = { logout: () => req("/api/logout", { method: "POST" }), getSettings: () => req("/api/settings"), putSettings: (body: SettingsUpdate) => req("/api/settings", { method: "PUT", body: JSON.stringify(body) }), + 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(`/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(`/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`), }; diff --git a/web/src/components/Sparkline.tsx b/web/src/components/Sparkline.tsx new file mode 100644 index 0000000..604f4f1 --- /dev/null +++ b/web/src/components/Sparkline.tsx @@ -0,0 +1,14 @@ +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 ( + + + + + ); +} diff --git a/web/src/pages/LabDraft.tsx b/web/src/pages/LabDraft.tsx new file mode 100644 index 0000000..83a2940 --- /dev/null +++ b/web/src/pages/LabDraft.tsx @@ -0,0 +1,99 @@ +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([]); + const [error, setError] = useState(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) => + 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

Loading…

; + return ( +
+

Review: {meta.filename}

+ {meta.error &&

Extraction failed: {meta.error}. You can still enter values manually below.

} + {meta.status !== "pending" &&

This draft is already {meta.status}.

} +

Check every value against the PDF (open original). Nothing is saved until you confirm.

+ +
+ + +
+ + + + + {markers.map((m, i) => ( + + + + + + + + + ))} + +
NameValueUnitRangeFlag
edit(i, { name: e.target.value })} /> edit(i, { value: e.target.value })} /> edit(i, { unit: e.target.value || null })} /> edit(i, { referenceRange: e.target.value || null })} /> edit(i, { flagged: e.target.checked })} />
+ + + {error &&

{error}

} + {meta.status === "pending" && ( +
+ + +
+ )} +
+ ); +} diff --git a/web/src/pages/LabDraw.tsx b/web/src/pages/LabDraw.tsx new file mode 100644 index 0000000..4e62dc7 --- /dev/null +++ b/web/src/pages/LabDraw.tsx @@ -0,0 +1,66 @@ +import { Fragment, 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(null); + const [history, setHistory] = useState>({}); + + 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; + } + try { + const h = await api.markerHistory(key); + setHistory((prev) => ({ ...prev, [key]: h })); + } catch { + // Silently no-op: history is a supplemental, click-to-expand feature — + // a failed fetch should never throw unhandled or break the row. + } + }; + + if (!detail) return

Loading…

; + return ( +
+

{detail.collectedAt}{detail.labName ? ` — ${detail.labName}` : ""}

+ {detail.panels.map((p) => ( +
+

{p.panel}

+ + + {p.markers.map((m) => ( + + + + + + + + {m.analyteKey && history[m.analyteKey] && ( + + + + )} + + ))} + +
+ {m.analyteKey + ? + : m.name} + {m.value} {m.unit ?? ""}{m.referenceRange ?? ""}{m.flagged ? ● : null}
+ pt.value)} /> + {history[m.analyteKey].points.length} draws, {history[m.analyteKey].canonicalUnit} +
+
+ ))} +
+ ); +} diff --git a/web/src/pages/Labs.tsx b/web/src/pages/Labs.tsx new file mode 100644 index 0000000..1216325 --- /dev/null +++ b/web/src/pages/Labs.tsx @@ -0,0 +1,72 @@ +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(null); + const [draws, setDraws] = useState>["draws"]>([]); + const [drafts, setDrafts] = useState>["drafts"]>([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( +
+
+

Labs

+ + e.target.files?.[0] && onFile(e.target.files[0])} /> +
+ {error &&

{error}

} +

Upload a blood-test PDF. The AI extracts the values; nothing is saved until you review and confirm them.

+ + {drafts.length > 0 && ( + <> +

Awaiting review

+
    + {drafts.map((d) => ( +
  • + {d.filename} + {d.error ? extraction failed : {d.markerCount} markers} +
  • + ))} +
+ + )} + +

Draws

+ {draws.length === 0 &&

No confirmed draws yet.

} +
    + {draws.map((d) => ( +
  • + {d.collectedAt} + {d.labName ?? ""} · {d.markerCount} markers + {d.flaggedCount > 0 && {d.flaggedCount} flagged} +
  • + ))} +
+
+ ); +} diff --git a/web/src/styles.css b/web/src/styles.css index a261856..f4702e8 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -5,3 +5,19 @@ nav { display: flex; gap: 16px; margin-bottom: 24px; } form { display: flex; flex-direction: column; gap: 12px; max-width: 360px; } input { padding: 8px; } .error { color: crimson; } +.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; }