From bd4817cb777635e1cf25790e33e9347c675f90b6 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 15:17:37 -0700 Subject: [PATCH 01/13] feat(db): lab_drafts, lab_draws, biomarkers tables Refs PAI-91. --- server/drizzle/0001_labs.sql | 33 +++ server/drizzle/meta/0001_snapshot.json | 279 +++++++++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema.ts | 36 +++- server/test/db-labs.test.ts | 21 ++ 5 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 server/drizzle/0001_labs.sql create mode 100644 server/drizzle/meta/0001_snapshot.json create mode 100644 server/test/db-labs.test.ts 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/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/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"); + }); +}); From a08544db7daea25016b4a2a1756f1c03c3d479b5 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 15:23:34 -0700 Subject: [PATCH 02/13] feat(labs): unit conversion, analyte registry, marker normalization --- server/src/lib/analytes.ts | 508 ++++++++++++++++++++++++++++++++++ server/src/lib/normalize.ts | 62 +++++ server/src/lib/units.ts | 49 ++++ server/test/normalize.test.ts | 55 ++++ 4 files changed, 674 insertions(+) create mode 100644 server/src/lib/analytes.ts create mode 100644 server/src/lib/normalize.ts create mode 100644 server/src/lib/units.ts create mode 100644 server/test/normalize.test.ts diff --git a/server/src/lib/analytes.ts b/server/src/lib/analytes.ts new file mode 100644 index 0000000..75f8b68 --- /dev/null +++ b/server/src/lib/analytes.ts @@ -0,0 +1,508 @@ +// 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: 384.5, + 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", + aliases: ["urea", "bun", "blood urea nitrogen"], + }, + + // --- 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); +} + +export function resolveAnalyte(name: string): Analyte | null { + return INDEX.get(norm(name)) ?? null; +} diff --git a/server/src/lib/normalize.ts b/server/src/lib/normalize.ts new file mode 100644 index 0000000..fc7c8db --- /dev/null +++ b/server/src/lib/normalize.ts @@ -0,0 +1,62 @@ +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, + }; +} 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/test/normalize.test.ts b/server/test/normalize.test.ts new file mode 100644 index 0000000..34589b4 --- /dev/null +++ b/server/test/normalize.test.ts @@ -0,0 +1,55 @@ +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"); + }); +}); From ae7ec9d699a66c9de76d95ecab5a1d43ee320f6f Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 15:27:09 -0700 Subject: [PATCH 03/13] docs: fix DHEA-S molar mass in M2 plan (368.49, was 384.5) --- docs/superpowers/plans/2026-08-18-m2-labs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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). From e0bb8881151c82cc424cf6b61d8056f1d4b4b8c7 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 15:28:13 -0700 Subject: [PATCH 04/13] fix(labs): remove BUN aliases from urea, correct DHEA-S molar mass --- server/src/lib/analytes.ts | 9 +++++++-- server/test/normalize.test.ts | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/server/src/lib/analytes.ts b/server/src/lib/analytes.ts index 75f8b68..7aa262f 100644 --- a/server/src/lib/analytes.ts +++ b/server/src/lib/analytes.ts @@ -85,7 +85,7 @@ export const ANALYTES: Analyte[] = [ key: "dhea_s", display: "DHEA-S", canonicalUnit: "µmol/L", - molarMass: 384.5, + molarMass: 368.49, panel: "hormones", aliases: ["dhea-s", "dhea s", "dheas", "dehydroepiandrosterone sulfate"], }, @@ -347,7 +347,12 @@ export const ANALYTES: Analyte[] = [ canonicalUnit: "mmol/L", molarMass: 60.06, panel: "kidney", - aliases: ["urea", "bun", "blood urea nitrogen"], + // 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 --- diff --git a/server/test/normalize.test.ts b/server/test/normalize.test.ts index 34589b4..ebf338c 100644 --- a/server/test/normalize.test.ts +++ b/server/test/normalize.test.ts @@ -52,4 +52,13 @@ describe("normalizeMarker", () => { 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 + }); }); From 89394732fd279dd988569380749836bb98044fbc Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 15:33:53 -0700 Subject: [PATCH 05/13] feat(llm): OpenAI-compatible chatJSON helper with injectable fetch Co-Authored-By: Claude Fable 5 --- server/src/lib/llm.ts | 38 ++++++++++++++++++++++++++ server/src/routes/settings.ts | 2 +- server/test/llm.test.ts | 51 +++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 server/src/lib/llm.ts create mode 100644 server/test/llm.test.ts 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/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/llm.test.ts b/server/test/llm.test.ts new file mode 100644 index 0000000..b090d80 --- /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; + expect(chatJSON(deps(mock), { system: "s", user: "u" })).rejects.toThrow(/llm_error/); + }); +}); From 8e9f3d58943edd05cfee515f738228f1086ab771 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 15:41:02 -0700 Subject: [PATCH 06/13] feat(labs): PDF upload, text extraction, LLM draft pipeline --- bun.lock | 3 ++ server/package.json | 6 ++- server/src/app.ts | 4 +- server/src/index.ts | 2 +- server/src/lib/extract.ts | 15 +++++++ server/src/lib/pdf.ts | 7 ++++ server/src/routes/labs.ts | 68 ++++++++++++++++++++++++++++++++ server/test/auth.test.ts | 2 +- server/test/extract.test.ts | 46 ++++++++++++++++++++++ server/test/labs-upload.test.ts | 70 +++++++++++++++++++++++++++++++++ server/test/settings.test.ts | 2 +- shared/src/types.ts | 17 ++++++++ 12 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 server/src/lib/extract.ts create mode 100644 server/src/lib/pdf.ts create mode 100644 server/src/routes/labs.ts create mode 100644 server/test/extract.test.ts create mode 100644 server/test/labs-upload.test.ts 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/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..6a7194d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -3,9 +3,10 @@ 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"]); export function createApp(deps: Deps) { @@ -20,6 +21,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/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/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/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/routes/labs.ts b/server/src/routes/labs.ts new file mode 100644 index 0000000..cc77273 --- /dev/null +++ b/server/src/routes/labs.ts @@ -0,0 +1,68 @@ +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; +} diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index a0e3f55..d20436c 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", diff --git a/server/test/extract.test.ts b/server/test/extract.test.ts new file mode 100644 index 0000000..a80faa6 --- /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-")); + expect(extractFromText({ db: openDb(dir), key: loadOrCreateKey(dir), fetchImpl: mock }, "x")).rejects.toThrow(/llm_error/); + }); +}); 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/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..fd352b8 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -20,3 +20,20 @@ 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; From 038c92af453aa84f3baadcbfccd47c9b5df433a6 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 15:46:10 -0700 Subject: [PATCH 07/13] feat(labs): draft review, confirm with normalization, discard - Add ConfirmDraftBody schema to shared types - Implement GET /api/labs/drafts/:id (retrieves draft with extracted data) - Implement POST /api/labs/drafts/:id/confirm (normalizes markers, inserts lab draw + biomarkers in transaction, marks draft confirmed) - Implement POST /api/labs/drafts/:id/discard (marks draft discarded) - Add comprehensive test suite with 3 new tests Co-Authored-By: Claude Fable 5 --- server/src/routes/labs.ts | 49 +++++++++++++++++- server/test/labs-confirm.test.ts | 86 ++++++++++++++++++++++++++++++++ shared/src/types.ts | 7 +++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 server/test/labs-confirm.test.ts diff --git a/server/src/routes/labs.ts b/server/src/routes/labs.ts index cc77273..193dbfe 100644 --- a/server/src/routes/labs.ts +++ b/server/src/routes/labs.ts @@ -4,9 +4,11 @@ import { randomUUID } from "node:crypto"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; import type { Db } from "../db"; -import { labDrafts } from "../db/schema"; +import { biomarkers, labDrafts, labDraws } from "../db/schema"; import { extractFromText } from "../lib/extract"; +import { normalizeMarker } from "../lib/normalize"; import { pdfToText } from "../lib/pdf"; +import { ConfirmDraftBody } from "@helios/shared"; export type LabsDeps = { db: Db; key: Buffer; dataDir: string; llmFetch?: typeof fetch }; @@ -64,5 +66,50 @@ export function labsRoutes(deps: LabsDeps) { }); }); + app.get("/labs/drafts/:id", async (c) => { + const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0); + if (!row) return c.json({ error: "not found" }, 404); + return c.json({ + id: row.id, filename: row.filename, status: row.status, error: row.error, + draft: row.extracted ? JSON.parse(row.extracted) : null, + }); + }); + + app.post("/labs/drafts/:id/confirm", async (c) => { + const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0); + if (!row) return c.json({ error: "not found" }, 404); + if (row.status !== "pending") return c.json({ error: "draft is not pending" }, 409); + const body = ConfirmDraftBody.safeParse(await c.req.json().catch(() => null)); + if (!body.success) return c.json({ error: body.error.issues[0]?.message ?? "invalid body" }, 400); + + const drawId = randomUUID(); + const norm = body.data.markers.map((m) => normalizeMarker({ + panel: m.panel, name: m.name, value: m.value, unit: m.unit, + referenceRange: m.referenceRange, flagged: m.flagged, + })); + await deps.db.transaction(async (tx) => { + await tx.insert(labDraws).values({ + id: drawId, collectedAt: body.data.collectedDate, labName: body.data.labName, + draftId: row.id, createdAt: Date.now(), + }); + for (const n of norm) { + await tx.insert(biomarkers).values({ + drawId, panel: n.panel, name: n.name, marker: n.marker, analyteKey: n.analyteKey, + value: n.value, valueNum: n.valueNum, unit: n.unit, referenceRange: n.referenceRange, + flagged: n.flagged ? 1 : 0, valueCanonical: n.valueCanonical, canonicalUnit: n.canonicalUnit, + }); + } + await tx.update(labDrafts).set({ status: "confirmed" }).where(eq(labDrafts.id, row.id)); + }); + return c.json({ drawId }, 201); + }); + + app.post("/labs/drafts/:id/discard", async (c) => { + const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0); + if (!row) return c.json({ error: "not found" }, 404); + await deps.db.update(labDrafts).set({ status: "discarded" }).where(eq(labDrafts.id, row.id)); + return c.body(null, 204); + }); + return app; } diff --git a/server/test/labs-confirm.test.ts b/server/test/labs-confirm.test.ts new file mode 100644 index 0000000..4640f6c --- /dev/null +++ b/server/test/labs-confirm.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { createApp } from "../src/app"; +import { openDb } from "../src/db"; +import { biomarkers, labDrafts } from "../src/db/schema"; +import { loadOrCreateKey } from "../src/lib/crypto"; + +async function setup() { + const dir = mkdtempSync(join(tmpdir(), "helios-")); + const db = openDb(dir); + const app = createApp({ db, key: loadOrCreateKey(dir), dataDir: dir }); + const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) }); + await app.request("/api/setup", j({ password: "hunter2hunter2" })); + const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!; + await db.insert(labDrafts).values({ + id: "draft1", filename: "r.pdf", filePath: "/tmp/none.pdf", status: "pending", + extracted: JSON.stringify({ collectedDate: "2026-01-15", labName: "Sample Diagnostics", markers: [ + { panel: "chemistry", name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true }, + ] }), + error: null, createdAt: 1, + }); + return { app, db, cookie }; +} + +describe("draft review", () => { + test("get, confirm with edits → normalized biomarkers, draft confirmed", async () => { + const { app, db, cookie } = await setup(); + const h = { cookie, "content-type": "application/json" }; + + const got = await (await app.request("/api/labs/drafts/draft1", { headers: { cookie } })).json(); + expect(got.draft.markers).toHaveLength(1); + + const confirm = await app.request("/api/labs/drafts/draft1/confirm", { + method: "POST", headers: h, + body: JSON.stringify({ + collectedDate: "2026-01-15", + labName: "Sample Diagnostics", + markers: [ + { panel: "chemistry", name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true }, + { panel: null, name: "Ferritin", value: "30", unit: "ng/mL", referenceRange: null, flagged: false }, // user-added row + ], + }), + }); + expect(confirm.status).toBe(201); + + const rows = await db.select().from(biomarkers); + expect(rows).toHaveLength(2); + const glucose = rows.find((r) => r.analyteKey === "glucose")!; + expect(glucose.valueCanonical).toBeCloseTo(5.551, 2); + expect(glucose.flagged).toBe(1); + const ferritin = rows.find((r) => r.analyteKey === "ferritin")!; + expect(ferritin.valueCanonical).toBeCloseTo(30); // ng/mL → µg/L 1:1 + expect(ferritin.canonicalUnit).toBe("µg/L"); + + const draft = (await db.select().from(labDrafts).where(eq(labDrafts.id, "draft1")))[0]; + expect(draft.status).toBe("confirmed"); + + // second confirm → 409 + const again = await app.request("/api/labs/drafts/draft1/confirm", { + method: "POST", headers: h, + body: JSON.stringify({ collectedDate: "2026-01-15", labName: null, markers: [{ panel: null, name: "X", value: "1", unit: null, referenceRange: null, flagged: false }] }), + }); + expect(again.status).toBe(409); + }); + + test("discard marks draft discarded", async () => { + const { app, db, cookie } = await setup(); + const r = await app.request("/api/labs/drafts/draft1/discard", { method: "POST", headers: { cookie } }); + expect(r.status).toBe(204); + const draft = (await db.select().from(labDrafts).where(eq(labDrafts.id, "draft1")))[0]; + expect(draft.status).toBe("discarded"); + }); + + test("unknown draft 404; bad date 400", async () => { + const { app, cookie } = await setup(); + expect((await app.request("/api/labs/drafts/nope", { headers: { cookie } })).status).toBe(404); + const bad = await app.request("/api/labs/drafts/draft1/confirm", { + method: "POST", headers: { cookie, "content-type": "application/json" }, + body: JSON.stringify({ collectedDate: "15/01/2026", labName: null, markers: [{ panel: null, name: "X", value: "1", unit: null, referenceRange: null, flagged: false }] }), + }); + expect(bad.status).toBe(400); + }); +}); diff --git a/shared/src/types.ts b/shared/src/types.ts index fd352b8..9654240 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -37,3 +37,10 @@ export const ExtractedDraft = z.object({ markers: z.array(ExtractedMarker).default([]), }); export type ExtractedDraft = z.infer; + +export const ConfirmDraftBody = z.object({ + collectedDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + labName: z.string().nullable().default(null), + markers: z.array(ExtractedMarker).min(1), +}); +export type ConfirmDraftBody = z.infer; From 96692163f19ba313617645837d2bac861f148573 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 15:52:59 -0700 Subject: [PATCH 08/13] =?UTF-8?q?fix(labs):=20synchronous=20confirm=20tran?= =?UTF-8?q?saction=20=E2=80=94=20async=20callback=20broke=20atomicity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bun-sqlite's Database.transaction is synchronous, but async callbacks return a pending Promise at the first await, causing immediate COMMIT before the entire callback completes. This allowed partial inserts with no rollback. Fixed by: - Remove async from transaction callback - Add .run() to each insert/update to execute synchronously - Add regression test proving atomicity: transaction that throws mid-loop rolls back all changes (both tables empty after failure) Co-Authored-By: Claude Fable 5 --- server/src/routes/labs.ts | 12 ++++++------ server/test/labs-confirm.test.ts | 21 ++++++++++++++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/server/src/routes/labs.ts b/server/src/routes/labs.ts index 193dbfe..bfffc62 100644 --- a/server/src/routes/labs.ts +++ b/server/src/routes/labs.ts @@ -87,19 +87,19 @@ export function labsRoutes(deps: LabsDeps) { 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({ + deps.db.transaction((tx) => { + 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) { - await tx.insert(biomarkers).values({ + 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(); } - await tx.update(labDrafts).set({ status: "confirmed" }).where(eq(labDrafts.id, row.id)); + tx.update(labDrafts).set({ status: "confirmed" }).where(eq(labDrafts.id, row.id)).run(); }); return c.json({ drawId }, 201); }); diff --git a/server/test/labs-confirm.test.ts b/server/test/labs-confirm.test.ts index 4640f6c..d19846e 100644 --- a/server/test/labs-confirm.test.ts +++ b/server/test/labs-confirm.test.ts @@ -5,7 +5,7 @@ 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 { biomarkers, labDrafts, labDraws } from "../src/db/schema"; import { loadOrCreateKey } from "../src/lib/crypto"; async function setup() { @@ -83,4 +83,23 @@ describe("draft review", () => { }); 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); + }); }); From 7e70bd1940a02d1cff480547eb308f6e01120685 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 15:57:24 -0700 Subject: [PATCH 09/13] feat(labs): draw list/detail, marker history, PDF file serving --- server/src/lib/analytes.ts | 6 ++++ server/src/routes/labs.ts | 56 +++++++++++++++++++++++++++++++++- server/test/labs-query.test.ts | 55 +++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 server/test/labs-query.test.ts diff --git a/server/src/lib/analytes.ts b/server/src/lib/analytes.ts index 7aa262f..8a917cd 100644 --- a/server/src/lib/analytes.ts +++ b/server/src/lib/analytes.ts @@ -508,6 +508,12 @@ for (const a of ANALYTES) { for (const al of a.aliases) INDEX.set(norm(al), a); } +const BY_KEY = new Map(ANALYTES.map((a) => [a.key, a])); + export function resolveAnalyte(name: string): Analyte | null { return INDEX.get(norm(name)) ?? null; } + +export function getAnalyte(key: string): Analyte | null { + return BY_KEY.get(key) ?? null; +} diff --git a/server/src/routes/labs.ts b/server/src/routes/labs.ts index bfffc62..2228626 100644 --- a/server/src/routes/labs.ts +++ b/server/src/routes/labs.ts @@ -1,13 +1,14 @@ import { desc, eq } from "drizzle-orm"; import { Hono } from "hono"; import { randomUUID } from "node:crypto"; -import { mkdirSync } from "node:fs"; +import { existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import type { Db } from "../db"; import { biomarkers, labDrafts, labDraws } from "../db/schema"; import { extractFromText } from "../lib/extract"; import { normalizeMarker } from "../lib/normalize"; import { pdfToText } from "../lib/pdf"; +import { getAnalyte } from "../lib/analytes"; import { ConfirmDraftBody } from "@helios/shared"; export type LabsDeps = { db: Db; key: Buffer; dataDir: string; llmFetch?: typeof fetch }; @@ -111,5 +112,58 @@ export function labsRoutes(deps: LabsDeps) { return c.body(null, 204); }); + app.get("/labs/draws", async (c) => { + const draws = await deps.db.select().from(labDraws).orderBy(desc(labDraws.collectedAt)); + const rows = await deps.db.select().from(biomarkers); + return c.json({ + draws: draws.map((d) => ({ + id: d.id, collectedAt: d.collectedAt, labName: d.labName, + markerCount: rows.filter((r) => r.drawId === d.id).length, + flaggedCount: rows.filter((r) => r.drawId === d.id && r.flagged === 1).length, + })), + }); + }); + + app.get("/labs/draws/:id", async (c) => { + const d = (await deps.db.select().from(labDraws).where(eq(labDraws.id, c.req.param("id")))).at(0); + if (!d) return c.json({ error: "not found" }, 404); + const rows = await deps.db.select().from(biomarkers).where(eq(biomarkers.drawId, d.id)); + const byPanel = new Map(); + for (const r of rows) byPanel.set(r.panel, [...(byPanel.get(r.panel) ?? []), r]); + return c.json({ + id: d.id, collectedAt: d.collectedAt, labName: d.labName, + panels: [...byPanel.keys()].sort().map((panel) => ({ + panel, + markers: byPanel.get(panel)! + .sort((a, b) => a.name.localeCompare(b.name)) + .map((r) => ({ ...r, flagged: r.flagged === 1 })), + })), + }); + }); + + app.get("/labs/markers/:key/history", async (c) => { + const analyte = getAnalyte(c.req.param("key")); + if (!analyte) return c.json({ error: "unknown marker" }, 404); + const draws = await deps.db.select().from(labDraws); + const dates = new Map(draws.map((d) => [d.id, d.collectedAt])); + const rows = await deps.db.select().from(biomarkers).where(eq(biomarkers.analyteKey, analyte.key)); + const points = rows + .filter((r) => r.valueCanonical !== null) + .map((r) => ({ drawId: r.drawId, collectedAt: dates.get(r.drawId) ?? "", value: r.valueCanonical! })) + .sort((a, b) => a.collectedAt.localeCompare(b.collectedAt)); + return c.json({ key: analyte.key, display: analyte.display, canonicalUnit: analyte.canonicalUnit, points }); + }); + + app.get("/labs/drafts/:id/file", async (c) => { + const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0); + if (!row || !existsSync(row.filePath)) return c.json({ error: "not found" }, 404); + return new Response(Bun.file(row.filePath), { + headers: { + "content-type": "application/pdf", + "content-disposition": `inline; filename="${row.filename.replace(/[^\w.-]/g, "_")}"`, + }, + }); + }); + return app; } diff --git a/server/test/labs-query.test.ts b/server/test/labs-query.test.ts new file mode 100644 index 0000000..67106ca --- /dev/null +++ b/server/test/labs-query.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "../src/app"; +import { openDb } from "../src/db"; +import { biomarkers, labDraws } from "../src/db/schema"; +import { loadOrCreateKey } from "../src/lib/crypto"; + +async function seeded() { + const dir = mkdtempSync(join(tmpdir(), "helios-")); + const db = openDb(dir); + const app = createApp({ db, key: loadOrCreateKey(dir), dataDir: dir }); + const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) }); + await app.request("/api/setup", j({ password: "hunter2hunter2" })); + const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!; + + await db.insert(labDraws).values([ + { id: "d1", collectedAt: "2025-06-01", labName: "A", draftId: null, createdAt: 1 }, + { id: "d2", collectedAt: "2026-01-15", labName: "B", draftId: null, createdAt: 2 }, + ]); + await db.insert(biomarkers).values([ + { drawId: "d1", panel: "metabolic", name: "Glucose", marker: "glucose", analyteKey: "glucose", value: "90", valueNum: 90, unit: "mg/dL", referenceRange: null, flagged: 0, valueCanonical: 4.996, canonicalUnit: "mmol/L" }, + { drawId: "d2", panel: "metabolic", name: "Glucose", marker: "glucose", analyteKey: "glucose", value: "100", valueNum: 100, unit: "mg/dL", referenceRange: "70-99", flagged: 1, valueCanonical: 5.551, canonicalUnit: "mmol/L" }, + { drawId: "d2", panel: "lipids", name: "LDL Cholesterol", marker: "ldl", analyteKey: "ldl", value: "3.1", valueNum: 3.1, unit: "mmol/L", referenceRange: "<3.4", flagged: 0, valueCanonical: 3.1, canonicalUnit: "mmol/L" }, + ]); + return { app, cookie }; +} + +describe("labs queries", () => { + test("draw list newest first with counts", async () => { + const { app, cookie } = await seeded(); + const { draws } = await (await app.request("/api/labs/draws", { headers: { cookie } })).json(); + expect(draws.map((d: any) => d.id)).toEqual(["d2", "d1"]); + expect(draws[0].markerCount).toBe(2); + expect(draws[0].flaggedCount).toBe(1); + }); + + test("draw detail grouped by panel", async () => { + const { app, cookie } = await seeded(); + const detail = await (await app.request("/api/labs/draws/d2", { headers: { cookie } })).json(); + expect(detail.panels.map((p: any) => p.panel)).toEqual(["lipids", "metabolic"]); + expect(detail.panels[1].markers[0].flagged).toBe(true); + expect((await app.request("/api/labs/draws/nope", { headers: { cookie } })).status).toBe(404); + }); + + test("marker history ascending with registry metadata", async () => { + const { app, cookie } = await seeded(); + const h = await (await app.request("/api/labs/markers/glucose/history", { headers: { cookie } })).json(); + expect(h.display).toBe("Glucose"); + expect(h.canonicalUnit).toBe("mmol/L"); + expect(h.points.map((p: any) => p.value)).toEqual([4.996, 5.551]); + expect((await app.request("/api/labs/markers/unknown_thing/history", { headers: { cookie } })).status).toBe(404); + }); +}); From 1aa600adfca7508bfd59e881ae7a674ec7db9d99 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 16:01:59 -0700 Subject: [PATCH 10/13] feat(web): labs upload, draft review, draw dashboard with history sparklines Co-Authored-By: Claude Fable 5 --- web/src/App.tsx | 7 +++ web/src/api.ts | 28 +++++++++- web/src/components/Sparkline.tsx | 14 +++++ web/src/pages/LabDraft.tsx | 88 ++++++++++++++++++++++++++++++++ web/src/pages/LabDraw.tsx | 61 ++++++++++++++++++++++ web/src/pages/Labs.tsx | 72 ++++++++++++++++++++++++++ web/src/styles.css | 16 ++++++ 7 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 web/src/components/Sparkline.tsx create mode 100644 web/src/pages/LabDraft.tsx create mode 100644 web/src/pages/LabDraw.tsx create mode 100644 web/src/pages/Labs.tsx 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..adfcdaf --- /dev/null +++ b/web/src/pages/LabDraft.tsx @@ -0,0 +1,88 @@ +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..a9acc90 --- /dev/null +++ b/web/src/pages/LabDraw.tsx @@ -0,0 +1,61 @@ +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; + } + const h = await api.markerHistory(key); + setHistory((prev) => ({ ...prev, [key]: h })); + }; + + 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; } From 876aa0f181ff89b82bf3ab8c874407337e30ecde Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 16:06:05 -0700 Subject: [PATCH 11/13] feat(security): CSP and hardening headers with upload serving Implement Content-Security-Policy headers and strict content-type handling for non-API responses, with x-content-type-options applied to all routes. Adds security-headers test suite to verify header presence. Co-Authored-By: Claude Fable 5 --- server/src/app.ts | 9 +++++++++ server/test/security-headers.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 server/test/security-headers.test.ts diff --git a/server/src/app.ts b/server/src/app.ts index 6a7194d..89682e0 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -8,9 +8,18 @@ import { settingsRoutes } from "./routes/settings"; 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(); 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"); + }); +}); From e81d07975ad8b8c64cf7b417f3d4b5310b031a67 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 16:17:03 -0700 Subject: [PATCH 12/13] fix(labs): strict numeric parsing, atomic confirm guard, test integrity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - normalize.ts num(): parseFloat truncated at the first comma, so "1,200" silently became 1 (1000x error) and "5,5" became 5. Now strictly matches either US thousands-grouping or a plain number spanning the whole string; anything else (incl. ambiguous "5,5") returns null instead of a wrong value. - labs.ts confirm handler: the pending-status check ran before the request body was read, so two concurrent confirms could both pass it and double-insert. Added a guarded UPDATE ... WHERE status = 'pending' as the first statement inside the existing synchronous transaction; zero rows affected throws and the route returns 409, with the fast-path check kept for the common case. - Added missing `await` on two rejects.toThrow assertions (llm.test.ts, extract.test.ts) that were previously resolving before the assertion settled. - Bumped the 11th-failed-login rate-limit test to a 30s timeout — 10 sequential argon2id verifies can exceed bun:test's 5s default under load. --- server/src/lib/normalize.ts | 22 +++++++++++++-- server/src/routes/labs.ts | 46 +++++++++++++++++++++++--------- server/test/auth.test.ts | 2 +- server/test/extract.test.ts | 2 +- server/test/labs-confirm.test.ts | 7 +++++ server/test/llm.test.ts | 2 +- server/test/normalize.test.ts | 17 ++++++++++++ 7 files changed, 80 insertions(+), 18 deletions(-) diff --git a/server/src/lib/normalize.ts b/server/src/lib/normalize.ts index fc7c8db..eb12484 100644 --- a/server/src/lib/normalize.ts +++ b/server/src/lib/normalize.ts @@ -24,9 +24,27 @@ export interface NormMarker { 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 n = parseFloat(v.replace(/[<>≤≥]/g, "").trim()); - return Number.isFinite(n) ? n : 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 { diff --git a/server/src/routes/labs.ts b/server/src/routes/labs.ts index 2228626..e552a94 100644 --- a/server/src/routes/labs.ts +++ b/server/src/routes/labs.ts @@ -1,4 +1,5 @@ -import { desc, eq } from "drizzle-orm"; +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"; @@ -88,20 +89,39 @@ export function labsRoutes(deps: LabsDeps) { panel: m.panel, name: m.name, value: m.value, unit: m.unit, referenceRange: m.referenceRange, flagged: m.flagged, })); - deps.db.transaction((tx) => { - 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, + 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); } - tx.update(labDrafts).set({ status: "confirmed" }).where(eq(labDrafts.id, row.id)).run(); - }); + throw e; + } return c.json({ drawId }, 201); }); diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index d20436c..2f4cf2c 100644 --- a/server/test/auth.test.ts +++ b/server/test/auth.test.ts @@ -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/extract.test.ts b/server/test/extract.test.ts index a80faa6..3118e57 100644 --- a/server/test/extract.test.ts +++ b/server/test/extract.test.ts @@ -41,6 +41,6 @@ describe("extractFromText", () => { 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-")); - expect(extractFromText({ db: openDb(dir), key: loadOrCreateKey(dir), fetchImpl: mock }, "x")).rejects.toThrow(/llm_error/); + 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 index d19846e..a126b54 100644 --- a/server/test/labs-confirm.test.ts +++ b/server/test/labs-confirm.test.ts @@ -64,6 +64,13 @@ describe("draft review", () => { 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 () => { diff --git a/server/test/llm.test.ts b/server/test/llm.test.ts index b090d80..eeea086 100644 --- a/server/test/llm.test.ts +++ b/server/test/llm.test.ts @@ -46,6 +46,6 @@ describe("chatJSON", () => { test("non-2xx throws llm_error", async () => { const mock = (async () => new Response("nope", { status: 401 })) as unknown as typeof fetch; - expect(chatJSON(deps(mock), { system: "s", user: "u" })).rejects.toThrow(/llm_error/); + 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 index ebf338c..fe13987 100644 --- a/server/test/normalize.test.ts +++ b/server/test/normalize.test.ts @@ -61,4 +61,21 @@ describe("normalizeMarker", () => { 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); + }); }); From a9256a765f24ba38f67796b6d977705cde88be53 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 16:17:03 -0700 Subject: [PATCH 13/13] fix(web): error handling on discard and history toggle - LabDraft.tsx discard button: chain .catch to surface a failed discard in the existing error banner instead of an unhandled rejection. - LabDraw.tsx toggleHistory: wrap the marker-history fetch in try/catch so a failed request silently no-ops (the row just doesn't expand) rather than throwing unhandled from the click handler. --- web/src/pages/LabDraft.tsx | 13 ++++++++++++- web/src/pages/LabDraw.tsx | 9 +++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/web/src/pages/LabDraft.tsx b/web/src/pages/LabDraft.tsx index adfcdaf..83a2940 100644 --- a/web/src/pages/LabDraft.tsx +++ b/web/src/pages/LabDraft.tsx @@ -80,7 +80,18 @@ export function LabDraft() { - + )} diff --git a/web/src/pages/LabDraw.tsx b/web/src/pages/LabDraw.tsx index a9acc90..4e62dc7 100644 --- a/web/src/pages/LabDraw.tsx +++ b/web/src/pages/LabDraw.tsx @@ -17,8 +17,13 @@ export function LabDraw() { setHistory((h) => { const { [key]: _, ...rest } = h; return rest; }); return; } - const h = await api.markerHistory(key); - setHistory((prev) => ({ ...prev, [key]: h })); + 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…

;