Compare commits
14 Commits
e3cfc6592e
...
628e14fde4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
628e14fde4 | ||
|
|
a9256a765f | ||
|
|
e81d07975a | ||
|
|
876aa0f181 | ||
|
|
1aa600adfc | ||
|
|
7e70bd1940 | ||
|
|
96692163f1 | ||
|
|
038c92af45 | ||
|
|
8e9f3d5894 | ||
|
|
89394732fd | ||
|
|
e0bb888115 | ||
|
|
ae7ec9d699 | ||
|
|
a08544db7d | ||
|
|
bd4817cb77 |
3
bun.lock
3
bun.lock
@@ -15,6 +15,7 @@
|
|||||||
"@helios/shared": "workspace:*",
|
"@helios/shared": "workspace:*",
|
||||||
"drizzle-orm": "^0.44.0",
|
"drizzle-orm": "^0.44.0",
|
||||||
"hono": "^4.6.0",
|
"hono": "^4.6.0",
|
||||||
|
"unpdf": "^1.8.1",
|
||||||
"zod": "^3.24.0",
|
"zod": "^3.24.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -322,6 +323,8 @@
|
|||||||
|
|
||||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -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):
|
`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).
|
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).
|
||||||
|
|
||||||
|
|||||||
33
server/drizzle/0001_labs.sql
Normal file
33
server/drizzle/0001_labs.sql
Normal file
@@ -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
|
||||||
|
);
|
||||||
279
server/drizzle/meta/0001_snapshot.json
Normal file
279
server/drizzle/meta/0001_snapshot.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@
|
|||||||
"when": 1787000672259,
|
"when": 1787000672259,
|
||||||
"tag": "0000_init",
|
"tag": "0000_init",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787005028172,
|
||||||
|
"tag": "0001_labs",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,11 @@
|
|||||||
"@helios/shared": "workspace:*",
|
"@helios/shared": "workspace:*",
|
||||||
"drizzle-orm": "^0.44.0",
|
"drizzle-orm": "^0.44.0",
|
||||||
"hono": "^4.6.0",
|
"hono": "^4.6.0",
|
||||||
|
"unpdf": "^1.8.1",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": { "bun-types": "latest", "drizzle-kit": "^0.31.0" }
|
"devDependencies": {
|
||||||
|
"bun-types": "latest",
|
||||||
|
"drizzle-kit": "^0.31.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,23 @@ import { getCookie } from "hono/cookie";
|
|||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
import type { Db } from "./db";
|
import type { Db } from "./db";
|
||||||
import { authRoutes, isAuthenticated } from "./routes/auth";
|
import { authRoutes, isAuthenticated } from "./routes/auth";
|
||||||
|
import { labsRoutes } from "./routes/labs";
|
||||||
import { settingsRoutes } from "./routes/settings";
|
import { settingsRoutes } from "./routes/settings";
|
||||||
|
|
||||||
export type Deps = { db: Db; key: Buffer };
|
export type Deps = { db: Db; key: Buffer; dataDir: string; llmFetch?: typeof fetch };
|
||||||
const PUBLIC = new Set(["/api/health", "/api/me", "/api/setup", "/api/login"]);
|
const 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) {
|
export function createApp(deps: Deps) {
|
||||||
const app = new Hono();
|
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.get("/api/health", (c) => c.json({ ok: true }));
|
||||||
app.use("/api/*", async (c, next) => {
|
app.use("/api/*", async (c, next) => {
|
||||||
if (PUBLIC.has(c.req.path)) return next();
|
if (PUBLIC.has(c.req.path)) return next();
|
||||||
@@ -20,6 +30,7 @@ export function createApp(deps: Deps) {
|
|||||||
});
|
});
|
||||||
app.route("/api", authRoutes({ db: deps.db }));
|
app.route("/api", authRoutes({ db: deps.db }));
|
||||||
app.route("/api", settingsRoutes(deps));
|
app.route("/api", settingsRoutes(deps));
|
||||||
|
app.route("/api", labsRoutes(deps));
|
||||||
// Later route groups (connectors, chat) mount here.
|
// Later route groups (connectors, chat) mount here.
|
||||||
app.all("/api/*", (c) => c.json({ error: "not found" }, 404));
|
app.all("/api/*", (c) => c.json({ error: "not found" }, 404));
|
||||||
app.use("/*", serveStatic({ root: "./web/dist" }));
|
app.use("/*", serveStatic({ root: "./web/dist" }));
|
||||||
|
|||||||
@@ -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", {
|
export const settings = sqliteTable("settings", {
|
||||||
key: text("key").primaryKey(),
|
key: text("key").primaryKey(),
|
||||||
@@ -10,3 +10,37 @@ export const sessions = sqliteTable("sessions", {
|
|||||||
createdAt: integer("created_at").notNull(),
|
createdAt: integer("created_at").notNull(),
|
||||||
expiresAt: integer("expires_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"),
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ import { loadOrCreateKey } from "./lib/crypto";
|
|||||||
import { createApp } from "./app";
|
import { createApp } from "./app";
|
||||||
|
|
||||||
const dataDir = process.env.DATA_DIR ?? "./data";
|
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 };
|
export default { port: Number(process.env.PORT ?? 3000), fetch: app.fetch };
|
||||||
|
|||||||
519
server/src/lib/analytes.ts
Normal file
519
server/src/lib/analytes.ts
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
// Analyte registry: canonical units, molar masses, and panel groupings for
|
||||||
|
// lab markers. `molarMass` is only set when a mass<->mole bridge is actually
|
||||||
|
// used in practice for that analyte (see units.ts); it stays null for
|
||||||
|
// analytes reported purely by mass, by count, as a percentage/ratio, in
|
||||||
|
// enzyme-activity units (U/L, IU/L), or in units the converter treats as
|
||||||
|
// "other" (e.g. eGFR's mL/min/1.73m2, MCV's fL) — those never convert.
|
||||||
|
export interface Analyte {
|
||||||
|
key: string;
|
||||||
|
display: string;
|
||||||
|
canonicalUnit: string;
|
||||||
|
molarMass: number | null;
|
||||||
|
panel: string;
|
||||||
|
aliases: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ANALYTES: Analyte[] = [
|
||||||
|
// --- iron ---
|
||||||
|
{
|
||||||
|
key: "iron",
|
||||||
|
display: "Iron",
|
||||||
|
canonicalUnit: "µmol/L",
|
||||||
|
molarMass: 55.845,
|
||||||
|
panel: "iron",
|
||||||
|
aliases: ["iron", "serum iron", "fe"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ferritin",
|
||||||
|
display: "Ferritin",
|
||||||
|
canonicalUnit: "µg/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "iron",
|
||||||
|
aliases: ["ferritin", "serum ferritin"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "transferrin",
|
||||||
|
display: "Transferrin",
|
||||||
|
canonicalUnit: "g/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "iron",
|
||||||
|
aliases: ["transferrin"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tibc",
|
||||||
|
display: "Total Iron Binding Capacity",
|
||||||
|
canonicalUnit: "µmol/L",
|
||||||
|
molarMass: 55.845,
|
||||||
|
panel: "iron",
|
||||||
|
aliases: ["tibc", "total iron binding capacity", "total iron-binding capacity"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "transferrin_saturation",
|
||||||
|
display: "Transferrin Saturation",
|
||||||
|
canonicalUnit: "%",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "iron",
|
||||||
|
aliases: ["transferrin saturation", "iron saturation", "tsat", "% saturation", "percent saturation"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- hormones ---
|
||||||
|
{
|
||||||
|
key: "testosterone_total",
|
||||||
|
display: "Total Testosterone",
|
||||||
|
canonicalUnit: "ng/dL",
|
||||||
|
molarMass: 288.42,
|
||||||
|
panel: "hormones",
|
||||||
|
aliases: ["testosterone", "total testosterone", "testosterone total", "testosterone, total"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "testosterone_free",
|
||||||
|
display: "Free Testosterone",
|
||||||
|
canonicalUnit: "pg/mL",
|
||||||
|
molarMass: 288.42,
|
||||||
|
panel: "hormones",
|
||||||
|
aliases: ["free testosterone", "testosterone free", "testosterone, free"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "shbg",
|
||||||
|
display: "SHBG",
|
||||||
|
canonicalUnit: "nmol/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "hormones",
|
||||||
|
aliases: ["shbg", "sex hormone binding globulin", "sex hormone-binding globulin"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "dhea_s",
|
||||||
|
display: "DHEA-S",
|
||||||
|
canonicalUnit: "µmol/L",
|
||||||
|
molarMass: 368.49,
|
||||||
|
panel: "hormones",
|
||||||
|
aliases: ["dhea-s", "dhea s", "dheas", "dehydroepiandrosterone sulfate"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cortisol",
|
||||||
|
display: "Cortisol",
|
||||||
|
canonicalUnit: "nmol/L",
|
||||||
|
molarMass: 362.46,
|
||||||
|
panel: "hormones",
|
||||||
|
aliases: ["cortisol", "cortisol am", "cortisol, am", "morning cortisol"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "estradiol",
|
||||||
|
display: "Estradiol",
|
||||||
|
canonicalUnit: "pmol/L",
|
||||||
|
molarMass: 272.38,
|
||||||
|
panel: "hormones",
|
||||||
|
aliases: ["estradiol", "oestradiol", "e2"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "prolactin",
|
||||||
|
display: "Prolactin",
|
||||||
|
canonicalUnit: "µg/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "hormones",
|
||||||
|
aliases: ["prolactin", "prl"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fsh",
|
||||||
|
display: "FSH",
|
||||||
|
canonicalUnit: "IU/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "hormones",
|
||||||
|
aliases: ["fsh", "follicle stimulating hormone", "follicle-stimulating hormone"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lh",
|
||||||
|
display: "LH",
|
||||||
|
canonicalUnit: "IU/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "hormones",
|
||||||
|
aliases: ["lh", "luteinizing hormone"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "insulin",
|
||||||
|
display: "Insulin",
|
||||||
|
canonicalUnit: "µIU/mL",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "hormones",
|
||||||
|
aliases: ["insulin", "fasting insulin", "insulin fasting", "insulin, fasting"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "igf1",
|
||||||
|
display: "IGF-1",
|
||||||
|
canonicalUnit: "µg/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "hormones",
|
||||||
|
aliases: ["igf-1", "igf1", "igf 1", "insulin-like growth factor 1", "insulin like growth factor 1"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- thyroid ---
|
||||||
|
{
|
||||||
|
key: "tsh",
|
||||||
|
display: "TSH",
|
||||||
|
canonicalUnit: "mIU/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "thyroid",
|
||||||
|
aliases: ["tsh", "thyroid stimulating hormone", "thyroid-stimulating hormone"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "free_t4",
|
||||||
|
display: "Free T4",
|
||||||
|
canonicalUnit: "pmol/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "thyroid",
|
||||||
|
aliases: ["free t4", "ft4", "free thyroxine", "t4 free", "thyroxine free", "thyroxine, free"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "free_t3",
|
||||||
|
display: "Free T3",
|
||||||
|
canonicalUnit: "pmol/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "thyroid",
|
||||||
|
aliases: ["free t3", "ft3", "free triiodothyronine", "t3 free", "triiodothyronine free", "triiodothyronine, free"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- lipids ---
|
||||||
|
{
|
||||||
|
key: "cholesterol_total",
|
||||||
|
display: "Total Cholesterol",
|
||||||
|
canonicalUnit: "mmol/L",
|
||||||
|
molarMass: 386.65,
|
||||||
|
panel: "lipids",
|
||||||
|
aliases: ["cholesterol", "total cholesterol", "cholesterol total", "cholesterol, total"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hdl",
|
||||||
|
display: "HDL Cholesterol",
|
||||||
|
canonicalUnit: "mmol/L",
|
||||||
|
molarMass: 386.65,
|
||||||
|
panel: "lipids",
|
||||||
|
aliases: ["hdl", "hdl cholesterol", "hdl-c", "cholesterol hdl"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ldl",
|
||||||
|
display: "LDL Cholesterol",
|
||||||
|
canonicalUnit: "mmol/L",
|
||||||
|
molarMass: 386.65,
|
||||||
|
panel: "lipids",
|
||||||
|
aliases: ["ldl", "ldl cholesterol", "ldl-c", "cholesterol ldl", "ldl calculated", "ldl, calculated"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "triglycerides",
|
||||||
|
display: "Triglycerides",
|
||||||
|
canonicalUnit: "mmol/L",
|
||||||
|
molarMass: 885.4,
|
||||||
|
panel: "lipids",
|
||||||
|
aliases: ["triglycerides", "trig", "trigs", "tg"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "non_hdl",
|
||||||
|
display: "Non-HDL Cholesterol",
|
||||||
|
canonicalUnit: "mmol/L",
|
||||||
|
molarMass: 386.65,
|
||||||
|
panel: "lipids",
|
||||||
|
aliases: ["non-hdl", "non hdl", "non-hdl cholesterol", "non hdl cholesterol"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "apob",
|
||||||
|
display: "Apolipoprotein B",
|
||||||
|
canonicalUnit: "g/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "lipids",
|
||||||
|
aliases: ["apob", "apo b", "apolipoprotein b"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lipoprotein_a",
|
||||||
|
display: "Lipoprotein(a)",
|
||||||
|
canonicalUnit: "nmol/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "lipids",
|
||||||
|
aliases: ["lipoprotein a", "lipoprotein(a)", "lp(a)", "lpa"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- metabolic ---
|
||||||
|
{
|
||||||
|
key: "glucose",
|
||||||
|
display: "Glucose",
|
||||||
|
canonicalUnit: "mmol/L",
|
||||||
|
molarMass: 180.16,
|
||||||
|
panel: "metabolic",
|
||||||
|
aliases: ["glucose", "glucose fasting", "fasting glucose", "blood glucose"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hba1c",
|
||||||
|
display: "HbA1c",
|
||||||
|
canonicalUnit: "%",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "metabolic",
|
||||||
|
aliases: ["hba1c", "hemoglobin a1c", "haemoglobin a1c", "a1c", "glycated hemoglobin", "glycated haemoglobin"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "uric_acid",
|
||||||
|
display: "Uric Acid",
|
||||||
|
canonicalUnit: "mmol/L",
|
||||||
|
molarMass: 168.11,
|
||||||
|
panel: "metabolic",
|
||||||
|
aliases: ["uric acid", "urate"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- inflammation ---
|
||||||
|
{
|
||||||
|
key: "hs_crp",
|
||||||
|
display: "hs-CRP",
|
||||||
|
canonicalUnit: "mg/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "inflammation",
|
||||||
|
aliases: ["hs-crp", "hscrp", "hs crp", "high sensitivity crp", "high-sensitivity crp", "c-reactive protein", "crp"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- liver ---
|
||||||
|
{
|
||||||
|
key: "alt",
|
||||||
|
display: "ALT",
|
||||||
|
canonicalUnit: "U/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "liver",
|
||||||
|
aliases: ["alt", "sgpt", "alanine aminotransferase", "alanine transaminase"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ast",
|
||||||
|
display: "AST",
|
||||||
|
canonicalUnit: "U/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "liver",
|
||||||
|
aliases: ["ast", "sgot", "aspartate aminotransferase", "aspartate transaminase"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ggt",
|
||||||
|
display: "GGT",
|
||||||
|
canonicalUnit: "U/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "liver",
|
||||||
|
aliases: ["ggt", "gamma gt", "gamma-glutamyl transferase", "gamma glutamyl transferase"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "alp",
|
||||||
|
display: "ALP",
|
||||||
|
canonicalUnit: "U/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "liver",
|
||||||
|
aliases: ["alp", "alkaline phosphatase"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "bilirubin_total",
|
||||||
|
display: "Total Bilirubin",
|
||||||
|
canonicalUnit: "µmol/L",
|
||||||
|
molarMass: 584.66,
|
||||||
|
panel: "liver",
|
||||||
|
aliases: ["bilirubin", "total bilirubin", "bilirubin total", "bilirubin, total"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "albumin",
|
||||||
|
display: "Albumin",
|
||||||
|
canonicalUnit: "g/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "liver",
|
||||||
|
aliases: ["albumin"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "total_protein",
|
||||||
|
display: "Total Protein",
|
||||||
|
canonicalUnit: "g/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "liver",
|
||||||
|
aliases: ["total protein", "protein total", "protein, total"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- kidney ---
|
||||||
|
{
|
||||||
|
key: "creatinine",
|
||||||
|
display: "Creatinine",
|
||||||
|
canonicalUnit: "µmol/L",
|
||||||
|
molarMass: 113.12,
|
||||||
|
panel: "kidney",
|
||||||
|
aliases: ["creatinine", "serum creatinine"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "egfr",
|
||||||
|
display: "eGFR",
|
||||||
|
canonicalUnit: "mL/min/1.73m2",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "kidney",
|
||||||
|
aliases: ["egfr", "estimated gfr", "estimated glomerular filtration rate", "gfr estimated", "gfr, estimated"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "urea",
|
||||||
|
display: "Urea",
|
||||||
|
canonicalUnit: "mmol/L",
|
||||||
|
molarMass: 60.06,
|
||||||
|
panel: "kidney",
|
||||||
|
// Deliberately no "bun"/"blood urea nitrogen" aliases: BUN mg/dL -> urea
|
||||||
|
// mmol/L needs the nitrogen divisor (2N = 28.01 g/mol), not urea's own
|
||||||
|
// molar mass (60.06). Mapping BUN here would silently under-convert by
|
||||||
|
// ~2.14x. BUN rows stay unmapped (raw value preserved) until a proper
|
||||||
|
// BUN analyte with its own divisor is added.
|
||||||
|
aliases: ["urea"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- cbc ---
|
||||||
|
{
|
||||||
|
key: "hemoglobin",
|
||||||
|
display: "Hemoglobin",
|
||||||
|
canonicalUnit: "g/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "cbc",
|
||||||
|
aliases: ["hemoglobin", "haemoglobin", "hgb", "hb"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hematocrit",
|
||||||
|
display: "Hematocrit",
|
||||||
|
canonicalUnit: "%",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "cbc",
|
||||||
|
aliases: ["hematocrit", "haematocrit", "hct", "pcv", "packed cell volume"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "wbc",
|
||||||
|
display: "White Blood Cells",
|
||||||
|
canonicalUnit: "x10^9/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "cbc",
|
||||||
|
aliases: ["wbc", "white blood cells", "white blood cell count", "white cell count", "leukocytes", "leucocytes"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "rbc",
|
||||||
|
display: "Red Blood Cells",
|
||||||
|
canonicalUnit: "x10^12/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "cbc",
|
||||||
|
aliases: ["rbc", "red blood cells", "red blood cell count", "red cell count", "erythrocytes"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "platelets",
|
||||||
|
display: "Platelets",
|
||||||
|
canonicalUnit: "x10^9/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "cbc",
|
||||||
|
aliases: ["platelets", "platelet count", "plt"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mcv",
|
||||||
|
display: "MCV",
|
||||||
|
canonicalUnit: "fL",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "cbc",
|
||||||
|
aliases: ["mcv", "mean corpuscular volume", "mean cell volume"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "neutrophils",
|
||||||
|
display: "Neutrophils",
|
||||||
|
canonicalUnit: "x10^9/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "cbc",
|
||||||
|
aliases: ["neutrophils", "neutrophil count", "neutrophils absolute", "absolute neutrophils", "anc"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lymphocytes",
|
||||||
|
display: "Lymphocytes",
|
||||||
|
canonicalUnit: "x10^9/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "cbc",
|
||||||
|
aliases: ["lymphocytes", "lymphocyte count", "lymphocytes absolute", "absolute lymphocytes", "alc"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- vitamins ---
|
||||||
|
{
|
||||||
|
key: "vitamin_d",
|
||||||
|
display: "Vitamin D",
|
||||||
|
canonicalUnit: "nmol/L",
|
||||||
|
molarMass: 400.64,
|
||||||
|
panel: "vitamins",
|
||||||
|
aliases: [
|
||||||
|
"vitamin d",
|
||||||
|
"vitamin d3",
|
||||||
|
"25-oh vitamin d",
|
||||||
|
"vitamin d 25-hydroxy",
|
||||||
|
"25-hydroxyvitamin d",
|
||||||
|
"25-hydroxy vitamin d",
|
||||||
|
"25 oh vitamin d",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "vitamin_b12",
|
||||||
|
display: "Vitamin B12",
|
||||||
|
canonicalUnit: "pmol/L",
|
||||||
|
molarMass: 1355.4,
|
||||||
|
panel: "vitamins",
|
||||||
|
aliases: ["vitamin b12", "b12", "cobalamin"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "folate",
|
||||||
|
display: "Folate",
|
||||||
|
canonicalUnit: "nmol/L",
|
||||||
|
molarMass: 441.4,
|
||||||
|
panel: "vitamins",
|
||||||
|
aliases: ["folate", "folic acid", "vitamin b9"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- electrolytes ---
|
||||||
|
{
|
||||||
|
key: "sodium",
|
||||||
|
display: "Sodium",
|
||||||
|
canonicalUnit: "mmol/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "electrolytes",
|
||||||
|
aliases: ["sodium", "na"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "potassium",
|
||||||
|
display: "Potassium",
|
||||||
|
canonicalUnit: "mmol/L",
|
||||||
|
molarMass: null,
|
||||||
|
panel: "electrolytes",
|
||||||
|
aliases: ["potassium", "k"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "calcium",
|
||||||
|
display: "Calcium",
|
||||||
|
canonicalUnit: "mmol/L",
|
||||||
|
molarMass: 40.08,
|
||||||
|
panel: "electrolytes",
|
||||||
|
aliases: ["calcium", "calcium total", "total calcium", "ca"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "magnesium",
|
||||||
|
display: "Magnesium",
|
||||||
|
canonicalUnit: "mmol/L",
|
||||||
|
molarMass: 24.31,
|
||||||
|
panel: "electrolytes",
|
||||||
|
aliases: ["magnesium", "mg"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "zinc",
|
||||||
|
display: "Zinc",
|
||||||
|
canonicalUnit: "µmol/L",
|
||||||
|
molarMass: 65.38,
|
||||||
|
panel: "electrolytes",
|
||||||
|
aliases: ["zinc", "zn"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Resolution is alias/display lookup only — normalized to lowercase with
|
||||||
|
// punctuation collapsed to spaces so "Total Testosterone", "total-testosterone",
|
||||||
|
// and "TOTAL TESTOSTERONE" all hit the same registry entry.
|
||||||
|
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9%]+/g, " ").trim();
|
||||||
|
const INDEX = new Map<string, Analyte>();
|
||||||
|
for (const a of ANALYTES) {
|
||||||
|
INDEX.set(norm(a.display), a);
|
||||||
|
for (const al of a.aliases) INDEX.set(norm(al), a);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BY_KEY = new Map(ANALYTES.map((a) => [a.key, a]));
|
||||||
|
|
||||||
|
export function resolveAnalyte(name: string): Analyte | null {
|
||||||
|
return INDEX.get(norm(name)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAnalyte(key: string): Analyte | null {
|
||||||
|
return BY_KEY.get(key) ?? null;
|
||||||
|
}
|
||||||
15
server/src/lib/extract.ts
Normal file
15
server/src/lib/extract.ts
Normal file
@@ -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<ExtractedDraft> {
|
||||||
|
const out = await chatJSON(deps, { system: SYSTEM, user: text.slice(0, MAX_CHARS) });
|
||||||
|
const parsed = ExtractedDraft.safeParse(out);
|
||||||
|
if (!parsed.success) throw new Error("llm_error: draft failed schema validation");
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
38
server/src/lib/llm.ts
Normal file
38
server/src/lib/llm.ts
Normal file
@@ -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<unknown> {
|
||||||
|
const f = deps.fetchImpl ?? fetch;
|
||||||
|
const base = (await getSetting(deps.db, "llm_base_url", deps.key)) ?? DEFAULTS.llm_base_url;
|
||||||
|
const model = (await getSetting(deps.db, "llm_model", deps.key)) ?? DEFAULTS.llm_model;
|
||||||
|
const apiKey = await getSetting(deps.db, "llm_key", deps.key);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||||
|
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
||||||
|
|
||||||
|
const res = await f(`${base.replace(/\/$/, "")}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: opts.system },
|
||||||
|
{ role: "user", content: opts.user },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`llm_error: provider returned ${res.status}`);
|
||||||
|
|
||||||
|
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
||||||
|
const content = data.choices?.[0]?.message?.content;
|
||||||
|
if (!content) throw new Error("llm_error: empty completion");
|
||||||
|
const stripped = content.replace(/^\s*```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "");
|
||||||
|
try {
|
||||||
|
return JSON.parse(stripped);
|
||||||
|
} catch {
|
||||||
|
throw new Error("llm_error: completion was not valid JSON");
|
||||||
|
}
|
||||||
|
}
|
||||||
80
server/src/lib/normalize.ts
Normal file
80
server/src/lib/normalize.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { resolveAnalyte } from "./analytes";
|
||||||
|
import { convert } from "./units";
|
||||||
|
|
||||||
|
export interface RawMarker {
|
||||||
|
panel?: string | null;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
unit?: string | null;
|
||||||
|
referenceRange?: string | null;
|
||||||
|
flagged?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormMarker {
|
||||||
|
panel: string;
|
||||||
|
name: string;
|
||||||
|
marker: string;
|
||||||
|
analyteKey: string | null;
|
||||||
|
value: string;
|
||||||
|
valueNum: number | null;
|
||||||
|
unit: string | null;
|
||||||
|
referenceRange: string | null;
|
||||||
|
flagged: boolean;
|
||||||
|
valueCanonical: number | null;
|
||||||
|
canonicalUnit: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THOUSANDS_GROUPED = /^\d{1,3}(,\d{3})+(\.\d+)?$/;
|
||||||
|
const PLAIN_NUMBER = /^-?\d+(\.\d+)?$/;
|
||||||
|
|
||||||
|
// Strict numeric parsing: parseFloat alone stops at the first non-numeric
|
||||||
|
// character, so "1,200" silently became 1 (a 1000x error) and "5,5" (a
|
||||||
|
// European decimal) silently became 5. Instead: strip comparators/whitespace,
|
||||||
|
// then only accept (a) US thousands-grouping, comma-stripped, or (b) a plain
|
||||||
|
// number that spans the ENTIRE remaining string. Anything else — including
|
||||||
|
// ambiguous "5,5" — returns null so no canonical value is computed rather
|
||||||
|
// than a silently wrong one.
|
||||||
|
const num = (v: string): number | null => {
|
||||||
|
const stripped = v.replace(/[<>≤≥\s]/g, "");
|
||||||
|
if (THOUSANDS_GROUPED.test(stripped)) {
|
||||||
|
const n = parseFloat(stripped.replace(/,/g, ""));
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
if (PLAIN_NUMBER.test(stripped)) {
|
||||||
|
const n = parseFloat(stripped);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeMarker(raw: RawMarker): NormMarker {
|
||||||
|
const analyte = resolveAnalyte(raw.name);
|
||||||
|
const valueNum = num(raw.value);
|
||||||
|
const unit = raw.unit ?? null;
|
||||||
|
|
||||||
|
let valueCanonical: number | null = null;
|
||||||
|
let canonicalUnit: string | null = null;
|
||||||
|
if (analyte && valueNum !== null && unit) {
|
||||||
|
canonicalUnit = analyte.canonicalUnit;
|
||||||
|
valueCanonical = convert(valueNum, unit, analyte.canonicalUnit, analyte.molarMass);
|
||||||
|
if (valueCanonical === null && unit.toLowerCase().replace(/\s/g, "") === analyte.canonicalUnit.toLowerCase().replace(/\s/g, "")) {
|
||||||
|
valueCanonical = valueNum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Registry panel wins for mapped analytes so a marker groups the same way
|
||||||
|
// no matter which section a particular lab filed it under.
|
||||||
|
panel: analyte?.panel ?? raw.panel ?? "other",
|
||||||
|
name: raw.name,
|
||||||
|
marker: analyte?.key ?? raw.name,
|
||||||
|
analyteKey: analyte?.key ?? null,
|
||||||
|
value: raw.value,
|
||||||
|
valueNum,
|
||||||
|
unit,
|
||||||
|
referenceRange: raw.referenceRange ?? null,
|
||||||
|
flagged: raw.flagged === true,
|
||||||
|
valueCanonical,
|
||||||
|
canonicalUnit,
|
||||||
|
};
|
||||||
|
}
|
||||||
7
server/src/lib/pdf.ts
Normal file
7
server/src/lib/pdf.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { extractText, getDocumentProxy } from "unpdf";
|
||||||
|
|
||||||
|
export async function pdfToText(data: Uint8Array): Promise<string> {
|
||||||
|
const doc = await getDocumentProxy(data);
|
||||||
|
const { text } = await extractText(doc, { mergePages: true });
|
||||||
|
return text;
|
||||||
|
}
|
||||||
49
server/src/lib/units.ts
Normal file
49
server/src/lib/units.ts
Normal file
@@ -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<string, [Kind, number]> = {
|
||||||
|
g: ["mass", 1], mg: ["mass", 1e-3], ug: ["mass", 1e-6], mcg: ["mass", 1e-6],
|
||||||
|
"µg": ["mass", 1e-6], ng: ["mass", 1e-9], pg: ["mass", 1e-12],
|
||||||
|
mol: ["mole", 1], mmol: ["mole", 1e-3], umol: ["mole", 1e-6], "µmol": ["mole", 1e-6],
|
||||||
|
nmol: ["mole", 1e-9], pmol: ["mole", 1e-12],
|
||||||
|
cells: ["count", 1], k: ["count", 1e3], thousand: ["count", 1e3],
|
||||||
|
million: ["count", 1e6], "x10^9": ["count", 1e9], "x10^12": ["count", 1e12],
|
||||||
|
iu: ["iu", 1], miu: ["iu", 1e-3], uiu: ["iu", 1e-6], "µiu": ["iu", 1e-6], u: ["iu", 1],
|
||||||
|
};
|
||||||
|
const VOLUME: Record<string, number> = { l: 1, dl: 0.1, ml: 1e-3, ul: 1e-6, "µl": 1e-6 };
|
||||||
|
|
||||||
|
const clean = (u: string) => u.toLowerCase().replace(/\s+/g, "").replace("μ", "µ");
|
||||||
|
|
||||||
|
interface Parsed { kind: Kind; amountFactor: number; volumeFactor: number }
|
||||||
|
|
||||||
|
export function parseUnit(unit: string): Parsed | null {
|
||||||
|
const u = clean(unit);
|
||||||
|
if (u === "%") return { kind: "percent", amountFactor: 1, volumeFactor: 1 };
|
||||||
|
if (u === "" || u === "ratio" || u === "index") return { kind: "ratio", amountFactor: 1, volumeFactor: 1 };
|
||||||
|
const m = u.match(/^([a-zµ0-9^]+)\/([a-zµ0-9.]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const amtEntry = AMOUNT[m[1]];
|
||||||
|
const volFactor = VOLUME[m[2]];
|
||||||
|
if (amtEntry && volFactor) return { kind: amtEntry[0], amountFactor: amtEntry[1], volumeFactor: volFactor };
|
||||||
|
}
|
||||||
|
return { kind: "other", amountFactor: 1, volumeFactor: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convert(value: number, fromUnit: string, toUnit: string, molarMass?: number | null): number | null {
|
||||||
|
const from = parseUnit(fromUnit);
|
||||||
|
const to = parseUnit(toUnit);
|
||||||
|
if (!from || !to) return null;
|
||||||
|
if (clean(fromUnit) === clean(toUnit)) return value;
|
||||||
|
if (from.kind === "other" || to.kind === "other") return null;
|
||||||
|
|
||||||
|
const fromBasePerL = (value * from.amountFactor) / from.volumeFactor;
|
||||||
|
let toBasePerL = fromBasePerL;
|
||||||
|
if (from.kind !== to.kind) {
|
||||||
|
const bridge = (from.kind === "mass" && to.kind === "mole") || (from.kind === "mole" && to.kind === "mass");
|
||||||
|
if (!bridge || !molarMass) return null;
|
||||||
|
toBasePerL = from.kind === "mass" ? fromBasePerL / molarMass : fromBasePerL * molarMass;
|
||||||
|
}
|
||||||
|
return (toBasePerL * to.volumeFactor) / to.amountFactor;
|
||||||
|
}
|
||||||
189
server/src/routes/labs.ts
Normal file
189
server/src/routes/labs.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import type { Changes } from "bun:sqlite";
|
||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { existsSync, mkdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { Db } from "../db";
|
||||||
|
import { biomarkers, labDrafts, labDraws } from "../db/schema";
|
||||||
|
import { extractFromText } from "../lib/extract";
|
||||||
|
import { normalizeMarker } from "../lib/normalize";
|
||||||
|
import { pdfToText } from "../lib/pdf";
|
||||||
|
import { getAnalyte } from "../lib/analytes";
|
||||||
|
import { ConfirmDraftBody } from "@helios/shared";
|
||||||
|
|
||||||
|
export type LabsDeps = { db: Db; key: Buffer; dataDir: string; llmFetch?: typeof fetch };
|
||||||
|
|
||||||
|
const MAX_UPLOAD = 15 * 1024 * 1024;
|
||||||
|
|
||||||
|
export function labsRoutes(deps: LabsDeps) {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.post("/labs/upload", async (c) => {
|
||||||
|
const body = await c.req.parseBody();
|
||||||
|
const file = body.file;
|
||||||
|
if (!(file instanceof File)) return c.json({ error: "file field required" }, 400);
|
||||||
|
if (!file.name.toLowerCase().endsWith(".pdf") && file.type !== "application/pdf") {
|
||||||
|
return c.json({ error: "not_a_pdf" }, 400);
|
||||||
|
}
|
||||||
|
if (file.size > MAX_UPLOAD) return c.json({ error: "too_large" }, 400);
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
const uploadsDir = join(deps.dataDir, "uploads");
|
||||||
|
mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
const filePath = join(uploadsDir, `${id}.pdf`);
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
await Bun.write(filePath, bytes);
|
||||||
|
|
||||||
|
// Extraction failures land on the draft row so the user sees them in the
|
||||||
|
// review UI instead of the upload 500ing.
|
||||||
|
let extracted: string | null = null;
|
||||||
|
let error: string | null = null;
|
||||||
|
try {
|
||||||
|
const text = await pdfToText(bytes);
|
||||||
|
if (text.trim().length < 20) throw new Error("pdf_error: no text layer (scanned PDFs are not supported yet)");
|
||||||
|
const draft = await extractFromText({ db: deps.db, key: deps.key, fetchImpl: deps.llmFetch }, text);
|
||||||
|
extracted = JSON.stringify(draft);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : "extraction failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
await deps.db.insert(labDrafts).values({
|
||||||
|
id, filename: file.name, filePath, status: "pending", extracted, error, createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
return c.json({ id }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/labs/drafts", async (c) => {
|
||||||
|
const rows = await deps.db.select().from(labDrafts).orderBy(desc(labDrafts.createdAt));
|
||||||
|
return c.json({
|
||||||
|
drafts: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
filename: r.filename,
|
||||||
|
status: r.status,
|
||||||
|
error: r.error,
|
||||||
|
markerCount: r.extracted ? (JSON.parse(r.extracted).markers?.length ?? 0) : 0,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/labs/drafts/:id", async (c) => {
|
||||||
|
const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0);
|
||||||
|
if (!row) return c.json({ error: "not found" }, 404);
|
||||||
|
return c.json({
|
||||||
|
id: row.id, filename: row.filename, status: row.status, error: row.error,
|
||||||
|
draft: row.extracted ? JSON.parse(row.extracted) : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/labs/drafts/:id/confirm", async (c) => {
|
||||||
|
const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0);
|
||||||
|
if (!row) return c.json({ error: "not found" }, 404);
|
||||||
|
if (row.status !== "pending") return c.json({ error: "draft is not pending" }, 409);
|
||||||
|
const body = ConfirmDraftBody.safeParse(await c.req.json().catch(() => null));
|
||||||
|
if (!body.success) return c.json({ error: body.error.issues[0]?.message ?? "invalid body" }, 400);
|
||||||
|
|
||||||
|
const drawId = randomUUID();
|
||||||
|
const norm = body.data.markers.map((m) => normalizeMarker({
|
||||||
|
panel: m.panel, name: m.name, value: m.value, unit: m.unit,
|
||||||
|
referenceRange: m.referenceRange, flagged: m.flagged,
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
deps.db.transaction((tx) => {
|
||||||
|
// Guarded status transition (pending -> confirmed) inside the same
|
||||||
|
// synchronous transaction as the inserts below, so two concurrent
|
||||||
|
// confirms of the same draft can't both pass the earlier status
|
||||||
|
// check (before the request body was even read) and double-insert.
|
||||||
|
// Only the request that actually flips the row gets to write rows.
|
||||||
|
// drizzle-orm's bun-sqlite types pin TRunResult to `void`, but at
|
||||||
|
// runtime bun:sqlite's Statement.run() actually returns a
|
||||||
|
// `{ changes, lastInsertRowid }` Changes object — confirmed in
|
||||||
|
// node_modules/bun-types/sqlite.d.ts. Cast to the real runtime type.
|
||||||
|
const updated = tx.update(labDrafts).set({ status: "confirmed" })
|
||||||
|
.where(and(eq(labDrafts.id, row.id), eq(labDrafts.status, "pending"))).run() as unknown as Changes;
|
||||||
|
if (updated.changes === 0) throw new Error("draft_not_pending");
|
||||||
|
|
||||||
|
tx.insert(labDraws).values({
|
||||||
|
id: drawId, collectedAt: body.data.collectedDate, labName: body.data.labName,
|
||||||
|
draftId: row.id, createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
for (const n of norm) {
|
||||||
|
tx.insert(biomarkers).values({
|
||||||
|
drawId, panel: n.panel, name: n.name, marker: n.marker, analyteKey: n.analyteKey,
|
||||||
|
value: n.value, valueNum: n.valueNum, unit: n.unit, referenceRange: n.referenceRange,
|
||||||
|
flagged: n.flagged ? 1 : 0, valueCanonical: n.valueCanonical, canonicalUnit: n.canonicalUnit,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message === "draft_not_pending") {
|
||||||
|
return c.json({ error: "draft is not pending" }, 409);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return c.json({ drawId }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/labs/drafts/:id/discard", async (c) => {
|
||||||
|
const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0);
|
||||||
|
if (!row) return c.json({ error: "not found" }, 404);
|
||||||
|
await deps.db.update(labDrafts).set({ status: "discarded" }).where(eq(labDrafts.id, row.id));
|
||||||
|
return c.body(null, 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/labs/draws", async (c) => {
|
||||||
|
const draws = await deps.db.select().from(labDraws).orderBy(desc(labDraws.collectedAt));
|
||||||
|
const rows = await deps.db.select().from(biomarkers);
|
||||||
|
return c.json({
|
||||||
|
draws: draws.map((d) => ({
|
||||||
|
id: d.id, collectedAt: d.collectedAt, labName: d.labName,
|
||||||
|
markerCount: rows.filter((r) => r.drawId === d.id).length,
|
||||||
|
flaggedCount: rows.filter((r) => r.drawId === d.id && r.flagged === 1).length,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/labs/draws/:id", async (c) => {
|
||||||
|
const d = (await deps.db.select().from(labDraws).where(eq(labDraws.id, c.req.param("id")))).at(0);
|
||||||
|
if (!d) return c.json({ error: "not found" }, 404);
|
||||||
|
const rows = await deps.db.select().from(biomarkers).where(eq(biomarkers.drawId, d.id));
|
||||||
|
const byPanel = new Map<string, typeof rows>();
|
||||||
|
for (const r of rows) byPanel.set(r.panel, [...(byPanel.get(r.panel) ?? []), r]);
|
||||||
|
return c.json({
|
||||||
|
id: d.id, collectedAt: d.collectedAt, labName: d.labName,
|
||||||
|
panels: [...byPanel.keys()].sort().map((panel) => ({
|
||||||
|
panel,
|
||||||
|
markers: byPanel.get(panel)!
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map((r) => ({ ...r, flagged: r.flagged === 1 })),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/labs/markers/:key/history", async (c) => {
|
||||||
|
const analyte = getAnalyte(c.req.param("key"));
|
||||||
|
if (!analyte) return c.json({ error: "unknown marker" }, 404);
|
||||||
|
const draws = await deps.db.select().from(labDraws);
|
||||||
|
const dates = new Map(draws.map((d) => [d.id, d.collectedAt]));
|
||||||
|
const rows = await deps.db.select().from(biomarkers).where(eq(biomarkers.analyteKey, analyte.key));
|
||||||
|
const points = rows
|
||||||
|
.filter((r) => r.valueCanonical !== null)
|
||||||
|
.map((r) => ({ drawId: r.drawId, collectedAt: dates.get(r.drawId) ?? "", value: r.valueCanonical! }))
|
||||||
|
.sort((a, b) => a.collectedAt.localeCompare(b.collectedAt));
|
||||||
|
return c.json({ key: analyte.key, display: analyte.display, canonicalUnit: analyte.canonicalUnit, points });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/labs/drafts/:id/file", async (c) => {
|
||||||
|
const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0);
|
||||||
|
if (!row || !existsSync(row.filePath)) return c.json({ error: "not found" }, 404);
|
||||||
|
return new Response(Bun.file(row.filePath), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/pdf",
|
||||||
|
"content-disposition": `inline; filename="${row.filename.replace(/[^\w.-]/g, "_")}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import type { Db } from "../db";
|
|||||||
import { settings } from "../db/schema";
|
import { settings } from "../db/schema";
|
||||||
import { decrypt, encrypt, mask } from "../lib/crypto";
|
import { decrypt, encrypt, mask } from "../lib/crypto";
|
||||||
|
|
||||||
const DEFAULTS = {
|
export const DEFAULTS = {
|
||||||
llm_base_url: "https://openrouter.ai/api/v1",
|
llm_base_url: "https://openrouter.ai/api/v1",
|
||||||
llm_model: "anthropic/claude-sonnet-4.5",
|
llm_model: "anthropic/claude-sonnet-4.5",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { loadOrCreateKey } from "../src/lib/crypto";
|
|||||||
|
|
||||||
function makeApp() {
|
function makeApp() {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
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) => ({
|
const json = (body: unknown) => ({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -54,7 +54,7 @@ describe("auth", () => {
|
|||||||
// The global window applies to everyone, including a request with the correct password.
|
// The global window applies to everyone, including a request with the correct password.
|
||||||
const blocked = await app.request("/api/login", json({ password: "hunter2hunter2" }));
|
const blocked = await app.request("/api/login", json({ password: "hunter2hunter2" }));
|
||||||
expect(blocked.status).toBe(429);
|
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 () => {
|
test("unknown /api/* returns 404 when authed, 401 when unauthed", async () => {
|
||||||
const app = makeApp();
|
const app = makeApp();
|
||||||
|
|||||||
21
server/test/db-labs.test.ts
Normal file
21
server/test/db-labs.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
46
server/test/extract.test.ts
Normal file
46
server/test/extract.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { openDb } from "../src/db";
|
||||||
|
import { loadOrCreateKey } from "../src/lib/crypto";
|
||||||
|
import { extractFromText } from "../src/lib/extract";
|
||||||
|
|
||||||
|
const FIXTURE_TEXT = `
|
||||||
|
Sample Diagnostics — Final Report
|
||||||
|
Collected: 15 Jan 2026
|
||||||
|
CHEMISTRY
|
||||||
|
Glucose (Fasting) 100 mg/dL (70-99) H
|
||||||
|
LIPIDS
|
||||||
|
LDL Cholesterol 3.1 mmol/L (<3.4)
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("extractFromText", () => {
|
||||||
|
test("passes text to LLM and validates the draft shape", async () => {
|
||||||
|
let userPrompt = "";
|
||||||
|
const mock = (async (_: any, init: any) => {
|
||||||
|
userPrompt = JSON.parse(String(init.body)).messages[1].content;
|
||||||
|
return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({
|
||||||
|
collectedDate: "2026-01-15",
|
||||||
|
labName: "Sample Diagnostics",
|
||||||
|
markers: [
|
||||||
|
{ panel: "chemistry", name: "Glucose (Fasting)", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true },
|
||||||
|
{ panel: "lipids", name: "LDL Cholesterol", value: "3.1", unit: "mmol/L", referenceRange: "<3.4", flagged: false },
|
||||||
|
],
|
||||||
|
}) } }] }), { status: 200 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
||||||
|
const draft = await extractFromText({ db: openDb(dir), key: loadOrCreateKey(dir), fetchImpl: mock }, FIXTURE_TEXT);
|
||||||
|
expect(userPrompt).toContain("Glucose (Fasting)");
|
||||||
|
expect(draft.markers).toHaveLength(2);
|
||||||
|
expect(draft.collectedDate).toBe("2026-01-15");
|
||||||
|
expect(draft.markers[0].flagged).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("malformed LLM output → llm_error, not a crash", async () => {
|
||||||
|
const mock = (async () => new Response(JSON.stringify({ choices: [{ message: { content: '{"markers": "not an array"}' } }] }), { status: 200 })) as unknown as typeof fetch;
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
||||||
|
await expect(extractFromText({ db: openDb(dir), key: loadOrCreateKey(dir), fetchImpl: mock }, "x")).rejects.toThrow(/llm_error/);
|
||||||
|
});
|
||||||
|
});
|
||||||
112
server/test/labs-confirm.test.ts
Normal file
112
server/test/labs-confirm.test.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { createApp } from "../src/app";
|
||||||
|
import { openDb } from "../src/db";
|
||||||
|
import { biomarkers, labDrafts, labDraws } from "../src/db/schema";
|
||||||
|
import { loadOrCreateKey } from "../src/lib/crypto";
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
||||||
|
const db = openDb(dir);
|
||||||
|
const app = createApp({ db, key: loadOrCreateKey(dir), dataDir: dir });
|
||||||
|
const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
|
||||||
|
await app.request("/api/setup", j({ password: "hunter2hunter2" }));
|
||||||
|
const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!;
|
||||||
|
await db.insert(labDrafts).values({
|
||||||
|
id: "draft1", filename: "r.pdf", filePath: "/tmp/none.pdf", status: "pending",
|
||||||
|
extracted: JSON.stringify({ collectedDate: "2026-01-15", labName: "Sample Diagnostics", markers: [
|
||||||
|
{ panel: "chemistry", name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true },
|
||||||
|
] }),
|
||||||
|
error: null, createdAt: 1,
|
||||||
|
});
|
||||||
|
return { app, db, cookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("draft review", () => {
|
||||||
|
test("get, confirm with edits → normalized biomarkers, draft confirmed", async () => {
|
||||||
|
const { app, db, cookie } = await setup();
|
||||||
|
const h = { cookie, "content-type": "application/json" };
|
||||||
|
|
||||||
|
const got = await (await app.request("/api/labs/drafts/draft1", { headers: { cookie } })).json();
|
||||||
|
expect(got.draft.markers).toHaveLength(1);
|
||||||
|
|
||||||
|
const confirm = await app.request("/api/labs/drafts/draft1/confirm", {
|
||||||
|
method: "POST", headers: h,
|
||||||
|
body: JSON.stringify({
|
||||||
|
collectedDate: "2026-01-15",
|
||||||
|
labName: "Sample Diagnostics",
|
||||||
|
markers: [
|
||||||
|
{ panel: "chemistry", name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true },
|
||||||
|
{ panel: null, name: "Ferritin", value: "30", unit: "ng/mL", referenceRange: null, flagged: false }, // user-added row
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(confirm.status).toBe(201);
|
||||||
|
|
||||||
|
const rows = await db.select().from(biomarkers);
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
const glucose = rows.find((r) => r.analyteKey === "glucose")!;
|
||||||
|
expect(glucose.valueCanonical).toBeCloseTo(5.551, 2);
|
||||||
|
expect(glucose.flagged).toBe(1);
|
||||||
|
const ferritin = rows.find((r) => r.analyteKey === "ferritin")!;
|
||||||
|
expect(ferritin.valueCanonical).toBeCloseTo(30); // ng/mL → µg/L 1:1
|
||||||
|
expect(ferritin.canonicalUnit).toBe("µg/L");
|
||||||
|
|
||||||
|
const draft = (await db.select().from(labDrafts).where(eq(labDrafts.id, "draft1")))[0];
|
||||||
|
expect(draft.status).toBe("confirmed");
|
||||||
|
|
||||||
|
// second confirm → 409
|
||||||
|
const again = await app.request("/api/labs/drafts/draft1/confirm", {
|
||||||
|
method: "POST", headers: h,
|
||||||
|
body: JSON.stringify({ collectedDate: "2026-01-15", labName: null, markers: [{ panel: null, name: "X", value: "1", unit: null, referenceRange: null, flagged: false }] }),
|
||||||
|
});
|
||||||
|
expect(again.status).toBe(409);
|
||||||
|
|
||||||
|
// Second (rejected) confirm must not have inserted a second draw or any
|
||||||
|
// extra biomarker rows — exactly one draw, one set of biomarkers.
|
||||||
|
const drawsAfter = await db.select().from(labDraws);
|
||||||
|
expect(drawsAfter).toHaveLength(1);
|
||||||
|
const rowsAfter = await db.select().from(biomarkers);
|
||||||
|
expect(rowsAfter).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("discard marks draft discarded", async () => {
|
||||||
|
const { app, db, cookie } = await setup();
|
||||||
|
const r = await app.request("/api/labs/drafts/draft1/discard", { method: "POST", headers: { cookie } });
|
||||||
|
expect(r.status).toBe(204);
|
||||||
|
const draft = (await db.select().from(labDrafts).where(eq(labDrafts.id, "draft1")))[0];
|
||||||
|
expect(draft.status).toBe("discarded");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown draft 404; bad date 400", async () => {
|
||||||
|
const { app, cookie } = await setup();
|
||||||
|
expect((await app.request("/api/labs/drafts/nope", { headers: { cookie } })).status).toBe(404);
|
||||||
|
const bad = await app.request("/api/labs/drafts/draft1/confirm", {
|
||||||
|
method: "POST", headers: { cookie, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ collectedDate: "15/01/2026", labName: null, markers: [{ panel: null, name: "X", value: "1", unit: null, referenceRange: null, flagged: false }] }),
|
||||||
|
});
|
||||||
|
expect(bad.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sync transaction rolls back on mid-loop failure", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
||||||
|
const db = openDb(dir);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
db.transaction((tx) => {
|
||||||
|
tx.insert(labDraws).values({ id: "dX", collectedAt: "2026-01-01", labName: null, draftId: null, createdAt: 1 }).run();
|
||||||
|
tx.insert(biomarkers).values({ drawId: "dX", panel: "p", name: "n", marker: "m", analyteKey: null, value: "1", valueNum: 1, unit: null, referenceRange: null, flagged: 0, valueCanonical: null, canonicalUnit: null }).run();
|
||||||
|
throw new Error("boom");
|
||||||
|
}),
|
||||||
|
).toThrow("boom");
|
||||||
|
|
||||||
|
// Verify both tables are empty after rollback
|
||||||
|
const drawRows = db.select().from(labDraws).all();
|
||||||
|
const bioRows = db.select().from(biomarkers).all();
|
||||||
|
expect(drawRows).toHaveLength(0);
|
||||||
|
expect(bioRows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
server/test/labs-query.test.ts
Normal file
55
server/test/labs-query.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
70
server/test/labs-upload.test.ts
Normal file
70
server/test/labs-upload.test.ts
Normal file
@@ -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<</Type/Catalog/Pages 2 0 R>>endobj
|
||||||
|
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||||
|
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj
|
||||||
|
4 0 obj<</Length 60>>stream
|
||||||
|
BT /F1 12 Tf 72 720 Td (Glucose 100 mg/dL 70-99 H) Tj ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
|
||||||
|
trailer<</Root 1 0 R>>`;
|
||||||
|
|
||||||
|
async function authedApp(fetchImpl: typeof fetch) {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
||||||
|
const app = createApp({ db: openDb(dir), key: loadOrCreateKey(dir), dataDir: dir, llmFetch: fetchImpl });
|
||||||
|
const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
|
||||||
|
await app.request("/api/setup", j({ password: "hunter2hunter2" }));
|
||||||
|
const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!;
|
||||||
|
return { app, cookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
const llmOk = (async () => new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({
|
||||||
|
collectedDate: "2026-01-15", labName: "Sample Diagnostics",
|
||||||
|
markers: [{ panel: null, name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true }],
|
||||||
|
}) } }] }), { status: 200 })) as 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
51
server/test/llm.test.ts
Normal file
51
server/test/llm.test.ts
Normal file
@@ -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<string, string>).authorization).toBe("Bearer sk-test-1234");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no key → no auth header (Ollama)", async () => {
|
||||||
|
let headers: Record<string, string> = {};
|
||||||
|
const mock = (async (_: any, init: any) => {
|
||||||
|
headers = init.headers;
|
||||||
|
return new Response(JSON.stringify({ choices: [{ message: { content: "```json\n{\"a\":2}\n```" } }] }), { status: 200 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
const out = await chatJSON(deps(mock), { system: "s", user: "u" });
|
||||||
|
expect(out).toEqual({ a: 2 }); // fenced JSON stripped
|
||||||
|
expect(headers.authorization).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-2xx throws llm_error", async () => {
|
||||||
|
const mock = (async () => new Response("nope", { status: 401 })) as unknown as typeof fetch;
|
||||||
|
await expect(chatJSON(deps(mock), { system: "s", user: "u" })).rejects.toThrow(/llm_error/);
|
||||||
|
});
|
||||||
|
});
|
||||||
81
server/test/normalize.test.ts
Normal file
81
server/test/normalize.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { resolveAnalyte } from "../src/lib/analytes";
|
||||||
|
import { normalizeMarker } from "../src/lib/normalize";
|
||||||
|
import { convert } from "../src/lib/units";
|
||||||
|
|
||||||
|
describe("units", () => {
|
||||||
|
test("dimensional scaling: ng/mL → µg/L is 1:1", () => {
|
||||||
|
expect(convert(30, "ng/mL", "µg/L")).toBeCloseTo(30);
|
||||||
|
});
|
||||||
|
test("mass→mole bridge needs molar mass", () => {
|
||||||
|
expect(convert(100, "mg/dL", "mmol/L")).toBeNull();
|
||||||
|
expect(convert(100, "mg/dL", "mmol/L", 180.16)).toBeCloseTo(5.551, 2); // glucose
|
||||||
|
});
|
||||||
|
test("mole→mass: testosterone 20 nmol/L → ng/dL", () => {
|
||||||
|
expect(convert(20, "nmol/L", "ng/dL", 288.42)).toBeCloseTo(576.8, 0);
|
||||||
|
});
|
||||||
|
test("percent only converts to percent", () => {
|
||||||
|
expect(convert(42, "%", "%")).toBe(42);
|
||||||
|
expect(convert(42, "%", "mg/dL")).toBeNull();
|
||||||
|
});
|
||||||
|
test("count units: x10^9/L → thousand/uL is 1:1", () => {
|
||||||
|
expect(convert(6.1, "x10^9/L", "thousand/uL")).toBeCloseTo(6.1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("analytes", () => {
|
||||||
|
test("alias resolution is case/space-insensitive", () => {
|
||||||
|
expect(resolveAnalyte("Total Testosterone")?.key).toBe("testosterone_total");
|
||||||
|
expect(resolveAnalyte("HbA1c")?.key).toBe("hba1c");
|
||||||
|
expect(resolveAnalyte("definitely not a marker")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeMarker", () => {
|
||||||
|
test("known analyte converts to canonical unit and registry panel", () => {
|
||||||
|
const n = normalizeMarker({ panel: "chemistry", name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true });
|
||||||
|
expect(n.analyteKey).toBe("glucose");
|
||||||
|
expect(n.panel).toBe("metabolic"); // registry panel wins for consistent grouping
|
||||||
|
expect(n.valueCanonical).toBeCloseTo(5.551, 2);
|
||||||
|
expect(n.canonicalUnit).toBe("mmol/L");
|
||||||
|
expect(n.value).toBe("100"); // raw preserved
|
||||||
|
expect(n.flagged).toBe(true);
|
||||||
|
});
|
||||||
|
test("unknown marker keeps raw fields, no canonical value", () => {
|
||||||
|
const n = normalizeMarker({ name: "Exotic Marker X", value: "1.2", unit: "u/L" });
|
||||||
|
expect(n.analyteKey).toBeNull();
|
||||||
|
expect(n.marker).toBe("Exotic Marker X");
|
||||||
|
expect(n.valueCanonical).toBeNull();
|
||||||
|
});
|
||||||
|
test("comparator values parse numerically", () => {
|
||||||
|
const n = normalizeMarker({ name: "hs-CRP", value: "<0.3", unit: "mg/L" });
|
||||||
|
expect(n.valueNum).toBeCloseTo(0.3);
|
||||||
|
expect(n.value).toBe("<0.3");
|
||||||
|
});
|
||||||
|
test("BUN is not mapped onto urea (needs N-divisor, not molar mass)", () => {
|
||||||
|
expect(resolveAnalyte("BUN")).toBeNull();
|
||||||
|
expect(resolveAnalyte("blood urea nitrogen")).toBeNull();
|
||||||
|
expect(resolveAnalyte("urea")?.key).toBe("urea");
|
||||||
|
});
|
||||||
|
test("DHEA-S µg/dL converts with corrected molar mass", () => {
|
||||||
|
const n = normalizeMarker({ name: "DHEA-S", value: "250", unit: "ug/dL" });
|
||||||
|
expect(n.valueCanonical).toBeCloseTo(6.78, 1); // 250 * 0.02713
|
||||||
|
});
|
||||||
|
test("thousands-grouped value parses as 1200, not 1 (parseFloat truncation bug)", () => {
|
||||||
|
const n = normalizeMarker({ name: "Vitamin B12", value: "1,200", unit: "pg/mL" });
|
||||||
|
expect(n.valueNum).toBe(1200);
|
||||||
|
expect(n.analyteKey).toBe("vitamin_b12");
|
||||||
|
expect(n.valueCanonical).toBeCloseTo(885.35, 1);
|
||||||
|
expect(n.canonicalUnit).toBe("pmol/L");
|
||||||
|
});
|
||||||
|
test("ambiguous European-decimal-looking value is left unmapped, not silently wrong", () => {
|
||||||
|
const n = normalizeMarker({ name: "Glucose", value: "5,5", unit: "mg/dL" });
|
||||||
|
expect(n.valueNum).toBeNull();
|
||||||
|
expect(n.valueCanonical).toBeNull();
|
||||||
|
expect(n.value).toBe("5,5"); // raw value preserved
|
||||||
|
});
|
||||||
|
test("comparator value still parses after strict-parsing rewrite", () => {
|
||||||
|
const n = normalizeMarker({ name: "hs-CRP", value: "<0.3", unit: "mg/L" });
|
||||||
|
expect(n.valueNum).toBeCloseTo(0.3);
|
||||||
|
});
|
||||||
|
});
|
||||||
26
server/test/security-headers.test.ts
Normal file
26
server/test/security-headers.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@ import { loadOrCreateKey } from "../src/lib/crypto";
|
|||||||
async function authedApp() {
|
async function authedApp() {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
||||||
const db = openDb(dir);
|
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) });
|
const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
|
||||||
await app.request("/api/setup", j({ password: "hunter2hunter2" }));
|
await app.request("/api/setup", j({ password: "hunter2hunter2" }));
|
||||||
const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!;
|
const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!;
|
||||||
|
|||||||
@@ -20,3 +20,27 @@ export const SettingsUpdate = z.object({
|
|||||||
llmKey: z.string().min(1).optional(),
|
llmKey: z.string().min(1).optional(),
|
||||||
});
|
});
|
||||||
export type SettingsUpdate = z.infer<typeof SettingsUpdate>;
|
export type SettingsUpdate = z.infer<typeof SettingsUpdate>;
|
||||||
|
|
||||||
|
export const ExtractedMarker = z.object({
|
||||||
|
panel: z.string().nullable().default(null),
|
||||||
|
name: z.string().min(1),
|
||||||
|
value: z.string().min(1),
|
||||||
|
unit: z.string().nullable().default(null),
|
||||||
|
referenceRange: z.string().nullable().default(null),
|
||||||
|
flagged: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
export type ExtractedMarker = z.infer<typeof ExtractedMarker>;
|
||||||
|
|
||||||
|
export const ExtractedDraft = z.object({
|
||||||
|
collectedDate: z.string().nullable().default(null),
|
||||||
|
labName: z.string().nullable().default(null),
|
||||||
|
markers: z.array(ExtractedMarker).default([]),
|
||||||
|
});
|
||||||
|
export type ExtractedDraft = z.infer<typeof ExtractedDraft>;
|
||||||
|
|
||||||
|
export const ConfirmDraftBody = z.object({
|
||||||
|
collectedDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
labName: z.string().nullable().default(null),
|
||||||
|
markers: z.array(ExtractedMarker).min(1),
|
||||||
|
});
|
||||||
|
export type ConfirmDraftBody = z.infer<typeof ConfirmDraftBody>;
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { api } from "./api";
|
|||||||
import { Gate } from "./pages/Gate";
|
import { Gate } from "./pages/Gate";
|
||||||
import { Settings } from "./pages/Settings";
|
import { Settings } from "./pages/Settings";
|
||||||
import { Today } from "./pages/Today";
|
import { Today } from "./pages/Today";
|
||||||
|
import { Labs } from "./pages/Labs";
|
||||||
|
import { LabDraft } from "./pages/LabDraft";
|
||||||
|
import { LabDraw } from "./pages/LabDraw";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [me, setMe] = useState<MeResponse | null>(null);
|
const [me, setMe] = useState<MeResponse | null>(null);
|
||||||
@@ -18,11 +21,15 @@ export function App() {
|
|||||||
<div className="shell">
|
<div className="shell">
|
||||||
<nav>
|
<nav>
|
||||||
<Link to="/">Today</Link>
|
<Link to="/">Today</Link>
|
||||||
|
<Link to="/labs">Labs</Link>
|
||||||
<Link to="/settings">Settings</Link>
|
<Link to="/settings">Settings</Link>
|
||||||
<button onClick={() => api.logout().then(refresh)}>Log out</button>
|
<button onClick={() => api.logout().then(refresh)}>Log out</button>
|
||||||
</nav>
|
</nav>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Today />} />
|
<Route path="/" element={<Today />} />
|
||||||
|
<Route path="/labs" element={<Labs />} />
|
||||||
|
<Route path="/labs/draft/:id" element={<LabDraft />} />
|
||||||
|
<Route path="/labs/draw/:id" element={<LabDraw />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(path, { ...init, headers: { "content-type": "application/json", ...init?.headers } });
|
const res = await fetch(path, { ...init, headers: { "content-type": "application/json", ...init?.headers } });
|
||||||
@@ -6,6 +6,24 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
return res.status === 204 ? (undefined as T) : res.json();
|
return res.status === 204 ? (undefined as T) : res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadFile<T>(path: string, file: File): Promise<T> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch(path, { method: "POST", body: fd });
|
||||||
|
if (!res.ok) throw new Error((await res.json().catch(() => ({ error: res.statusText }))).error ?? res.statusText);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
export const api = {
|
||||||
me: () => req<MeResponse>("/api/me"),
|
me: () => req<MeResponse>("/api/me"),
|
||||||
setup: (password: string) => req<void>("/api/setup", { method: "POST", body: JSON.stringify({ password }) }),
|
setup: (password: string) => req<void>("/api/setup", { method: "POST", body: JSON.stringify({ password }) }),
|
||||||
@@ -13,4 +31,12 @@ export const api = {
|
|||||||
logout: () => req<void>("/api/logout", { method: "POST" }),
|
logout: () => req<void>("/api/logout", { method: "POST" }),
|
||||||
getSettings: () => req<SettingsResponse>("/api/settings"),
|
getSettings: () => req<SettingsResponse>("/api/settings"),
|
||||||
putSettings: (body: SettingsUpdate) => req<void>("/api/settings", { method: "PUT", body: JSON.stringify(body) }),
|
putSettings: (body: SettingsUpdate) => req<void>("/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<void>(`/api/labs/drafts/${id}/discard`, { method: "POST" }),
|
||||||
|
labDraws: () => req<{ draws: { id: string; collectedAt: string; labName: string | null; markerCount: number; flaggedCount: number }[] }>("/api/labs/draws"),
|
||||||
|
labDraw: (id: string) => req<LabDrawDetail>(`/api/labs/draws/${id}`),
|
||||||
|
markerHistory: (key: string) => req<{ key: string; display: string; canonicalUnit: string; points: { drawId: string; collectedAt: string; value: number }[] }>(`/api/labs/markers/${key}/history`),
|
||||||
};
|
};
|
||||||
|
|||||||
14
web/src/components/Sparkline.tsx
Normal file
14
web/src/components/Sparkline.tsx
Normal file
@@ -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 (
|
||||||
|
<svg width={width} height={height} className="sparkline" aria-hidden>
|
||||||
|
<path d={path} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
|
||||||
|
<circle cx={(points.length - 1) * step} cy={height - 4 - ((points[points.length - 1] - min) / span) * (height - 8)} r={3} fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
web/src/pages/LabDraft.tsx
Normal file
99
web/src/pages/LabDraft.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router";
|
||||||
|
import type { ExtractedMarker } from "@helios/shared";
|
||||||
|
import { api } from "../api";
|
||||||
|
|
||||||
|
export function LabDraft() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const nav = useNavigate();
|
||||||
|
const [meta, setMeta] = useState<{ filename: string; status: string; error: string | null } | null>(null);
|
||||||
|
const [collectedDate, setCollectedDate] = useState("");
|
||||||
|
const [labName, setLabName] = useState("");
|
||||||
|
const [markers, setMarkers] = useState<ExtractedMarker[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
api.labDraft(id).then((r) => {
|
||||||
|
setMeta({ filename: r.filename, status: r.status, error: r.error });
|
||||||
|
if (r.draft) {
|
||||||
|
setCollectedDate(r.draft.collectedDate ?? "");
|
||||||
|
setLabName(r.draft.labName ?? "");
|
||||||
|
setMarkers(r.draft.markers);
|
||||||
|
}
|
||||||
|
}).catch((e) => setError(e.message));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const edit = (i: number, patch: Partial<ExtractedMarker>) =>
|
||||||
|
setMarkers((m) => m.map((row, j) => (j === i ? { ...row, ...patch } : row)));
|
||||||
|
const remove = (i: number) => setMarkers((m) => m.filter((_, j) => j !== i));
|
||||||
|
const addRow = () => setMarkers((m) => [...m, { panel: null, name: "", value: "", unit: null, referenceRange: null, flagged: false }]);
|
||||||
|
|
||||||
|
const confirm = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { drawId } = await api.confirmDraft(id, { collectedDate, labName: labName || null, markers });
|
||||||
|
nav(`/labs/draw/${drawId}`);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "confirm failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!meta) return <p>Loading…</p>;
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2>Review: {meta.filename}</h2>
|
||||||
|
{meta.error && <p className="error">Extraction failed: {meta.error}. You can still enter values manually below.</p>}
|
||||||
|
{meta.status !== "pending" && <p className="muted">This draft is already {meta.status}.</p>}
|
||||||
|
<p className="hint">Check every value against the PDF (<a href={`/api/labs/drafts/${id}/file`} target="_blank" rel="noreferrer">open original</a>). Nothing is saved until you confirm.</p>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<label>Collected <input type="date" value={collectedDate} onChange={(e) => setCollectedDate(e.target.value)} /></label>
|
||||||
|
<label>Lab <input value={labName} onChange={(e) => setLabName(e.target.value)} placeholder="lab name" /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="review-table">
|
||||||
|
<thead><tr><th>Name</th><th>Value</th><th>Unit</th><th>Range</th><th>Flag</th><th /></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{markers.map((m, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td><input value={m.name} onChange={(e) => edit(i, { name: e.target.value })} /></td>
|
||||||
|
<td><input value={m.value} onChange={(e) => edit(i, { value: e.target.value })} /></td>
|
||||||
|
<td><input value={m.unit ?? ""} onChange={(e) => edit(i, { unit: e.target.value || null })} /></td>
|
||||||
|
<td><input value={m.referenceRange ?? ""} onChange={(e) => edit(i, { referenceRange: e.target.value || null })} /></td>
|
||||||
|
<td><input type="checkbox" checked={m.flagged} onChange={(e) => edit(i, { flagged: e.target.checked })} /></td>
|
||||||
|
<td><button className="link" onClick={() => remove(i)}>✕</button></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button className="link" onClick={addRow}>+ add marker</button>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{meta.status === "pending" && (
|
||||||
|
<div className="row">
|
||||||
|
<button disabled={busy || !collectedDate || markers.length === 0} onClick={confirm}>
|
||||||
|
{busy ? "Saving…" : `Confirm ${markers.length} markers`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="danger"
|
||||||
|
onClick={() =>
|
||||||
|
id &&
|
||||||
|
api
|
||||||
|
.discardDraft(id)
|
||||||
|
.then(() => nav("/labs"))
|
||||||
|
.catch((e) => setError(e instanceof Error ? e.message : "discard failed"))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
web/src/pages/LabDraw.tsx
Normal file
66
web/src/pages/LabDraw.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Fragment, useEffect, useState } from "react";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import { api, type LabDrawDetail } from "../api";
|
||||||
|
import { Sparkline } from "../components/Sparkline";
|
||||||
|
|
||||||
|
export function LabDraw() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [detail, setDetail] = useState<LabDrawDetail | null>(null);
|
||||||
|
const [history, setHistory] = useState<Record<string, { display: string; canonicalUnit: string; points: { collectedAt: string; value: number }[] }>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) api.labDraw(id).then(setDetail).catch(() => setDetail(null));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const toggleHistory = async (key: string) => {
|
||||||
|
if (history[key]) {
|
||||||
|
setHistory((h) => { const { [key]: _, ...rest } = h; return rest; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 <p>Loading…</p>;
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2>{detail.collectedAt}{detail.labName ? ` — ${detail.labName}` : ""}</h2>
|
||||||
|
{detail.panels.map((p) => (
|
||||||
|
<div key={p.panel}>
|
||||||
|
<h3 className="panel-title">{p.panel}</h3>
|
||||||
|
<table className="lab-table">
|
||||||
|
<tbody>
|
||||||
|
{p.markers.map((m) => (
|
||||||
|
<Fragment key={m.id}>
|
||||||
|
<tr className={m.flagged ? "flagged" : undefined}>
|
||||||
|
<td>
|
||||||
|
{m.analyteKey
|
||||||
|
? <button className="link" onClick={() => toggleHistory(m.analyteKey!)}>{m.name}</button>
|
||||||
|
: m.name}
|
||||||
|
</td>
|
||||||
|
<td className="num">{m.value} {m.unit ?? ""}</td>
|
||||||
|
<td className="muted">{m.referenceRange ?? ""}</td>
|
||||||
|
<td>{m.flagged ? <span className="flag">●</span> : null}</td>
|
||||||
|
</tr>
|
||||||
|
{m.analyteKey && history[m.analyteKey] && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4}>
|
||||||
|
<Sparkline points={history[m.analyteKey].points.map((pt) => pt.value)} />
|
||||||
|
<span className="muted"> {history[m.analyteKey].points.length} draws, {history[m.analyteKey].canonicalUnit}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
web/src/pages/Labs.tsx
Normal file
72
web/src/pages/Labs.tsx
Normal file
@@ -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<HTMLInputElement>(null);
|
||||||
|
const [draws, setDraws] = useState<Awaited<ReturnType<typeof api.labDraws>>["draws"]>([]);
|
||||||
|
const [drafts, setDrafts] = useState<Awaited<ReturnType<typeof api.labDrafts>>["drafts"]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
api.labDraws().then((r) => setDraws(r.draws)).catch((e) => setError(e.message));
|
||||||
|
api.labDrafts().then((r) => setDrafts(r.drafts.filter((d) => d.status === "pending"))).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
useEffect(refresh, [refresh]);
|
||||||
|
|
||||||
|
const onFile = async (file: File) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { id } = await api.uploadLab(file);
|
||||||
|
nav(`/labs/draft/${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "upload failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="row-between">
|
||||||
|
<h2>Labs</h2>
|
||||||
|
<button disabled={busy} onClick={() => fileInput.current?.click()}>
|
||||||
|
{busy ? "Extracting…" : "Upload lab PDF"}
|
||||||
|
</button>
|
||||||
|
<input ref={fileInput} type="file" accept="application/pdf" hidden
|
||||||
|
onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
|
||||||
|
</div>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<p className="hint">Upload a blood-test PDF. The AI extracts the values; nothing is saved until you review and confirm them.</p>
|
||||||
|
|
||||||
|
{drafts.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3>Awaiting review</h3>
|
||||||
|
<ul className="list">
|
||||||
|
{drafts.map((d) => (
|
||||||
|
<li key={d.id}>
|
||||||
|
<Link to={`/labs/draft/${d.id}`}>{d.filename}</Link>
|
||||||
|
{d.error ? <span className="error"> extraction failed</span> : <span className="muted"> {d.markerCount} markers</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3>Draws</h3>
|
||||||
|
{draws.length === 0 && <p className="muted">No confirmed draws yet.</p>}
|
||||||
|
<ul className="list">
|
||||||
|
{draws.map((d) => (
|
||||||
|
<li key={d.id}>
|
||||||
|
<Link to={`/labs/draw/${d.id}`}>{d.collectedAt}</Link>
|
||||||
|
<span className="muted"> {d.labName ?? ""} · {d.markerCount} markers</span>
|
||||||
|
{d.flaggedCount > 0 && <span className="flag"> {d.flaggedCount} flagged</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,3 +5,19 @@ nav { display: flex; gap: 16px; margin-bottom: 24px; }
|
|||||||
form { display: flex; flex-direction: column; gap: 12px; max-width: 360px; }
|
form { display: flex; flex-direction: column; gap: 12px; max-width: 360px; }
|
||||||
input { padding: 8px; }
|
input { padding: 8px; }
|
||||||
.error { color: crimson; }
|
.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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user