Compare commits

...

14 Commits

Author SHA1 Message Date
923311e650 feat: display user biomarker results with severity indicators and visual scale bars on dashboard 2025-12-21 18:17:49 +05:30
8919942322 feat: implement Mistral BYOK feature for OCR processing with user profile integration 2025-12-21 15:16:41 +05:30
0f277d6b3d feat: implement OCR parsing status tracking and biomarker count display 2025-12-21 13:33:39 +05:30
fc6376abec feat: implement Mistral OCR document parsing with fuzzy matching and frontend integration 2025-12-21 11:06:57 +05:30
c8b4beafff refactor: replace inline styles with new CSS utility classes across various pages. 2025-12-20 16:08:11 +05:30
d9f6694b2f feat: Implement source management with file upload, display, and deletion functionality on a new Sources page, backed by new backend models and handlers. 2025-12-20 15:55:16 +05:30
89815e7e21 feat: redesign biomarker display as a vertical list with status indicators and scale bars 2025-12-20 12:57:33 +05:30
69bc5675dc feat: add profile avatar selection and enhance sidebar UI with icons 2025-12-20 12:26:28 +05:30
e558e19512 feat: Introduce a shared Layout component, add new Insights and Sources pages, and refactor Dashboard and Profile pages to integrate with the new layout. 2025-12-19 21:25:23 +05:30
10e6d2c58a feat: Display biomarkers by category in collapsible sections on the dashboard, including new UI styles and backend category_id in the API response. 2025-12-19 21:12:02 +05:30
d08f63f50c feat: Implement theme state management and enhance theme toggle button with dynamic icons and accessibility. 2025-12-19 20:41:07 +05:30
21a0031c81 feat: implement admin user management page with user listing, creation, deletion, and password reset functionality 2025-12-19 20:28:27 +05:30
c26c74ebdb feat: add stop command to Makefile for terminating local servers 2025-12-19 18:07:50 +05:30
d981ff37fb feat: Add optional display name to user profile with corresponding API and UI updates 2025-12-19 18:07:01 +05:30
61 changed files with 3796 additions and 123 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@ config.yaml
backend/data/zhealth.db backend/data/zhealth.db
backend/data/zhealth.db-wal backend/data/zhealth.db-wal
backend/data/zhealth.db-shm backend/data/zhealth.db-shm
backend/data/uploads/
# FRONTEND # FRONTEND

View File

@@ -1,12 +1,13 @@
# zhealth Makefile # zhealth Makefile
# Run `make help` to see available commands # Run `make help` to see available commands
.PHONY: help build release lint typecheck test clean serve .PHONY: help build release lint typecheck test clean serve stop
# Default target # Default target
help: help:
@echo "Available commands:" @echo "Available commands:"
@echo " make serve - Start both backend and frontend servers" @echo " make serve - Start both backend and frontend servers"
@echo " make stop - Stop running backend and frontend servers"
@echo " make build - Build for development" @echo " make build - Build for development"
@echo " make release - Build optimized production bundle" @echo " make release - Build optimized production bundle"
@echo " make migrate - Run database migrations" @echo " make migrate - Run database migrations"
@@ -16,6 +17,12 @@ help:
@echo " make test - Run all tests" @echo " make test - Run all tests"
@echo " make clean - Clean build artifacts" @echo " make clean - Clean build artifacts"
stop:
@echo "Stopping backend (port 3000) and frontend (port 5173)..."
-@lsof -ti :3000 | xargs -r kill 2>/dev/null || true
-@lsof -ti :5173 | xargs -r kill 2>/dev/null || true
@echo "Servers stopped"
# Backend commands # Backend commands
.PHONY: backend-dev backend-build backend-release backend-lint backend-test migrate seed .PHONY: backend-dev backend-build backend-release backend-lint backend-test migrate seed
@@ -47,7 +54,7 @@ frontend-install:
cd frontend && npm install cd frontend && npm install
frontend-dev: frontend-dev:
cd frontend && npm run dev cd frontend && npm run dev -- --host 0.0.0.0
frontend-build: frontend-build:
cd frontend && npm run build cd frontend && npm run build
@@ -75,7 +82,7 @@ test: backend-test frontend-test
serve: serve:
@echo "Starting backend (port 3000) and frontend (port 5173)..." @echo "Starting backend (port 3000) and frontend (port 5173)..."
@cd backend && cargo run -- serve & cd frontend && npm run dev @cd backend && cargo run -- serve & cd frontend && npm run dev -- --host 0.0.0.0
clean: clean:
cd backend && cargo clean cd backend && cargo clean

View File

@@ -10,7 +10,9 @@ path = "src/main.rs"
[dependencies] [dependencies]
# Web Framework # Web Framework
axum = "0.8" axum = "0.8"
axum-extra = { version = "0.10", features = ["multipart"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
tower = "0.5" tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] } tower-http = { version = "0.6", features = ["cors", "trace"] }
@@ -45,3 +47,9 @@ regex = "1"
# CLI # CLI
argh = "0.1" argh = "0.1"
reqwest = { version = "0.12.26", features = ["multipart", "json"] }
serde_json = "1.0.145"
# PDF parsing for page count
lopdf = "0.36"
strsim = "0.11"

245
backend/ocr_schema.json Normal file
View File

@@ -0,0 +1,245 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"name": "LabReport",
"description": "Extract biomarker data from a medical lab report",
"type": "object",
"properties": {
"patient_name": {
"type": "string",
"description": "Full name of the patient"
},
"patient_age": {
"type": "integer",
"description": "Age of the patient in years"
},
"patient_gender": {
"type": "string",
"enum": [
"male",
"female",
"other"
],
"description": "Gender of the patient"
},
"lab_name": {
"type": "string",
"description": "Name of the laboratory"
},
"test_date": {
"type": "string",
"description": "Date when the sample was collected (YYYY-MM-DD format if possible)"
},
"report_id": {
"type": "string",
"description": "Report ID, barcode, or reference number"
},
"biomarkers": {
"type": "array",
"description": "List of biomarker test results",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the biomarker/test",
"enum": [
"ARSENIC",
"CADMIUM",
"MERCURY",
"LEAD",
"CHROMIUM",
"BARIUM",
"COBALT",
"CAESIUM",
"THALLIUM",
"URANIUM",
"STRONTIUM",
"ANTIMONY",
"TIN",
"MOLYBDENUM",
"SILVER",
"VANADIUM",
"BERYLLIUM",
"BISMUTH",
"SELENIUM",
"ALUMINIUM",
"NICKEL",
"MANGANESE",
"GLYCOSYLATED HEMOGLOBIN (HbA1c)",
"AVERAGE BLOOD GLUCOSE (ABG)",
"FASTING BLOOD SUGAR (GLUCOSE)",
"INSULIN FASTING",
"FRUCTOSAMINE",
"BLOOD KETONE (D3HB)",
"ERYTHROCYTE SEDIMENTATION RATE (ESR)",
"ANTI CCP (ACCP)",
"ANTI NUCLEAR ANTIBODIES (ANA)",
"HEMOGLOBIN",
"HEMATOCRIT (PCV)",
"TOTAL RED BLOOD CELL COUNT (RBC)",
"MEAN CORPUSCULAR VOLUME (MCV)",
"MEAN CORPUSCULAR HEMOGLOBIN (MCH)",
"MEAN CORP. HEMO. CONC (MCHC)",
"RED CELL DISTRIBUTION WIDTH - SD (RDW-SD)",
"RED CELL DISTRIBUTION WIDTH (RDW-CV)",
"TOTAL LEUCOCYTE COUNT (WBC)",
"NEUTROPHILS PERCENTAGE",
"LYMPHOCYTES PERCENTAGE",
"MONOCYTES PERCENTAGE",
"EOSINOPHILS PERCENTAGE",
"BASOPHILS PERCENTAGE",
"IMMATURE GRANULOCYTE PERCENTAGE (IG%)",
"NUCLEATED RED BLOOD CELLS %",
"NEUTROPHILS ABSOLUTE COUNT",
"LYMPHOCYTES ABSOLUTE COUNT",
"MONOCYTES - ABSOLUTE COUNT",
"BASOPHILS ABSOLUTE COUNT",
"EOSINOPHILS ABSOLUTE COUNT",
"IMMATURE GRANULOCYTES (IG)",
"NUCLEATED RED BLOOD CELLS",
"PLATELET COUNT",
"MEAN PLATELET VOLUME (MPV)",
"PLATELET DISTRIBUTION WIDTH (PDW)",
"PLATELET TO LARGE CELL RATIO (PLCR)",
"PLATELETCRIT (PCT)",
"VITAMIN A",
"VITAMIN E",
"VITAMIN K",
"VITAMIN B1 (THIAMIN)",
"VITAMIN B2 (RIBOFLAVIN)",
"VITAMIN B3 (NIACIN/NICOTINIC ACID)",
"VITAMIN B5 (PANTOTHENIC ACID)",
"VITAMIN B6 (PYRIDOXAL-5-PHOSPHATE)",
"VITAMIN B7 (BIOTIN)",
"VITAMIN B9 (FOLIC ACID)",
"VITAMIN B12 (COBALAMIN)",
"VITAMIN D TOTAL",
"VITAMIN D2",
"VITAMIN D3",
"CORTISOL",
"CORTICOSTERONE",
"ANDROSTENEDIONE",
"ESTRADIOL",
"TESTOSTERONE",
"PROGESTERONE",
"17-HYDROXYPROGESTERONE",
"DEHYDROEPIANDROSTERONE (DHEA)",
"DHEA - SULPHATE (DHEAS)",
"DEOXYCORTISOL",
"ALPHA-1-ANTITRYPSIN (AAT)",
"HOMOCYSTEINE",
"TROPONIN I",
"HIGH SENSITIVITY C-REACTIVE PROTEIN (HS-CRP)",
"LIPOPROTEIN (A) [Lp(a)]",
"LIPOPROTEIN-ASSOCIATED PHOSPHOLIPASE A2 (LP-PLA2)",
"CYSTATIN C",
"BLOOD UREA NITROGEN (BUN)",
"UREA (CALCULATED)",
"CREATININE - SERUM",
"UREA / SR.CREATININE RATIO",
"BUN / SR.CREATININE RATIO",
"CALCIUM",
"URIC ACID",
"ESTIMATED GLOMERULAR FILTRATION RATE (eGFR)",
"TOTAL CHOLESTEROL",
"HDL CHOLESTEROL - DIRECT",
"LDL CHOLESTEROL - DIRECT",
"TRIGLYCERIDES",
"VLDL CHOLESTEROL",
"NON-HDL CHOLESTEROL",
"TC / HDL CHOLESTEROL RATIO",
"LDL / HDL RATIO",
"HDL / LDL RATIO",
"TRIG / HDL RATIO",
"APOLIPOPROTEIN - A1 (APO-A1)",
"APOLIPOPROTEIN - B (APO-B)",
"APO B / APO A1 RATIO",
"IRON",
"TOTAL IRON BINDING CAPACITY (TIBC)",
"% TRANSFERRIN SATURATION",
"FERRITIN",
"UNSAT. IRON-BINDING CAPACITY (UIBC)",
"ALKALINE PHOSPHATASE",
"BILIRUBIN - TOTAL",
"BILIRUBIN - DIRECT",
"BILIRUBIN (INDIRECT)",
"GAMMA GLUTAMYL TRANSFERASE (GGT)",
"ASPARTATE AMINOTRANSFERASE (SGOT)",
"ALANINE TRANSAMINASE (SGPT)",
"SGOT / SGPT RATIO",
"PROTEIN - TOTAL",
"ALBUMIN - SERUM",
"SERUM GLOBULIN",
"SERUM ALB/GLOBULIN RATIO",
"SODIUM",
"POTASSIUM",
"CHLORIDE",
"MAGNESIUM",
"TOTAL TRIIODOTHYRONINE (T3)",
"TOTAL THYROXINE (T4)",
"TSH ULTRASENSITIVE",
"SERUM COPPER",
"SERUM ZINC",
"AMYLASE",
"LIPASE",
"URINARY MICROALBUMIN",
"CREATININE - URINE",
"URI. ALBUMIN/CREATININE RATIO",
"URINE COLOUR",
"URINE APPEARANCE",
"URINE SPECIFIC GRAVITY",
"URINE PH",
"URINARY PROTEIN",
"URINARY GLUCOSE",
"URINE KETONE",
"URINARY BILIRUBIN",
"UROBILINOGEN",
"BILE SALT",
"BILE PIGMENT",
"URINE BLOOD",
"NITRITE",
"LEUCOCYTE ESTERASE",
"MUCUS",
"URINE RBC",
"URINARY LEUCOCYTES (PUS CELLS)",
"EPITHELIAL CELLS",
"CASTS",
"CRYSTALS",
"BACTERIA",
"YEAST",
"PARASITE",
"WEIGHT",
"HEIGHT",
"BODY MASS INDEX (BMI)",
"HEART RATE",
"BLOOD PRESSURE SYSTOLIC",
"BLOOD PRESSURE DIASTOLIC",
"OXYGEN SATURATION (SpO2)",
"BODY TEMPERATURE",
"STEPS",
"CALORIES BURNED"
]
},
"value": {
"type": "number",
"description": "Observed/measured value"
},
"value_string": {
"type": "string",
"description": "Value as string if non-numeric (e.g., 'Negative', 'Trace', '> 65')"
},
"unit": {
"type": "string",
"description": "Unit of measurement"
}
},
"required": [
"name"
]
}
}
},
"required": [
"biomarkers"
]
}

View File

@@ -9,6 +9,8 @@ server:
paths: paths:
database: "./data/zhealth.db" database: "./data/zhealth.db"
logs: "./logs" logs: "./logs"
uploads: "./data/uploads"
max_upload_mb: 50 # Maximum file upload size in MB
logging: logging:
level: "info" # Options: trace | debug | info | warn | error level: "info" # Options: trace | debug | info | warn | error
@@ -28,3 +30,11 @@ ai:
provider: "gemini" # Options: gemini | openai | anthropic provider: "gemini" # Options: gemini | openai | anthropic
model: "gemini-3-flash-preview" model: "gemini-3-flash-preview"
api_key: "${AI_API_KEY}" api_key: "${AI_API_KEY}"
# Mistral OCR for document parsing
# Note: API key is set per-user in Profile settings (BYOK)
mistral:
ocr_model: "mistral-ocr-latest"
max_pages_per_request: 8
max_retries: 2 # Max retry attempts per chunk
timeout_secs: 120 # Request timeout in seconds

View File

@@ -214,7 +214,7 @@ biomarkers:
# ============================================================================ # ============================================================================
# DIABETES / METABOLIC - Scale-based interpretations # DIABETES / METABOLIC - Scale-based interpretations
# ============================================================================ # ============================================================================
- name: "HbA1c" - name: "GLYCOSYLATED HEMOGLOBIN (HbA1c)"
test_category: DIABETES test_category: DIABETES
category: metabolic category: metabolic
unit: "%" unit: "%"
@@ -339,7 +339,7 @@ biomarkers:
min: 36.0 min: 36.0
max: 44.0 max: 44.0
- name: "TOTAL RBC" - name: "TOTAL RED BLOOD CELL COUNT (RBC)"
test_category: HEMOGRAM test_category: HEMOGRAM
category: blood category: blood
unit: "10^6/µL" unit: "10^6/µL"
@@ -614,7 +614,7 @@ biomarkers:
min: 0.13 min: 0.13
max: 1.19 max: 1.19
- name: "VITAMIN B1/THIAMIN" - name: "VITAMIN B1 (THIAMIN)"
test_category: VITAMIN test_category: VITAMIN
category: vitamins category: vitamins
unit: "ng/mL" unit: "ng/mL"
@@ -623,7 +623,7 @@ biomarkers:
min: 0.5 min: 0.5
max: 4.0 max: 4.0
- name: "VITAMIN B2/RIBOFLAVIN" - name: "VITAMIN B2 (RIBOFLAVIN)"
test_category: VITAMIN test_category: VITAMIN
category: vitamins category: vitamins
unit: "ng/mL" unit: "ng/mL"
@@ -632,7 +632,7 @@ biomarkers:
min: 1.6 min: 1.6
max: 68.2 max: 68.2
- name: "VITAMIN B3/NICOTINIC ACID" - name: "VITAMIN B3 (NIACIN/NICOTINIC ACID)"
test_category: VITAMIN test_category: VITAMIN
category: vitamins category: vitamins
unit: "ng/mL" unit: "ng/mL"
@@ -640,7 +640,7 @@ biomarkers:
reference: reference:
max: 5.0 max: 5.0
- name: "VITAMIN B5/PANTOTHENIC" - name: "VITAMIN B5 (PANTOTHENIC ACID)"
test_category: VITAMIN test_category: VITAMIN
category: vitamins category: vitamins
unit: "ng/mL" unit: "ng/mL"
@@ -649,7 +649,7 @@ biomarkers:
min: 11.0 min: 11.0
max: 150.0 max: 150.0
- name: "VITAMIN B6/P5P" - name: "VITAMIN B6 (PYRIDOXAL-5-PHOSPHATE)"
test_category: VITAMIN test_category: VITAMIN
category: vitamins category: vitamins
unit: "ng/mL" unit: "ng/mL"
@@ -658,7 +658,7 @@ biomarkers:
min: 5.0 min: 5.0
max: 50.0 max: 50.0
- name: "VITAMIN B7/BIOTIN" - name: "VITAMIN B7 (BIOTIN)"
test_category: VITAMIN test_category: VITAMIN
category: vitamins category: vitamins
unit: "ng/mL" unit: "ng/mL"
@@ -667,7 +667,7 @@ biomarkers:
min: 0.2 min: 0.2
max: 3.0 max: 3.0
- name: "VITAMIN B9/FOLIC ACID" - name: "VITAMIN B9 (FOLIC ACID)"
test_category: VITAMIN test_category: VITAMIN
category: vitamins category: vitamins
unit: "ng/mL" unit: "ng/mL"
@@ -676,7 +676,7 @@ biomarkers:
min: 0.2 min: 0.2
max: 20.0 max: 20.0
- name: "VITAMIN B-12" - name: "VITAMIN B12 (COBALAMIN)"
test_category: VITAMIN test_category: VITAMIN
category: vitamins category: vitamins
unit: "pg/mL" unit: "pg/mL"
@@ -951,7 +951,7 @@ biomarkers:
- { min: 4, max: 10, label: "Moderate risk of future heart attack" } - { min: 4, max: 10, label: "Moderate risk of future heart attack" }
- { min: 10, label: "Elevated risk of future heart attack" } - { min: 10, label: "Elevated risk of future heart attack" }
- name: "HS-CRP" - name: "HIGH SENSITIVITY C-REACTIVE PROTEIN (HS-CRP)"
test_category: CARDIAC test_category: CARDIAC
category: cardiac category: cardiac
unit: "mg/L" unit: "mg/L"
@@ -970,7 +970,7 @@ biomarkers:
reference: reference:
max: 30.0 max: 30.0
- name: "LP-PLA2" - name: "LIPOPROTEIN-ASSOCIATED PHOSPHOLIPASE A2 (LP-PLA2)"
test_category: CARDIAC test_category: CARDIAC
category: cardiac category: cardiac
unit: "nmol/min/mL" unit: "nmol/min/mL"
@@ -1062,7 +1062,7 @@ biomarkers:
min: 2.6 min: 2.6
max: 6.0 max: 6.0
- name: "eGFR" - name: "ESTIMATED GLOMERULAR FILTRATION RATE (eGFR)"
test_category: RENAL test_category: RENAL
category: renal category: renal
unit: "mL/min/1.73m²" unit: "mL/min/1.73m²"
@@ -1733,7 +1733,7 @@ biomarkers:
category: body category: body
unit: "cm" unit: "cm"
- name: "BMI" - name: "BODY MASS INDEX (BMI)"
test_category: BODY test_category: BODY
category: body category: body
unit: "kg/m²" unit: "kg/m²"
@@ -1773,7 +1773,7 @@ biomarkers:
- { min: 80, max: 89, label: "High Blood Pressure Stage 1" } - { min: 80, max: 89, label: "High Blood Pressure Stage 1" }
- { min: 90, label: "High Blood Pressure Stage 2" } - { min: 90, label: "High Blood Pressure Stage 2" }
- name: "SPO2" - name: "OXYGEN SATURATION (SpO2)"
test_category: VITALS test_category: VITALS
category: vitals category: vitals
unit: "%" unit: "%"

View File

@@ -12,6 +12,7 @@ pub struct Config {
pub auth: AuthConfig, pub auth: AuthConfig,
pub admin: AdminConfig, pub admin: AdminConfig,
pub ai: AiConfig, pub ai: AiConfig,
pub mistral: MistralConfig,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -20,10 +21,12 @@ pub struct ServerConfig {
pub port: u16, pub port: u16,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, Clone)]
pub struct PathsConfig { pub struct PathsConfig {
pub database: String, pub database: String,
pub logs: String, pub logs: String,
pub uploads: String,
pub max_upload_mb: u32,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -52,6 +55,17 @@ pub struct AiConfig {
pub api_key: String, pub api_key: String,
} }
#[derive(Debug, Deserialize, Clone)]
pub struct MistralConfig {
/// API key - NOT loaded from config, set at runtime from user's profile
#[serde(skip, default)]
pub api_key: String,
pub ocr_model: String,
pub max_pages_per_request: u32,
pub max_retries: u32,
pub timeout_secs: u64,
}
impl Config { impl Config {
/// Load configuration from a YAML file. /// Load configuration from a YAML file.
pub fn load<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> { pub fn load<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {

View File

@@ -4,7 +4,7 @@ use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbBackend, DbErr, S
use sea_orm::sea_query::SqliteQueryBuilder; use sea_orm::sea_query::SqliteQueryBuilder;
use crate::config::Config; use crate::config::Config;
use crate::models::bio::{biomarker, biomarker_category, biomarker_entry, biomarker_reference_rule}; use crate::models::bio::{biomarker, biomarker_category, biomarker_entry, biomarker_reference_rule, source};
use crate::models::user::{diet, role, session, user}; use crate::models::user::{diet, role, session, user};
/// Connect to the SQLite database. /// Connect to the SQLite database.
@@ -33,6 +33,7 @@ pub async fn run_migrations(db: &DatabaseConnection) -> Result<(), DbErr> {
schema.create_table_from_entity(biomarker_category::Entity), schema.create_table_from_entity(biomarker_category::Entity),
schema.create_table_from_entity(biomarker::Entity), schema.create_table_from_entity(biomarker::Entity),
schema.create_table_from_entity(biomarker_reference_rule::Entity), schema.create_table_from_entity(biomarker_reference_rule::Entity),
schema.create_table_from_entity(source::Entity),
schema.create_table_from_entity(biomarker_entry::Entity), schema.create_table_from_entity(biomarker_entry::Entity),
]; ];

View File

@@ -43,6 +43,7 @@ pub struct ReferenceRuleResponse {
#[derive(Serialize)] #[derive(Serialize)]
pub struct BiomarkerListItem { pub struct BiomarkerListItem {
pub id: i32, pub id: i32,
pub category_id: i32,
pub name: String, pub name: String,
pub test_category: String, pub test_category: String,
pub unit: String, pub unit: String,
@@ -62,6 +63,7 @@ pub async fn list_biomarkers(
.into_iter() .into_iter()
.map(|b| BiomarkerListItem { .map(|b| BiomarkerListItem {
id: b.id, id: b.id,
category_id: b.category_id,
name: b.name, name: b.name,
test_category: b.test_category, test_category: b.test_category,
unit: b.unit, unit: b.unit,

View File

@@ -6,8 +6,10 @@ use axum::{
use chrono::Utc; use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set}; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::models::bio::{biomarker, biomarker_entry}; use crate::models::bio::{biomarker, biomarker_entry, biomarker_reference_rule};
use crate::models::user::user;
/// Request to create a new biomarker entry. /// Request to create a new biomarker entry.
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -30,6 +32,23 @@ pub struct EntryResponse {
pub notes: Option<String>, pub notes: Option<String>,
} }
/// Response for biomarker result with reference info.
#[derive(Serialize)]
pub struct BiomarkerResult {
pub biomarker_id: i32,
pub name: String,
pub category_id: i32,
pub unit: String,
// Latest entry
pub value: Option<f64>,
pub measured_at: Option<String>,
// Reference info
pub ref_min: Option<f64>,
pub ref_max: Option<f64>,
pub label: String,
pub severity: i32,
}
/// POST /api/entries - Create a new biomarker entry. /// POST /api/entries - Create a new biomarker entry.
pub async fn create_entry( pub async fn create_entry(
State(db): State<DatabaseConnection>, State(db): State<DatabaseConnection>,
@@ -60,6 +79,7 @@ pub async fn create_entry(
value: Set(req.value), value: Set(req.value),
measured_at: Set(measured_at), measured_at: Set(measured_at),
notes: Set(req.notes.clone()), notes: Set(req.notes.clone()),
source_id: Set(None),
created_at: Set(now), created_at: Set(now),
}; };
@@ -102,7 +122,7 @@ pub async fn list_user_entries(
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let bm_map: std::collections::HashMap<i32, String> = biomarkers let bm_map: HashMap<i32, String> = biomarkers
.into_iter() .into_iter()
.map(|b| (b.id, b.name)) .map(|b| (b.id, b.name))
.collect(); .collect();
@@ -121,3 +141,143 @@ pub async fn list_user_entries(
Ok(Json(items)) Ok(Json(items))
} }
/// GET /api/users/:user_id/results - Get latest biomarker results with reference rules.
pub async fn get_user_results(
State(db): State<DatabaseConnection>,
Path(user_id): Path<i32>,
) -> Result<Json<Vec<BiomarkerResult>>, StatusCode> {
// Get user profile for sex/age matching
let user_profile = user::Entity::find_by_id(user_id)
.one(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
// Calculate age from birthdate
let user_age = user_profile.birthdate.map(|bd| {
let today = chrono::Utc::now().date_naive();
let years = today.years_since(bd).unwrap_or(0) as i32;
years
});
// Fetch all biomarkers
let biomarkers = biomarker::Entity::find()
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Fetch all entries for this user, ordered by measured_at desc
let entries = biomarker_entry::Entity::find()
.filter(biomarker_entry::Column::UserId.eq(user_id))
.order_by_desc(biomarker_entry::Column::MeasuredAt)
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Build map of biomarker_id -> latest entry
let mut latest_entries: HashMap<i32, &biomarker_entry::Model> = HashMap::new();
for entry in &entries {
latest_entries.entry(entry.biomarker_id).or_insert(entry);
}
// Fetch all reference rules
let rules = biomarker_reference_rule::Entity::find()
.order_by_asc(biomarker_reference_rule::Column::SortOrder)
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Group rules by biomarker_id
let mut rules_map: HashMap<i32, Vec<&biomarker_reference_rule::Model>> = HashMap::new();
for rule in &rules {
rules_map.entry(rule.biomarker_id).or_default().push(rule);
}
// Build results
let mut results: Vec<BiomarkerResult> = Vec::new();
for bm in &biomarkers {
let entry = latest_entries.get(&bm.id);
let value = entry.map(|e| e.value);
let measured_at = entry.map(|e| e.measured_at.to_string());
// Find matching reference rule
let bm_rules = rules_map.get(&bm.id).map(|v| v.as_slice()).unwrap_or(&[]);
let (ref_min, ref_max, label, severity) = find_matching_rule(bm_rules, value, user_age, None);
results.push(BiomarkerResult {
biomarker_id: bm.id,
name: bm.name.clone(),
category_id: bm.category_id,
unit: bm.unit.clone(),
value,
measured_at,
ref_min,
ref_max,
label,
severity,
});
}
Ok(Json(results))
}
/// Find the best matching reference rule for a value.
fn find_matching_rule(
rules: &[&biomarker_reference_rule::Model],
value: Option<f64>,
user_age: Option<i32>,
_user_sex: Option<&str>,
) -> (Option<f64>, Option<f64>, String, i32) {
// Default: no data
if rules.is_empty() {
return (None, None, "No reference".to_string(), 0);
}
// Find the "range" type rule first (defines normal range)
let range_rule = rules.iter().find(|r| r.rule_type == "range");
let (ref_min, ref_max) = range_rule
.map(|r| (r.value_min, r.value_max))
.unwrap_or((None, None));
// If no value, return range with "No data" label
let Some(val) = value else {
return (ref_min, ref_max, "No data".to_string(), 0);
};
// Find matching scale rule based on value
for rule in rules {
// Check age bounds
if let Some(min_age) = rule.age_min {
if user_age.map(|a| a < min_age).unwrap_or(false) {
continue;
}
}
if let Some(max_age) = rule.age_max {
if user_age.map(|a| a > max_age).unwrap_or(false) {
continue;
}
}
// Check value bounds
let min_ok = rule.value_min.map(|min| val >= min).unwrap_or(true);
let max_ok = rule.value_max.map(|max| val <= max).unwrap_or(true);
if min_ok && max_ok {
return (ref_min, ref_max, rule.label.clone(), rule.severity);
}
}
// No matching rule found, determine based on range
if let (Some(min), Some(max)) = (ref_min, ref_max) {
if val < min {
return (ref_min, ref_max, "Low".to_string(), 1);
} else if val > max {
return (ref_min, ref_max, "High".to_string(), 1);
}
}
(ref_min, ref_max, "Normal".to_string(), 0)
}

View File

@@ -5,4 +5,6 @@ pub mod biomarkers;
pub mod categories; pub mod categories;
pub mod diets; pub mod diets;
pub mod entries; pub mod entries;
pub mod ocr;
pub mod sources;
pub mod users; pub mod users;

View File

@@ -0,0 +1,183 @@
//! Biomarker matching and merging logic.
use std::collections::HashMap;
use strsim::jaro_winkler;
use super::types::{Biomarker, DocumentAnnotation, OcrResult};
/// Fuzzy matching threshold (0.0 - 1.0).
/// Names with Jaro-Winkler similarity >= this value are considered a match.
const FUZZY_THRESHOLD: f64 = 0.90;
/// Find a matching biomarker name from the valid set.
/// Returns the canonical name (original case) if found (exact, alias, or fuzzy match).
///
/// Matching order:
/// 1. Exact match on full name (case-insensitive)
/// 2. Extract parenthetical alias from INPUT (e.g., `(HS-CRP)` from `HIGH SENSITIVITY C-REACTIVE PROTEIN (HS-CRP)`)
/// 3. Extract parenthetical alias from SCHEMA (e.g., `HS-CRP` matches `HIGH SENSITIVITY C-REACTIVE PROTEIN (HS-CRP)`)
/// 4. Fuzzy match with Jaro-Winkler (threshold 0.90)
///
/// valid_biomarkers: HashMap<uppercase_name, original_case_name>
fn find_matching_biomarker(name: &str, valid_biomarkers: &HashMap<String, String>) -> Option<String> {
let name_upper = name.to_uppercase();
// 1. Exact match first (fast path) - lookup by uppercase key, return original case value
if let Some(canonical) = valid_biomarkers.get(&name_upper) {
return Some(canonical.clone());
}
// 2. Try extracting parenthetical alias from INPUT
if let Some(alias) = extract_parenthetical_alias(&name_upper) {
if let Some(canonical) = valid_biomarkers.get(&alias) {
tracing::debug!(
"Alias matched '{}' -> '{}' (extracted from parentheses in input)",
name, canonical
);
return Some(canonical.clone());
}
}
// 3. Try matching input against aliases in SCHEMA
// This handles input "HS-CRP" matching schema "HIGH SENSITIVITY C-REACTIVE PROTEIN (HS-CRP)"
for (upper_key, canonical) in valid_biomarkers {
if let Some(alias) = extract_parenthetical_alias(upper_key) {
if alias == name_upper {
tracing::debug!(
"Reverse alias matched '{}' -> '{}' (input is alias in schema)",
name, canonical
);
return Some(canonical.clone());
}
}
}
// 4. Fuzzy match with threshold - compare against uppercase keys
valid_biomarkers.iter()
.map(|(upper_key, canonical)| (canonical, jaro_winkler(&name_upper, upper_key)))
.filter(|(_, score)| *score >= FUZZY_THRESHOLD)
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
.map(|(matched_name, score)| {
tracing::debug!(
"Fuzzy matched '{}' -> '{}' (score: {:.3})",
name, matched_name, score
);
matched_name.clone()
})
}
/// Extract alias from parentheses or brackets at the end of a name.
/// Examples:
/// - "HIGH SENSITIVITY C-REACTIVE PROTEIN (HS-CRP)" -> "HS-CRP"
/// - "EST. GLOMERULAR FILTRATION RATE (eGFR)" -> "EGFR"
/// - "LIPOPROTEIN (A) [LP(A)]" -> None (nested parens too complex)
fn extract_parenthetical_alias(name: &str) -> Option<String> {
let name = name.trim();
// Look for trailing (ALIAS) pattern
if let Some(start) = name.rfind('(') {
if name.ends_with(')') {
let alias = &name[start + 1..name.len() - 1];
// Only use if it looks like an abbreviation (mostly uppercase, short)
if alias.len() >= 2 && alias.len() <= 15 && !alias.contains(' ') {
return Some(alias.to_uppercase());
}
}
}
None
}
/// Merge multiple OCR results into one, filtering to only known biomarkers.
/// Uses fuzzy matching to handle name variations.
/// valid_biomarkers: HashMap<uppercase_name, original_case_name>
pub fn merge_results(results: Vec<DocumentAnnotation>, valid_biomarkers: &HashMap<String, String>) -> OcrResult {
let mut merged = OcrResult {
patient_name: None,
patient_age: None,
patient_gender: None,
lab_name: None,
test_date: None,
biomarkers: Vec::new(),
};
// Track biomarkers by canonical name, prefer ones with actual values
let mut biomarker_map: HashMap<String, Biomarker> = HashMap::new();
let mut skipped_count = 0;
let mut fuzzy_matched_count = 0;
for result in results {
// Take first non-null metadata
if merged.patient_name.is_none() && result.patient_name.is_some() {
merged.patient_name = result.patient_name;
}
if merged.patient_age.is_none() && result.patient_age.is_some() {
merged.patient_age = result.patient_age;
}
if merged.patient_gender.is_none() && result.patient_gender.is_some() {
merged.patient_gender = result.patient_gender;
}
if merged.lab_name.is_none() && result.lab_name.is_some() {
merged.lab_name = result.lab_name;
}
if merged.test_date.is_none() && result.test_date.is_some() {
merged.test_date = result.test_date;
}
// Merge biomarkers with fuzzy matching
if let Some(biomarkers) = result.biomarkers {
for mut bm in biomarkers {
let original_name = bm.name.clone();
// Try to find a matching canonical name
let canonical_name = match find_matching_biomarker(&bm.name, valid_biomarkers) {
Some(matched) => {
if matched != bm.name.to_uppercase() {
fuzzy_matched_count += 1;
}
// Update the biomarker name to canonical form
bm.name = matched.clone();
matched
}
None => {
tracing::debug!("Skipping unknown biomarker: {}", original_name);
skipped_count += 1;
continue;
}
};
let has_real_value = bm.value.is_some() ||
bm.value_string.as_ref().map(|s| !s.eq_ignore_ascii_case("not provided")).unwrap_or(false);
if let Some(existing) = biomarker_map.get(&canonical_name) {
let existing_has_real_value = existing.value.is_some() ||
existing.value_string.as_ref().map(|s| !s.eq_ignore_ascii_case("not provided")).unwrap_or(false);
// Replace only if current has real value and existing doesn't
if has_real_value && !existing_has_real_value {
biomarker_map.insert(canonical_name, bm);
}
} else {
biomarker_map.insert(canonical_name, bm);
}
}
}
}
if skipped_count > 0 {
tracing::info!("Skipped {} unknown biomarkers not in schema", skipped_count);
}
if fuzzy_matched_count > 0 {
tracing::info!("Fuzzy matched {} biomarkers to canonical names", fuzzy_matched_count);
}
// Collect biomarkers from map, filtering out "Not Provided" only entries
merged.biomarkers = biomarker_map.into_values()
.filter(|bm| {
bm.value.is_some() ||
bm.value_string.as_ref().map(|s| !s.eq_ignore_ascii_case("not provided")).unwrap_or(false)
})
.collect();
merged
}

View File

@@ -0,0 +1,211 @@
//! Mistral API integration for OCR.
use reqwest::multipart::{Form, Part};
use serde_json::{json, Value};
use std::path::PathBuf;
use std::time::Duration;
use tokio::fs;
use crate::config::MistralConfig;
use super::types::{Biomarker, DocumentAnnotation, MistralFileResponse, MistralOcrResponse};
use super::schema::strip_descriptions;
/// Upload a file to Mistral and return the file ID.
pub async fn upload_to_mistral(config: &MistralConfig, file_path: &PathBuf) -> Result<String, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let file_bytes = fs::read(file_path)
.await
.map_err(|e| format!("Failed to read file: {}", e))?;
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("document.pdf")
.to_string();
let part = Part::bytes(file_bytes)
.file_name(file_name)
.mime_str("application/pdf")
.map_err(|e| format!("MIME error: {}", e))?;
let form = Form::new()
.text("purpose", "ocr")
.part("file", part);
let response = client
.post("https://api.mistral.ai/v1/files")
.header("Authorization", format!("Bearer {}", config.api_key))
.multipart(form)
.send()
.await
.map_err(|e| format!("HTTP request failed: {}", e))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(format!("Mistral upload failed: {}", error_text));
}
let response_text = response.text().await
.map_err(|e| format!("Failed to read response: {}", e))?;
tracing::info!("Mistral file upload response: {}", response_text);
let result: MistralFileResponse = serde_json::from_str(&response_text)
.map_err(|e| format!("Failed to parse response: {} - raw: {}", e, response_text))?;
tracing::info!("Parsed file upload: id={}, num_pages={:?}", result.id, result.num_pages);
Ok(result.id)
}
/// Process OCR for specific pages of an uploaded document.
pub async fn ocr_pages(
config: &MistralConfig,
file_id: &str,
pages: &[usize],
) -> Result<DocumentAnnotation, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
// Load the complete schema from file
let schema_content = std::fs::read_to_string("ocr_schema.json")
.map_err(|e| format!("Failed to read ocr_schema.json: {}", e))?;
let mut schema: Value = serde_json::from_str(&schema_content)
.map_err(|e| format!("Failed to parse ocr_schema.json: {}", e))?;
// Clean the schema - remove meta-fields that Mistral echoes back
if let Some(obj) = schema.as_object_mut() {
obj.remove("$schema");
obj.remove("name");
obj.remove("description");
}
strip_descriptions(&mut schema);
let body = json!({
"model": config.ocr_model,
"document": {
"type": "file",
"file_id": file_id
},
"pages": pages,
"document_annotation_format": {
"type": "json_schema",
"json_schema": {
"name": "LabReport",
"schema": schema
}
}
});
let response = client
.post("https://api.mistral.ai/v1/ocr")
.header("Authorization", format!("Bearer {}", config.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| format!("OCR request failed: {}", e))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(format!("OCR failed: {}", error_text));
}
let result: MistralOcrResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse OCR response: {}", e))?;
let annotation_str = result
.document_annotation
.ok_or_else(|| "No document annotation in response".to_string())?;
tracing::debug!("Raw annotation from Mistral: {}", &annotation_str);
// Mistral returns data wrapped in "properties" - extract it
let raw_json: Value = serde_json::from_str(&annotation_str)
.map_err(|e| format!("Failed to parse raw JSON: {}", e))?;
let data_json = if let Some(props) = raw_json.get("properties") {
props.clone()
} else {
raw_json
};
// Check if this is a schema-only response (no actual data)
if let Some(biomarkers) = data_json.get("biomarkers") {
if biomarkers.get("type").is_some() && biomarkers.get("items").is_some() {
tracing::warn!("Skipping schema-only response (no data for these pages)");
return Ok(DocumentAnnotation {
patient_name: None,
patient_age: None,
patient_gender: None,
lab_name: None,
test_date: None,
biomarkers: Some(vec![]),
});
}
}
let annotation = parse_annotation(&data_json)?;
tracing::info!("Parsed annotation: patient={:?}, biomarkers={}",
annotation.patient_name,
annotation.biomarkers.as_ref().map(|b| b.len()).unwrap_or(0));
Ok(annotation)
}
/// Parse annotation handling various Mistral response formats.
fn parse_annotation(data: &Value) -> Result<DocumentAnnotation, String> {
let patient_name = data.get("patient_name").and_then(|v| v.as_str()).map(|s| s.to_string());
let patient_age = data.get("patient_age").and_then(|v| v.as_i64()).map(|n| n as i32);
let patient_gender = data.get("patient_gender").and_then(|v| v.as_str()).map(|s| s.to_string());
let lab_name = data.get("lab_name").and_then(|v| v.as_str()).map(|s| s.to_string());
let test_date = data.get("test_date").and_then(|v| v.as_str()).map(|s| s.to_string());
// Parse biomarkers - handle nested "properties" format
let biomarkers = if let Some(bm_array) = data.get("biomarkers").and_then(|v| v.as_array()) {
let mut parsed: Vec<Biomarker> = vec![];
for item in bm_array {
// Try direct format first
if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
parsed.push(Biomarker {
name: name.to_string(),
value: item.get("value").and_then(|v| v.as_f64()),
value_string: item.get("value_string").and_then(|v| v.as_str()).map(|s| s.to_string()),
unit: item.get("unit").and_then(|v| v.as_str()).map(|s| s.to_string()),
});
}
// Try nested "properties" format
else if let Some(props) = item.get("properties") {
if let Some(name) = props.get("name").and_then(|v| v.as_str()) {
parsed.push(Biomarker {
name: name.to_string(),
value: props.get("value").and_then(|v| v.as_f64()),
value_string: props.get("value_string").and_then(|v| v.as_str()).map(|s| s.to_string()),
unit: props.get("unit").and_then(|v| v.as_str()).map(|s| s.to_string()),
});
}
}
}
Some(parsed)
} else {
Some(vec![])
};
Ok(DocumentAnnotation {
patient_name,
patient_age,
patient_gender,
lab_name,
test_date,
biomarkers,
})
}

View File

@@ -0,0 +1,322 @@
//! OCR API handlers - Mistral OCR integration for document parsing.
mod matching;
mod mistral;
mod schema;
mod types;
use std::path::PathBuf;
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use crate::models::bio::{biomarker, biomarker_entry, source};
// Re-export public types
pub use types::{ErrorResponse, OcrState, ParseResponse};
/// Get page count from a local file.
/// For PDFs, uses lopdf to read the actual page count.
/// For other file types (images, etc.), returns 1.
fn get_page_count(file_path: &PathBuf) -> usize {
let extension = file_path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if extension == "pdf" {
match lopdf::Document::load(file_path) {
Ok(doc) => {
let count = doc.get_pages().len();
tracing::info!("PDF page count (local): {}", count);
count
}
Err(e) => {
tracing::warn!("Failed to read PDF page count: {}, defaulting to 1", e);
1
}
}
} else {
tracing::info!("Non-PDF file, treating as 1 page");
1
}
}
/// POST /api/sources/:id/parse - Parse a source document using Mistral OCR.
/// Returns immediately with "processing" status; OCR runs in background.
pub async fn parse_source(
State(state): State<OcrState>,
Path(id): Path<i32>,
) -> Result<Json<ParseResponse>, (StatusCode, Json<ErrorResponse>)> {
use crate::models::user::user;
// 1. Get source from database
let source_entity = source::Entity::find_by_id(id)
.one(&state.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Database error: {}", e),
}),
)
})?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "Source not found".to_string(),
}),
)
})?;
// Check if already being processed
if source_entity.status == "processing" {
return Ok(Json(ParseResponse {
success: true,
biomarkers_count: 0,
message: "Already processing".to_string(),
}));
}
let file_path = PathBuf::from(&source_entity.file_path);
let user_id = source_entity.user_id;
// 2. Set status to "processing" immediately
let mut active_model: source::ActiveModel = source_entity.into();
active_model.status = Set("processing".to_string());
active_model.update(&state.db).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Database update failed: {}", e),
}),
)
})?;
// 3. User must have their own Mistral API key configured
let user_api_key = if let Ok(Some(user_entity)) = user::Entity::find_by_id(user_id).one(&state.db).await {
user_entity.mistral_api_key
} else {
None
};
let api_key = match user_api_key {
Some(key) if !key.is_empty() => {
tracing::info!("Using user's Mistral API key for source {}", id);
key
}
_ => {
// Revert status back to pending since we can't process
if let Ok(Some(entity)) = source::Entity::find_by_id(id).one(&state.db).await {
let mut revert_model: source::ActiveModel = entity.into();
revert_model.status = Set("pending".to_string());
let _ = revert_model.update(&state.db).await;
}
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Please configure your Mistral API key in Profile settings".to_string(),
}),
));
}
};
let mut mistral_config = state.mistral.clone();
mistral_config.api_key = api_key;
// 4. Spawn background task for OCR processing
let db = state.db.clone();
tokio::spawn(async move {
if let Err(e) = process_ocr_background(db, mistral_config, id, file_path).await {
tracing::error!("Background OCR failed for source {}: {}", id, e);
}
});
// 5. Return immediately
Ok(Json(ParseResponse {
success: true,
biomarkers_count: 0,
message: "Processing started".to_string(),
}))
}
/// Background OCR processing task
async fn process_ocr_background(
db: sea_orm::DatabaseConnection,
mistral_config: crate::config::MistralConfig,
source_id: i32,
file_path: PathBuf,
) -> Result<(), String> {
// Upload file to Mistral
let file_id = mistral::upload_to_mistral(&mistral_config, &file_path)
.await
.map_err(|e| format!("Mistral upload failed: {}", e))?;
// Get page count locally from PDF
let max_pages = get_page_count(&file_path);
let chunk_size = mistral_config.max_pages_per_request as usize;
let max_retries = mistral_config.max_retries;
let mut all_results: Vec<types::DocumentAnnotation> = Vec::new();
let mut failed_chunk: Option<String> = None;
for start_page in (0..max_pages).step_by(chunk_size) {
// Check if source still exists before processing next chunk
let source_exists = source::Entity::find_by_id(source_id)
.one(&db)
.await
.map(|opt| opt.is_some())
.unwrap_or(false);
if !source_exists {
tracing::warn!("Source {} was deleted mid-parse, aborting OCR", source_id);
return Err("Source was deleted during parsing".to_string());
}
let pages: Vec<usize> = (start_page..std::cmp::min(start_page + chunk_size, max_pages)).collect();
tracing::info!("Processing OCR for pages {:?}", pages);
// Retry loop for this chunk
let mut attempts = 0;
let mut chunk_result = None;
while attempts <= max_retries {
match mistral::ocr_pages(&mistral_config, &file_id, &pages).await {
Ok(annotation) => {
chunk_result = Some(annotation);
break;
}
Err(e) => {
if e.contains("out of range") || e.contains("no pages") || e.contains("Invalid page") {
tracing::info!("Reached end of document at pages {:?}", pages);
break;
}
attempts += 1;
if attempts <= max_retries {
tracing::warn!("OCR chunk error (pages {:?}), attempt {}/{}: {}", pages, attempts, max_retries + 1, e);
} else {
tracing::error!("OCR chunk failed after {} attempts (pages {:?}): {}", max_retries + 1, pages, e);
failed_chunk = Some(format!("Pages {:?}: {}", pages, e));
}
}
}
}
if let Some(annotation) = chunk_result {
all_results.push(annotation);
} else if failed_chunk.is_some() {
break;
} else {
break;
}
}
// Handle failure
if let Some(error_msg) = failed_chunk {
// Update status to failed
if let Ok(Some(entity)) = source::Entity::find_by_id(source_id).one(&db).await {
let mut active_model: source::ActiveModel = entity.into();
active_model.status = Set("failed".to_string());
let _ = active_model.update(&db).await;
}
return Err(format!("OCR parsing failed: {}", error_msg));
}
if all_results.is_empty() {
// Update status to failed
if let Ok(Some(entity)) = source::Entity::find_by_id(source_id).one(&db).await {
let mut active_model: source::ActiveModel = entity.into();
active_model.status = Set("failed".to_string());
let _ = active_model.update(&db).await;
}
return Err("No OCR results obtained".to_string());
}
// Get valid biomarker names from schema
let valid_biomarkers = schema::extract_valid_biomarker_names()
.map_err(|e| format!("Failed to read schema: {}", e))?;
tracing::info!("Loaded {} valid biomarker names from schema", valid_biomarkers.len());
// Merge results with fuzzy matching
let merged = matching::merge_results(all_results, &valid_biomarkers);
// Save to database
let ocr_json = serde_json::to_string(&merged)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
let source_entity = source::Entity::find_by_id(source_id)
.one(&db)
.await
.map_err(|e| format!("Database error: {}", e))?
.ok_or_else(|| "Source not found".to_string())?;
let user_id = source_entity.user_id;
let mut active_model: source::ActiveModel = source_entity.into();
active_model.ocr_data = Set(Some(ocr_json));
active_model.status = Set("parsed".to_string());
active_model.biomarker_count = Set(Some(merged.biomarkers.len() as i32));
active_model.update(&db).await
.map_err(|e| format!("Database update failed: {}", e))?;
// Create biomarker entries from parsed data
let mut entries_created = 0;
let now = chrono::Utc::now().naive_utc();
// Parse test_date or use current time
let measured_at = merged.test_date
.as_ref()
.and_then(|d| chrono::NaiveDate::parse_from_str(d, "%d %b %Y").ok()
.or_else(|| chrono::NaiveDate::parse_from_str(d, "%d %b, %Y").ok())
.or_else(|| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").ok()))
.map(|date| date.and_hms_opt(0, 0, 0).unwrap())
.unwrap_or(now);
for bio in &merged.biomarkers {
// Skip if no numeric value
let Some(value) = bio.value else { continue };
// Look up biomarker ID by name
let biomarker_entity = biomarker::Entity::find()
.filter(biomarker::Column::Name.eq(&bio.name))
.one(&db)
.await
.map_err(|e| format!("Biomarker lookup error: {}", e))?;
let Some(biomarker_entity) = biomarker_entity else { continue };
// Create entry
let entry = biomarker_entry::ActiveModel {
biomarker_id: Set(biomarker_entity.id),
user_id: Set(user_id),
measured_at: Set(measured_at),
value: Set(value),
notes: Set(bio.unit.clone()),
source_id: Set(Some(source_id)),
created_at: Set(now),
};
// Insert (ignore if duplicate composite key)
if entry.insert(&db).await.is_ok() {
entries_created += 1;
}
}
tracing::info!(
"Successfully parsed {} biomarkers, created {} entries for source {}",
merged.biomarkers.len(),
entries_created,
source_id
);
Ok(())
}

View File

@@ -0,0 +1,51 @@
//! Schema handling utilities.
use serde_json::Value;
use std::collections::HashMap;
/// Extract valid biomarker names from the ocr_schema.json enum.
/// Returns a HashMap where keys are UPPERCASE names (for matching) and values are original case names.
pub fn extract_valid_biomarker_names() -> Result<HashMap<String, String>, String> {
let schema_content = std::fs::read_to_string("ocr_schema.json")
.map_err(|e| format!("Failed to read ocr_schema.json: {}", e))?;
let schema: Value = serde_json::from_str(&schema_content)
.map_err(|e| format!("Failed to parse ocr_schema.json: {}", e))?;
// Navigate to: properties.biomarkers.items.properties.name.enum
let names = schema
.get("properties")
.and_then(|p| p.get("biomarkers"))
.and_then(|b| b.get("items"))
.and_then(|i| i.get("properties"))
.and_then(|p| p.get("name"))
.and_then(|n| n.get("enum"))
.and_then(|e| e.as_array())
.ok_or_else(|| "Could not find biomarker name enum in schema".to_string())?;
// Key = uppercase (for matching), Value = original case (for DB lookup)
let valid_names: HashMap<String, String> = names
.iter()
.filter_map(|v| v.as_str())
.map(|s| (s.to_uppercase(), s.to_string()))
.collect();
Ok(valid_names)
}
/// Recursively remove "description" fields from a JSON value.
pub fn strip_descriptions(value: &mut Value) {
match value {
Value::Object(map) => {
map.remove("description");
for (_, v) in map.iter_mut() {
strip_descriptions(v);
}
}
Value::Array(arr) => {
for v in arr.iter_mut() {
strip_descriptions(v);
}
}
_ => {}
}
}

View File

@@ -0,0 +1,77 @@
//! Type definitions for OCR module.
use sea_orm::DatabaseConnection;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::config::MistralConfig;
/// State for OCR handlers.
#[derive(Clone)]
pub struct OcrState {
pub db: DatabaseConnection,
pub uploads_path: PathBuf,
pub mistral: MistralConfig,
}
/// Response for parse endpoint.
#[derive(Serialize)]
pub struct ParseResponse {
pub success: bool,
pub biomarkers_count: usize,
pub message: String,
}
/// Error response.
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
/// Mistral file upload response.
#[derive(Deserialize)]
pub struct MistralFileResponse {
pub id: String,
#[allow(dead_code)]
pub bytes: i64,
pub num_pages: Option<usize>,
}
/// Mistral OCR response.
#[derive(Deserialize)]
pub struct MistralOcrResponse {
pub document_annotation: Option<String>,
#[allow(dead_code)]
pub pages: Option<Vec<serde_json::Value>>,
}
/// Extracted biomarker from OCR.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Biomarker {
pub name: String,
pub value: Option<f64>,
pub value_string: Option<String>,
pub unit: Option<String>,
}
/// Merged OCR result.
#[derive(Debug, Serialize, Deserialize)]
pub struct OcrResult {
pub patient_name: Option<String>,
pub patient_age: Option<i32>,
pub patient_gender: Option<String>,
pub lab_name: Option<String>,
pub test_date: Option<String>,
pub biomarkers: Vec<Biomarker>,
}
/// Document annotation from Mistral.
#[derive(Debug, Deserialize)]
pub struct DocumentAnnotation {
pub patient_name: Option<String>,
pub patient_age: Option<i32>,
pub patient_gender: Option<String>,
pub lab_name: Option<String>,
pub test_date: Option<String>,
pub biomarkers: Option<Vec<Biomarker>>,
}

View File

@@ -0,0 +1,282 @@
//! Sources API handlers - file upload and management.
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use axum_extra::extract::Multipart;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use crate::models::bio::{biomarker_entry, source};
/// Response for a source.
#[derive(Serialize)]
pub struct SourceResponse {
pub id: i32,
pub user_id: i32,
pub name: String,
pub file_path: String,
pub file_type: String,
pub file_size: i64,
pub status: String,
pub biomarker_count: Option<i32>,
pub ocr_data: Option<String>,
pub description: Option<String>,
pub uploaded_at: String,
}
/// State that includes config for upload path.
#[derive(Clone)]
pub struct SourcesState {
pub db: DatabaseConnection,
pub uploads_path: PathBuf,
}
/// GET /api/sources - List all sources for current user.
pub async fn list_sources(
State(state): State<SourcesState>,
// TODO: Get user_id from session
) -> Result<Json<Vec<SourceResponse>>, StatusCode> {
let sources = source::Entity::find()
.all(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let items: Vec<SourceResponse> = sources
.into_iter()
.map(|s| SourceResponse {
id: s.id,
user_id: s.user_id,
name: s.name,
file_path: s.file_path,
file_type: s.file_type,
file_size: s.file_size,
status: s.status,
biomarker_count: s.biomarker_count,
ocr_data: s.ocr_data,
description: s.description,
uploaded_at: s.uploaded_at.to_string(),
})
.collect();
Ok(Json(items))
}
/// GET /api/sources/:id - Get a source by ID.
pub async fn get_source(
State(state): State<SourcesState>,
Path(id): Path<i32>,
) -> Result<Json<SourceResponse>, StatusCode> {
let s = source::Entity::find_by_id(id)
.one(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(SourceResponse {
id: s.id,
user_id: s.user_id,
name: s.name,
file_path: s.file_path,
file_type: s.file_type,
file_size: s.file_size,
status: s.status,
biomarker_count: s.biomarker_count,
ocr_data: s.ocr_data,
description: s.description,
uploaded_at: s.uploaded_at.to_string(),
}))
}
/// POST /api/sources - Upload a new source file.
pub async fn upload_source(
State(state): State<SourcesState>,
mut multipart: Multipart,
) -> Result<Json<SourceResponse>, StatusCode> {
let mut file_name: Option<String> = None;
let mut file_type: Option<String> = None;
let mut file_data: Option<Vec<u8>> = None;
let mut name: Option<String> = None;
let mut description: Option<String> = None;
let mut user_id: Option<i32> = None;
while let Some(field) = multipart.next_field().await.map_err(|e| {
tracing::error!("Multipart error: {:?}", e);
StatusCode::BAD_REQUEST
})? {
let field_name = field.name().unwrap_or("").to_string();
match field_name.as_str() {
"file" => {
file_name = field.file_name().map(|s| s.to_string());
file_type = field.content_type().map(|s| s.to_string());
file_data = Some(field.bytes().await.map_err(|e| {
tracing::error!("Failed to read file data: {:?}", e);
StatusCode::BAD_REQUEST
})?.to_vec());
}
"name" => {
name = Some(field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?);
}
"description" => {
description = Some(field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?);
}
"user_id" => {
let text = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?;
user_id = Some(text.parse().map_err(|_| StatusCode::BAD_REQUEST)?);
}
_ => {}
}
}
let file_data = file_data.ok_or(StatusCode::BAD_REQUEST)?;
let user_id = user_id.ok_or(StatusCode::BAD_REQUEST)?;
let original_name = file_name.unwrap_or_else(|| "upload".to_string());
let display_name = name.unwrap_or_else(|| original_name.clone());
let content_type = file_type.unwrap_or_else(|| "application/octet-stream".to_string());
let file_size = file_data.len() as i64;
// Generate unique filename
let timestamp = Utc::now().timestamp_millis();
let safe_name = original_name.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_");
let stored_name = format!("{}_{}", timestamp, safe_name);
// Ensure uploads directory exists
fs::create_dir_all(&state.uploads_path).await.map_err(|e| {
tracing::error!("Failed to create uploads dir: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Write file
let file_path = state.uploads_path.join(&stored_name);
let mut file = fs::File::create(&file_path).await.map_err(|e| {
tracing::error!("Failed to create file: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
file.write_all(&file_data).await.map_err(|e| {
tracing::error!("Failed to write file: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let now = Utc::now().naive_utc();
let new_source = source::ActiveModel {
user_id: Set(user_id),
name: Set(display_name.clone()),
file_path: Set(file_path.to_string_lossy().to_string()),
file_type: Set(content_type.clone()),
file_size: Set(file_size),
status: Set("pending".to_string()),
biomarker_count: Set(None),
ocr_data: Set(None),
description: Set(description.clone()),
uploaded_at: Set(now),
..Default::default()
};
let inserted = new_source
.insert(&state.db)
.await
.map_err(|e| {
tracing::error!("Failed to insert source: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(SourceResponse {
id: inserted.id,
user_id: inserted.user_id,
name: inserted.name,
file_path: inserted.file_path,
file_type: inserted.file_type,
file_size: inserted.file_size,
status: inserted.status,
biomarker_count: inserted.biomarker_count,
ocr_data: inserted.ocr_data,
description: inserted.description,
uploaded_at: inserted.uploaded_at.to_string(),
}))
}
/// DELETE /api/sources/:id - Delete a source.
pub async fn delete_source(
State(state): State<SourcesState>,
Path(id): Path<i32>,
) -> Result<StatusCode, StatusCode> {
// Get the source first to delete the file
let s = source::Entity::find_by_id(id)
.one(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
// Delete related biomarker entries first (cascade delete)
biomarker_entry::Entity::delete_many()
.filter(biomarker_entry::Column::SourceId.eq(id))
.exec(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Delete file from disk
if let Err(e) = fs::remove_file(&s.file_path).await {
tracing::warn!("Failed to delete file {}: {:?}", s.file_path, e);
}
// Delete from database
let result = source::Entity::delete_by_id(id)
.exec(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if result.rows_affected == 0 {
return Err(StatusCode::NOT_FOUND);
}
Ok(StatusCode::NO_CONTENT)
}
/// Request to update OCR data for a source.
#[derive(Deserialize)]
pub struct UpdateOcrRequest {
pub ocr_data: String,
}
/// PUT /api/sources/:id/ocr - Update OCR data for a source.
pub async fn update_ocr(
State(state): State<SourcesState>,
Path(id): Path<i32>,
Json(req): Json<UpdateOcrRequest>,
) -> Result<Json<SourceResponse>, StatusCode> {
let existing = source::Entity::find_by_id(id)
.one(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
let mut active: source::ActiveModel = existing.into();
active.ocr_data = Set(Some(req.ocr_data));
let updated = active
.update(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(SourceResponse {
id: updated.id,
user_id: updated.user_id,
name: updated.name,
file_path: updated.file_path,
file_type: updated.file_type,
file_size: updated.file_size,
status: updated.status,
biomarker_count: updated.biomarker_count,
ocr_data: updated.ocr_data,
description: updated.description,
uploaded_at: updated.uploaded_at.to_string(),
}))
}

View File

@@ -28,12 +28,15 @@ pub struct CreateUserRequest {
/// Request to update a user. /// Request to update a user.
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateUserRequest { pub struct UpdateUserRequest {
pub name: Option<String>,
pub height_cm: Option<f32>, pub height_cm: Option<f32>,
pub blood_type: Option<String>, pub blood_type: Option<String>,
pub birthdate: Option<String>, pub birthdate: Option<String>,
pub smoking: Option<bool>, pub smoking: Option<bool>,
pub alcohol: Option<bool>, pub alcohol: Option<bool>,
pub diet_id: Option<i32>, pub diet_id: Option<i32>,
pub avatar_url: Option<String>,
pub mistral_api_key: Option<String>,
} }
/// Response for a user. /// Response for a user.
@@ -41,6 +44,7 @@ pub struct UpdateUserRequest {
pub struct UserResponse { pub struct UserResponse {
pub id: i32, pub id: i32,
pub username: String, pub username: String,
pub name: Option<String>,
pub role: String, pub role: String,
pub height_cm: Option<f32>, pub height_cm: Option<f32>,
pub blood_type: Option<String>, pub blood_type: Option<String>,
@@ -48,6 +52,8 @@ pub struct UserResponse {
pub smoking: Option<bool>, pub smoking: Option<bool>,
pub alcohol: Option<bool>, pub alcohol: Option<bool>,
pub diet: Option<String>, pub diet: Option<String>,
pub avatar_url: Option<String>,
pub has_mistral_key: bool,
pub created_at: String, pub created_at: String,
} }
@@ -84,14 +90,17 @@ pub async fn list_users(
.into_iter() .into_iter()
.map(|u| UserResponse { .map(|u| UserResponse {
id: u.id, id: u.id,
username: u.username, username: u.username.clone(),
name: u.name.clone(),
role: role_map.get(&u.role_id).cloned().unwrap_or_default(), role: role_map.get(&u.role_id).cloned().unwrap_or_default(),
height_cm: u.height_cm, height_cm: u.height_cm,
blood_type: u.blood_type, blood_type: u.blood_type.clone(),
birthdate: u.birthdate.map(|d| d.to_string()), birthdate: u.birthdate.map(|d| d.to_string()),
smoking: u.smoking, smoking: u.smoking,
alcohol: u.alcohol, alcohol: u.alcohol,
diet: u.diet_id.and_then(|id| diet_map.get(&id).cloned()), diet: u.diet_id.and_then(|id| diet_map.get(&id).cloned()),
avatar_url: u.avatar_url.clone(),
has_mistral_key: u.mistral_api_key.is_some(),
created_at: u.created_at.to_string(), created_at: u.created_at.to_string(),
}) })
.collect(); .collect();
@@ -130,6 +139,7 @@ pub async fn get_user(
Ok(Json(UserResponse { Ok(Json(UserResponse {
id: u.id, id: u.id,
username: u.username, username: u.username,
name: u.name,
role: role_name, role: role_name,
height_cm: u.height_cm, height_cm: u.height_cm,
blood_type: u.blood_type, blood_type: u.blood_type,
@@ -137,6 +147,8 @@ pub async fn get_user(
smoking: u.smoking, smoking: u.smoking,
alcohol: u.alcohol, alcohol: u.alcohol,
diet: diet_name, diet: diet_name,
avatar_url: u.avatar_url,
has_mistral_key: u.mistral_api_key.is_some(),
created_at: u.created_at.to_string(), created_at: u.created_at.to_string(),
})) }))
} }
@@ -184,6 +196,7 @@ pub async fn create_user(
smoking: Set(req.smoking), smoking: Set(req.smoking),
alcohol: Set(req.alcohol), alcohol: Set(req.alcohol),
diet_id: Set(req.diet_id), diet_id: Set(req.diet_id),
avatar_url: Set(None), // Default to None on create for now, unless we want to support it in CreateUserRequest
created_at: Set(now), created_at: Set(now),
updated_at: Set(now), updated_at: Set(now),
..Default::default() ..Default::default()
@@ -211,6 +224,7 @@ pub async fn create_user(
Ok(Json(UserResponse { Ok(Json(UserResponse {
id: inserted.id, id: inserted.id,
username: inserted.username, username: inserted.username,
name: inserted.name,
role: role_name, role: role_name,
height_cm: inserted.height_cm, height_cm: inserted.height_cm,
blood_type: inserted.blood_type, blood_type: inserted.blood_type,
@@ -218,6 +232,8 @@ pub async fn create_user(
smoking: inserted.smoking, smoking: inserted.smoking,
alcohol: inserted.alcohol, alcohol: inserted.alcohol,
diet: diet_name, diet: diet_name,
avatar_url: inserted.avatar_url,
has_mistral_key: inserted.mistral_api_key.is_some(),
created_at: inserted.created_at.to_string(), created_at: inserted.created_at.to_string(),
})) }))
} }
@@ -246,6 +262,9 @@ pub async fn update_user(
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let mut active: user::ActiveModel = existing.into(); let mut active: user::ActiveModel = existing.into();
if req.name.is_some() {
active.name = Set(req.name);
}
if req.height_cm.is_some() { if req.height_cm.is_some() {
active.height_cm = Set(req.height_cm); active.height_cm = Set(req.height_cm);
} }
@@ -262,6 +281,12 @@ pub async fn update_user(
if req.diet_id.is_some() { if req.diet_id.is_some() {
active.diet_id = Set(req.diet_id); active.diet_id = Set(req.diet_id);
} }
if req.avatar_url.is_some() {
active.avatar_url = Set(req.avatar_url);
}
if req.mistral_api_key.is_some() {
active.mistral_api_key = Set(req.mistral_api_key);
}
active.updated_at = Set(now); active.updated_at = Set(now);
let updated = active let updated = active
@@ -291,6 +316,7 @@ pub async fn update_user(
Ok(Json(UserResponse { Ok(Json(UserResponse {
id: updated.id, id: updated.id,
username: updated.username, username: updated.username,
name: updated.name,
role: role_name, role: role_name,
height_cm: updated.height_cm, height_cm: updated.height_cm,
blood_type: updated.blood_type, blood_type: updated.blood_type,
@@ -298,6 +324,8 @@ pub async fn update_user(
smoking: updated.smoking, smoking: updated.smoking,
alcohol: updated.alcohol, alcohol: updated.alcohol,
diet: diet_name, diet: diet_name,
avatar_url: updated.avatar_url,
has_mistral_key: updated.mistral_api_key.is_some(),
created_at: updated.created_at.to_string(), created_at: updated.created_at.to_string(),
})) }))
} }
@@ -319,6 +347,46 @@ pub async fn delete_user(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
/// Request to reset a user's password.
#[derive(Deserialize)]
pub struct ResetPasswordRequest {
pub new_password: String,
}
/// POST /api/users/:id/reset-password - Reset a user's password (admin only).
pub async fn reset_password(
State(db): State<DatabaseConnection>,
Path(id): Path<i32>,
Json(req): Json<ResetPasswordRequest>,
) -> Result<StatusCode, StatusCode> {
// Find the user
let existing = user::Entity::find_by_id(id)
.one(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
// Hash new password
let password_hash = crate::auth::hash_password(&req.new_password)
.map_err(|e| {
tracing::error!("Password hashing failed: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let now = Utc::now().naive_utc();
let mut active: user::ActiveModel = existing.into();
active.password_hash = Set(password_hash);
active.updated_at = Set(now);
active
.update(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::OK)
}
/// GET /api/roles - List all roles. /// GET /api/roles - List all roles.
pub async fn list_roles( pub async fn list_roles(
State(db): State<DatabaseConnection>, State(db): State<DatabaseConnection>,
@@ -346,3 +414,4 @@ pub struct RoleResponse {
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
} }

View File

@@ -17,6 +17,7 @@ use axum_login::{
}; };
use sea_orm::DatabaseConnection; use sea_orm::DatabaseConnection;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::PathBuf;
use time::Duration; use time::Duration;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -138,14 +139,44 @@ fn create_router(db: DatabaseConnection, config: &config::Config) -> Router {
.route("/api/users/{id}", get(handlers::users::get_user) .route("/api/users/{id}", get(handlers::users::get_user)
.put(handlers::users::update_user) .put(handlers::users::update_user)
.delete(handlers::users::delete_user)) .delete(handlers::users::delete_user))
.route("/api/users/{id}/reset-password", post(handlers::users::reset_password))
// Entries API // Entries API
.route("/api/entries", post(handlers::entries::create_entry)) .route("/api/entries", post(handlers::entries::create_entry))
.route("/api/users/{user_id}/entries", get(handlers::entries::list_user_entries)) .route("/api/users/{user_id}/entries", get(handlers::entries::list_user_entries))
.route("/api/users/{user_id}/results", get(handlers::entries::get_user_results))
.route_layer(middleware::from_fn(require_auth)); .route_layer(middleware::from_fn(require_auth));
// Sources routes (need separate state for uploads path)
let sources_state = handlers::sources::SourcesState {
db: db.clone(),
uploads_path: PathBuf::from(&config.paths.uploads),
};
let sources_routes = Router::new()
.route("/api/sources", get(handlers::sources::list_sources)
.post(handlers::sources::upload_source))
.route("/api/sources/{id}", get(handlers::sources::get_source)
.delete(handlers::sources::delete_source))
.route("/api/sources/{id}/ocr", put(handlers::sources::update_ocr))
.layer(axum::extract::DefaultBodyLimit::max(config.paths.max_upload_mb as usize * 1024 * 1024))
.route_layer(middleware::from_fn(require_auth))
.with_state(sources_state);
// OCR routes (need Mistral config)
let ocr_state = handlers::ocr::OcrState {
db: db.clone(),
uploads_path: PathBuf::from(&config.paths.uploads),
mistral: config.mistral.clone(),
};
let ocr_routes = Router::new()
.route("/api/sources/{id}/parse", post(handlers::ocr::parse_source))
.route_layer(middleware::from_fn(require_auth))
.with_state(ocr_state);
Router::new() Router::new()
.merge(public_routes) .merge(public_routes)
.merge(protected_routes) .merge(protected_routes)
.merge(sources_routes)
.merge(ocr_routes)
.layer(auth_layer) .layer(auth_layer)
.with_state(db) .with_state(db)
} }
@@ -168,10 +199,18 @@ async fn require_auth(
} }
fn init_logging(config: &config::Config) { fn init_logging(config: &config::Config) {
let log_level = config.logging.level.parse().unwrap_or(tracing::Level::INFO); // Build filter: use configured level for our code, but restrict sqlx/sea_orm
let filter_str = format!(
"{},sqlx=warn,sea_orm=warn",
config.logging.level
);
let filter = tracing_subscriber::filter::EnvFilter::try_new(&filter_str)
.unwrap_or_else(|_| tracing_subscriber::filter::EnvFilter::new("info,sqlx=warn,sea_orm=warn"));
tracing_subscriber::registry() tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.with(tracing_subscriber::filter::LevelFilter::from_level(log_level)) .with(filter)
.init(); .init();
} }

View File

@@ -26,6 +26,9 @@ pub struct Model {
#[sea_orm(column_type = "Text", nullable)] #[sea_orm(column_type = "Text", nullable)]
pub notes: Option<String>, pub notes: Option<String>,
/// Optional foreign key to source document
pub source_id: Option<i32>,
pub created_at: DateTime, pub created_at: DateTime,
} }
@@ -44,6 +47,13 @@ pub enum Relation {
to = "crate::models::user::user::Column::Id" to = "crate::models::user::user::Column::Id"
)] )]
User, User,
#[sea_orm(
belongs_to = "super::source::Entity",
from = "Column::SourceId",
to = "super::source::Column::Id"
)]
Source,
} }
impl Related<super::biomarker::Entity> for Entity { impl Related<super::biomarker::Entity> for Entity {
@@ -58,4 +68,10 @@ impl Related<crate::models::user::user::Entity> for Entity {
} }
} }
impl Related<super::source::Entity> for Entity {
fn to() -> RelationDef {
Relation::Source.def()
}
}
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}

View File

@@ -4,8 +4,10 @@ pub mod biomarker;
pub mod biomarker_category; pub mod biomarker_category;
pub mod biomarker_entry; pub mod biomarker_entry;
pub mod biomarker_reference_rule; pub mod biomarker_reference_rule;
pub mod source;
pub use biomarker::Entity as Biomarker; pub use biomarker::Entity as Biomarker;
pub use biomarker_category::Entity as BiomarkerCategory; pub use biomarker_category::Entity as BiomarkerCategory;
pub use biomarker_entry::Entity as BiomarkerEntry; pub use biomarker_entry::Entity as BiomarkerEntry;
pub use biomarker_reference_rule::Entity as BiomarkerReferenceRule; pub use biomarker_reference_rule::Entity as BiomarkerReferenceRule;
pub use source::Entity as Source;

View File

@@ -0,0 +1,75 @@
//! Source entity - user-uploaded documents with OCR data.
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "sources")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
/// Foreign key to users table
pub user_id: i32,
/// Display name for the source
#[sea_orm(column_type = "Text")]
pub name: String,
/// Path to stored file
#[sea_orm(column_type = "Text")]
pub file_path: String,
/// MIME type (e.g., "application/pdf", "image/jpeg")
#[sea_orm(column_type = "Text")]
pub file_type: String,
/// File size in bytes
pub file_size: i64,
/// Parsing status: "pending", "processing", "parsed", "failed"
#[sea_orm(column_type = "Text")]
pub status: String,
/// Number of biomarkers extracted (populated after parsing)
#[sea_orm(nullable)]
pub biomarker_count: Option<i32>,
/// OCR parsed data as JSON
#[sea_orm(column_type = "Text", nullable)]
pub ocr_data: Option<String>,
/// Optional description/notes
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
/// When the file was uploaded
pub uploaded_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "crate::models::user::user::Entity",
from = "Column::UserId",
to = "crate::models::user::user::Column::Id"
)]
User,
#[sea_orm(has_many = "super::biomarker_entry::Entity")]
BiomarkerEntries,
}
impl Related<crate::models::user::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl Related<super::biomarker_entry::Entity> for Entity {
fn to() -> RelationDef {
Relation::BiomarkerEntries.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -18,6 +18,9 @@ pub struct Model {
/// Foreign key to roles table /// Foreign key to roles table
pub role_id: i32, pub role_id: i32,
/// Display name (optional, separate from username)
pub name: Option<String>,
// Profile fields // Profile fields
/// Height in centimeters /// Height in centimeters
pub height_cm: Option<f32>, pub height_cm: Option<f32>,
@@ -38,6 +41,12 @@ pub struct Model {
/// Foreign key to diet types /// Foreign key to diet types
pub diet_id: Option<i32>, pub diet_id: Option<i32>,
/// URL to profile avatar icon
pub avatar_url: Option<String>,
/// User's own Mistral API key (BYOK - Bring Your Own Key)
pub mistral_api_key: Option<String>,
pub created_at: DateTime, pub created_at: DateTime,
pub updated_at: DateTime, pub updated_at: DateTime,
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,17 +1,29 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { LoginPage } from './pages/Login' import { LoginPage } from './pages/Login'
import { SignupPage } from './pages/Signup' import { SignupPage } from './pages/Signup'
import { Dashboard } from './pages/Dashboard' import { DashboardPage } from './pages/Dashboard'
import { ProfilePage } from './pages/Profile' import { ProfilePage } from './pages/Profile'
import { InsightsPage } from './pages/Insights'
import { SourcesPage } from './pages/Sources'
import { AdminPage } from './pages/Admin'
import { Layout } from './components/Layout'
function App() { function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
{/* Public routes */}
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} /> <Route path="/signup" element={<SignupPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/" element={<Dashboard />} /> {/* Protected routes with Layout */}
<Route path="/" element={<Layout><DashboardPage /></Layout>} />
<Route path="/profile" element={<Layout><ProfilePage /></Layout>} />
<Route path="/insights" element={<Layout><InsightsPage /></Layout>} />
<Route path="/sources" element={<Layout><SourcesPage /></Layout>} />
<Route path="/admin" element={<Layout><AdminPage /></Layout>} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -0,0 +1,166 @@
import { useEffect, useState, type ReactNode } from 'react'
import { useNavigate, useLocation, Link } from 'react-router-dom'
interface User {
id: number
username: string
name: string | null
role: string
avatar_url: string | null
}
interface LayoutProps {
children: ReactNode
}
export function Layout({ children }: LayoutProps) {
const navigate = useNavigate()
const location = useLocation()
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [theme, setTheme] = useState(() =>
document.documentElement.getAttribute('data-theme') || 'light'
)
useEffect(() => {
fetch('/api/auth/me', { credentials: 'include' })
.then(res => res.json())
.then(data => {
if (!data.user) {
navigate('/login')
return null
}
return fetch(`/api/users/${data.user.id}`, { credentials: 'include' })
.then(res => res.json())
})
.then((profile) => {
if (profile) {
setUser(profile)
}
})
.catch(() => navigate('/login'))
.finally(() => setLoading(false))
}, [navigate])
const handleLogout = async () => {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
})
navigate('/login')
}
const toggleTheme = () => {
const newTheme = theme === 'dark' ? 'light' : 'dark'
document.documentElement.setAttribute('data-theme', newTheme)
localStorage.setItem('theme', newTheme)
setTheme(newTheme)
}
if (loading) {
return <div className="layout-loading">Loading...</div>
}
if (!user) {
return null
}
const displayName = user.name || user.username
const navItems = [
{ path: '/', label: 'Dashboard', icon: '/icons/healthcare/icons8-powerchart-50.png' },
{ path: '/profile', label: 'Profile', icon: '/icons/general/icons8-user-50.png' },
{ path: '/insights', label: 'Insights', icon: '/icons/general/icons8-idea-50.png', disabled: true },
{ path: '/sources', label: 'Sources', icon: '/icons/general/icons8-document-50.png' },
]
return (
<div className="app-layout">
{/* Sidebar */}
<aside className="sidebar">
<div className="sidebar-header">
<img src="/logo.svg" alt="zhealth" className="sidebar-logo" />
<span className="sidebar-title">zhealth</span>
</div>
<nav className="sidebar-nav">
{navItems.map(item => (
<Link
key={item.path}
to={item.disabled ? '#' : item.path}
className={`sidebar-link ${location.pathname === item.path ? 'active' : ''} ${item.disabled ? 'disabled' : ''}`}
onClick={e => item.disabled && e.preventDefault()}
>
<span className="sidebar-icon">
<img src={item.icon} alt={item.label} />
</span>
<span className="sidebar-label">{item.label}</span>
{item.disabled && <span className="sidebar-badge">Soon</span>}
</Link>
))}
</nav>
{/* Admin link for admins */}
{user.role === 'admin' && (
<div className="sidebar-section">
<div className="sidebar-section-title">Admin</div>
<Link
to="/admin"
className={`sidebar-link ${location.pathname === '/admin' ? 'active' : ''}`}
>
<span className="sidebar-icon">
<img src="/icons/user/icons8-add-user-group-woman-man-50.png" alt="Admin" />
</span>
<span className="sidebar-label">Manage Users</span>
</Link>
</div>
)}
<div className="sidebar-footer">
<div className="sidebar-user">
<div className="sidebar-avatar">
{user.avatar_url ? (
<img src={user.avatar_url} alt={displayName} className="avatar-img" />
) : (
<div className="avatar-placeholder">{displayName[0].toUpperCase()}</div>
)}
</div>
<div className="sidebar-user-info">
<div className="sidebar-user-name">{displayName}</div>
<div className="sidebar-user-role">
<span className={`indicator indicator-${user.role === 'admin' ? 'warning' : 'info'}`}>
{user.role}
</span>
</div>
</div>
</div>
<div className="sidebar-actions">
<button
className="sidebar-btn"
onClick={toggleTheme}
title={theme === 'dark' ? 'Light mode' : 'Dark mode'}
>
{theme === 'dark' ? (
<img src="/icons/general/icons8-sun-50.png" alt="Light" className="theme-icon" />
) : (
<img src="/icons/general/icons8-waxing-crescent-50.png" alt="Dark" className="theme-icon" />
)}
</button>
<button
className="sidebar-btn"
onClick={handleLogout}
title="Logout"
>
<img src="/icons/general/icons8-cancel-50.png" alt="Logout" className="theme-icon" />
</button>
</div>
</div>
</aside>
{/* Main Content */}
<main className="main-content">
{children}
</main>
</div>
)
}

View File

@@ -110,6 +110,224 @@ a:hover {
outline-offset: 2px; outline-offset: 2px;
} }
/* =====================================================
UTILITY CLASSES
===================================================== */
/* Spacing - Margin Bottom */
.mb-xs {
margin-bottom: var(--space-xs);
}
.mb-sm {
margin-bottom: var(--space-sm);
}
.mb-md {
margin-bottom: var(--space-md);
}
.mb-lg {
margin-bottom: var(--space-lg);
}
.mb-xl {
margin-bottom: var(--space-xl);
}
/* Spacing - Margin Top */
.mt-xs {
margin-top: var(--space-xs);
}
.mt-sm {
margin-top: var(--space-sm);
}
.mt-md {
margin-top: var(--space-md);
}
.mt-lg {
margin-top: var(--space-lg);
}
.mt-xl {
margin-top: var(--space-xl);
}
/* Spacing - Margin Left */
.ml-xs {
margin-left: var(--space-xs);
}
.ml-sm {
margin-left: var(--space-sm);
}
.ml-md {
margin-left: var(--space-md);
}
/* Spacing - Padding */
.p-sm {
padding: var(--space-sm);
}
.p-md {
padding: var(--space-md);
}
.p-lg {
padding: var(--space-lg);
}
.p-xl {
padding: var(--space-xl);
}
/* Flex Layouts */
.flex {
display: flex;
}
.flex-col {
display: flex;
flex-direction: column;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
/* Gaps */
.gap-xs {
gap: var(--space-xs);
}
.gap-sm {
gap: var(--space-sm);
}
.gap-md {
gap: var(--space-md);
}
.gap-lg {
gap: var(--space-lg);
}
/* Text Utilities */
.text-center {
text-align: center;
}
.text-xs {
font-size: 12px;
}
.text-sm {
font-size: 14px;
}
.font-medium {
font-weight: 500;
}
.font-semibold {
font-weight: 600;
}
.uppercase {
text-transform: uppercase;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
max-width: 400px;
text-align: center;
}
/* Display */
.hidden {
display: none;
}
.block {
display: block;
}
/* Width */
.w-full {
width: 100%;
}
.max-w-sm {
max-width: 400px;
}
.max-w-md {
max-width: 600px;
}
.max-w-lg {
max-width: 800px;
}
.max-w-xl {
max-width: 1000px;
}
/* Min Width / Flex */
.min-w-0 {
min-width: 0;
}
.flex-1 {
flex: 1;
}
/* Border */
.border-t {
border-top: 1px solid var(--border);
}
/* =====================================================
END UTILITY CLASSES
===================================================== */
/* Button Base */ /* Button Base */
.btn { .btn {
display: inline-flex; display: inline-flex;
@@ -322,4 +540,585 @@ select.input {
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: right 12px center; background-position: right 12px center;
padding-right: 36px; padding-right: 36px;
}
/* Table */
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: var(--space-sm) var(--space-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.table th {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary);
}
.table tr:hover {
background-color: var(--bg-secondary);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-lg);
width: 100%;
max-width: 400px;
}
.modal h2 {
margin-bottom: var(--space-lg);
}
/* Button variants */
.btn-sm {
padding: var(--space-xs) var(--space-sm);
font-size: 12px;
}
.btn-danger {
background-color: transparent;
color: var(--indicator-critical);
border: 1px solid var(--indicator-critical);
}
.btn-danger:hover {
background-color: color-mix(in srgb, var(--indicator-critical) 10%, transparent);
}
/* Collapsible header */
.collapsible-header:hover {
background-color: var(--bg-secondary) !important;
}
/* Biomarker chips */
.biomarker-chip {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-size: 13px;
cursor: default;
transition: background-color 0.15s;
}
.biomarker-chip:hover {
background-color: color-mix(in srgb, var(--accent) 10%, var(--bg-secondary));
border-color: var(--accent);
}
.biomarker-name {
font-weight: 500;
color: var(--text-primary);
}
.biomarker-unit {
color: var(--text-secondary);
font-size: 11px;
}
/* Biomarker List Layout */
.biomarker-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.biomarker-row {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm);
border-radius: var(--radius-sm);
transition: background-color 0.15s;
}
.biomarker-row:hover {
background-color: var(--bg-primary);
}
.biomarker-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--text-secondary);
flex-shrink: 0;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
}
.biomarker-dot.status-low {
background: var(--warning);
}
.biomarker-dot.status-normal {
background: var(--success);
}
.biomarker-dot.status-high {
background: var(--error);
}
.biomarker-info {
flex: 0 0 280px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.biomarker-name {
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.biomarker-unit,
.biomarker-value {
font-size: 0.75rem;
color: var(--text-secondary);
}
.biomarker-value {
font-weight: 600;
color: var(--text-primary);
}
/* Biomarker Scale Bar */
.biomarker-scale {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
}
.scale-bar {
width: 220px;
height: 8px;
border-radius: 4px;
background: var(--border);
position: relative;
overflow: visible;
}
.scale-bar.placeholder {
background: var(--border);
}
.scale-bar.range {
background: var(--accent);
}
.scale-marker {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.4);
border: 2px solid var(--bg-secondary);
background: var(--accent);
}
.scale-labels {
display: flex;
justify-content: space-between;
width: 140px;
font-size: 0.65rem;
color: var(--text-secondary);
}
.text-muted {
color: var(--text-secondary);
}
/* App Layout with Sidebar */
.app-layout {
display: flex;
min-height: 100vh;
}
.layout-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
color: var(--text-secondary);
}
/* Sidebar */
.sidebar {
width: 240px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
left: 0;
top: 0;
}
.sidebar-header {
padding: var(--space-md);
display: flex;
align-items: center;
gap: var(--space-sm);
border-bottom: 1px solid var(--border);
}
.sidebar-logo {
width: 28px;
height: 28px;
}
.sidebar-title {
font-size: 18px;
font-weight: 600;
}
.sidebar-nav {
flex: 1;
padding: var(--space-sm);
overflow-y: auto;
}
.sidebar-link {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
margin-bottom: 2px;
transition: background-color 0.15s, color 0.15s;
}
.sidebar-link:hover {
background-color: var(--bg-primary);
color: var(--text-primary);
text-decoration: none;
}
.sidebar-link.active {
background-color: var(--accent);
color: white;
}
.sidebar-link.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sidebar-link.disabled:hover {
background-color: transparent;
color: var(--text-secondary);
}
.sidebar-icon {
font-size: 16px;
}
.sidebar-label {
flex: 1;
}
.sidebar-badge {
font-size: 10px;
padding: 2px 6px;
background: var(--border);
color: var(--text-secondary);
border-radius: 9999px;
}
.sidebar-section {
padding: var(--space-sm);
border-top: 1px solid var(--border);
}
.sidebar-section-title {
font-size: 11px;
text-transform: uppercase;
color: var(--text-secondary);
padding: var(--space-xs) var(--space-md);
margin-bottom: var(--space-xs);
}
.sidebar-footer {
padding: var(--space-md);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-user {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-sm);
flex: 1;
}
.sidebar-avatar {
width: 32px;
height: 32px;
flex-shrink: 0;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--border);
}
.avatar-placeholder {
width: 100%;
height: 100%;
border-radius: 50%;
background: var(--accent);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
}
.sidebar-user-info {
min-width: 0;
display: flex;
flex-direction: row;
align-items: center;
gap: var(--space-xs);
}
.sidebar-user-name {
font-size: 14px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Avatar Picker in Profile */
.avatar-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: var(--space-sm);
margin-top: var(--space-xs);
}
.avatar-option {
width: 60px;
height: 60px;
padding: 4px;
border: 2px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color 0.2s, transform 0.1s;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-option:hover {
background: var(--bg-primary);
transform: scale(1.05);
}
.avatar-option.selected {
border-color: var(--accent);
background: var(--bg-primary);
}
.avatar-option img {
width: 100%;
height: 100%;
object-fit: contain;
}
.sidebar-actions {
display: flex;
gap: var(--space-xs);
}
.sidebar-btn {
background: none;
border: 1px solid var(--border);
padding: var(--space-xs);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 14px;
transition: background 0.15s;
}
.sidebar-btn:hover {
background: var(--bg-primary);
}
.theme-icon {
width: 18px;
height: 18px;
display: block;
}
/* Invert icons in dark mode if they are dark-oriented */
[data-theme='dark'] .theme-icon {
filter: invert(1) brightness(2);
}
.sidebar-icon img {
width: 20px;
height: 20px;
}
/* Main Content */
.main-content {
flex: 1;
margin-left: 240px;
min-height: 100vh;
background: var(--bg-primary);
}
/* Page wrapper */
.page {
padding: var(--space-lg);
}
.page-header {
margin-bottom: var(--space-xl);
}
.page-header h1 {
margin-bottom: var(--space-xs);
}
.page-loading {
padding: var(--space-lg);
color: var(--text-secondary);
}
/* =====================================================
SOURCES PAGE
===================================================== */
/* Upload Zone */
.upload-zone {
border: 2px dashed var(--border);
border-radius: var(--radius-md);
padding: var(--space-xl);
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.upload-zone:hover {
border-color: var(--accent);
background-color: color-mix(in srgb, var(--accent) 5%, var(--bg-secondary));
}
.upload-zone.drag-over {
border-color: var(--accent);
background-color: color-mix(in srgb, var(--accent) 5%, var(--bg-secondary));
}
.upload-zone.uploading {
cursor: wait;
}
/* Upload Icon */
.upload-icon {
width: 36px;
height: 36px;
display: block;
margin: 0 auto;
}
/* Source Item */
.source-item {
padding: var(--space-sm) 0;
border-bottom: 1px solid var(--border);
}
/* Status Parsed */
.status-parsed {
color: var(--indicator-normal);
}
/* Icon sizes */
.icon-sm {
width: 14px;
height: 14px;
}
/* =====================================================
DASHBOARD PAGE
===================================================== */
/* Category Card */
.category-card {
padding: 0;
}
/* Category Name */
.category-name {
font-size: 16px;
font-weight: 600;
text-transform: uppercase;
}
/* Collapsible Header Button */
.collapsible-header {
background: none;
border: none;
cursor: pointer;
text-align: left;
color: var(--text-primary);
}
/* Collapse Icon */
.collapse-icon {
width: 18px;
height: 18px;
transition: transform 0.2s;
}
/* =====================================================
INSIGHTS PAGE
===================================================== */
.coming-soon-icon {
font-size: 48px;
} }

View File

@@ -0,0 +1,432 @@
import { useEffect, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
interface User {
id: number
username: string
name: string | null
role: string
height_cm: number | null
blood_type: string | null
birthdate: string | null
smoking: boolean | null
alcohol: boolean | null
diet: string | null
created_at: string
}
interface NewUser {
username: string
password: string
role_name: string
}
// Admin username from config (cannot be deleted or have password reset)
const CONFIG_ADMIN_USERNAME = 'admin'
export function AdminPage() {
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [users, setUsers] = useState<User[]>([])
const [currentUser, setCurrentUser] = useState<{ role: string } | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [resetPasswordUser, setResetPasswordUser] = useState<User | null>(null)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
// Create user form
const [newUser, setNewUser] = useState<NewUser>({
username: '',
password: '',
role_name: 'user',
})
useEffect(() => {
// Check if admin
fetch('/api/auth/me', { credentials: 'include' })
.then(res => res.json())
.then(data => {
if (!data.user) {
navigate('/login')
return
}
if (data.user.role !== 'admin') {
navigate('/')
return
}
setCurrentUser(data.user)
loadUsers()
})
.catch(() => navigate('/login'))
}, [navigate])
const loadUsers = async () => {
try {
const res = await fetch('/api/users', { credentials: 'include' })
if (res.ok) {
const data = await res.json()
setUsers(data)
}
} catch {
setMessage({ type: 'error', text: 'Failed to load users' })
} finally {
setLoading(false)
}
}
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault()
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(newUser),
})
if (res.ok) {
setMessage({ type: 'success', text: 'User created successfully' })
setShowCreateModal(false)
setNewUser({ username: '', password: '', role_name: 'user' })
loadUsers()
} else {
setMessage({ type: 'error', text: 'Failed to create user' })
}
} catch {
setMessage({ type: 'error', text: 'Network error' })
}
}
const handleDeleteUser = async (id: number, username: string) => {
if (!confirm(`Delete user "${username}"?`)) return
try {
const res = await fetch(`/api/users/${id}`, {
method: 'DELETE',
credentials: 'include',
})
if (res.ok) {
setMessage({ type: 'success', text: 'User deleted' })
loadUsers()
} else {
setMessage({ type: 'error', text: 'Failed to delete user' })
}
} catch {
setMessage({ type: 'error', text: 'Network error' })
}
}
// Check if user is the config admin (cannot delete or reset password)
const isConfigAdmin = (user: User) => user.username === CONFIG_ADMIN_USERNAME
if (loading) {
return <div className="p-lg">Loading...</div>
}
if (!currentUser || currentUser.role !== 'admin') {
return null
}
return (
<div className="admin-page p-lg max-w-xl">
<header className="mb-xl">
<Link to="/" className="text-secondary text-sm"> Back to Dashboard</Link>
<div className="flex-between mt-sm">
<h1>User Management</h1>
<button className="btn btn-primary" onClick={() => setShowCreateModal(true)}>
+ New User
</button>
</div>
</header>
{message && (
<div className={message.type === 'success' ? 'success-message' : 'error-message'}>
{message.text}
</div>
)}
{/* Users Table */}
<div className="card">
<table className="table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Name</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.username}</td>
<td>{user.name || '—'}</td>
<td>
<span className={`indicator indicator-${user.role === 'admin' ? 'warning' : 'info'}`}>
{user.role}
</span>
</td>
<td className="text-secondary text-sm">{new Date(user.created_at).toLocaleDateString()}</td>
<td>
<div className="flex gap-xs">
<button
className="btn btn-secondary btn-sm"
onClick={() => setEditingUser(user)}
>
Edit
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => setResetPasswordUser(user)}
disabled={isConfigAdmin(user)}
title={isConfigAdmin(user) ? 'Admin password is set in config' : 'Reset password'}
>
Reset Password
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => handleDeleteUser(user.id, user.username)}
disabled={isConfigAdmin(user)}
title={isConfigAdmin(user) ? 'Cannot delete config admin' : 'Delete user'}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Create User Modal */}
{showCreateModal && (
<div className="modal-overlay" onClick={() => setShowCreateModal(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h2>Create New User</h2>
<form onSubmit={handleCreateUser}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
className="input"
value={newUser.username}
onChange={e => setNewUser({ ...newUser, username: e.target.value })}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
className="input"
value={newUser.password}
onChange={e => setNewUser({ ...newUser, password: e.target.value })}
required
/>
</div>
<div className="form-group">
<label htmlFor="role">Role</label>
<select
id="role"
className="input"
value={newUser.role_name}
onChange={e => setNewUser({ ...newUser, role_name: e.target.value })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="flex gap-sm mt-lg">
<button type="submit" className="btn btn-primary">Create</button>
<button type="button" className="btn btn-secondary" onClick={() => setShowCreateModal(false)}>Cancel</button>
</div>
</form>
</div>
</div>
)}
{/* Edit User Modal */}
{editingUser && (
<EditUserModal
user={editingUser}
onClose={() => setEditingUser(null)}
onSave={() => { setEditingUser(null); loadUsers(); }}
setMessage={setMessage}
/>
)}
{/* Reset Password Modal */}
{resetPasswordUser && (
<ResetPasswordModal
user={resetPasswordUser}
onClose={() => setResetPasswordUser(null)}
onSave={() => { setResetPasswordUser(null); }}
setMessage={setMessage}
/>
)}
</div>
)
}
interface EditUserModalProps {
user: User
onClose: () => void
onSave: () => void
setMessage: (msg: { type: 'success' | 'error'; text: string } | null) => void
}
function EditUserModal({ user, onClose, onSave, setMessage }: EditUserModalProps) {
const [name, setName] = useState(user.name || '')
const [saving, setSaving] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
try {
const res = await fetch(`/api/users/${user.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name: name || null }),
})
if (res.ok) {
setMessage({ type: 'success', text: 'User updated' })
onSave()
} else {
setMessage({ type: 'error', text: 'Failed to update user' })
}
} catch {
setMessage({ type: 'error', text: 'Network error' })
} finally {
setSaving(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h2>Edit User: {user.username}</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="edit-name">Display Name</label>
<input
id="edit-name"
type="text"
className="input"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Full name"
/>
</div>
<div className="flex gap-sm mt-lg">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</button>
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancel</button>
</div>
</form>
</div>
</div>
)
}
interface ResetPasswordModalProps {
user: User
onClose: () => void
onSave: () => void
setMessage: (msg: { type: 'success' | 'error'; text: string } | null) => void
}
function ResetPasswordModal({ user, onClose, onSave, setMessage }: ResetPasswordModalProps) {
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (newPassword.length < 4) {
setError('Password must be at least 4 characters')
return
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match')
return
}
setSaving(true)
try {
const res = await fetch(`/api/users/${user.id}/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ new_password: newPassword }),
})
if (res.ok) {
setMessage({ type: 'success', text: `Password reset for ${user.username}` })
onSave()
} else {
setMessage({ type: 'error', text: 'Failed to reset password' })
}
} catch {
setMessage({ type: 'error', text: 'Network error' })
} finally {
setSaving(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h2>Reset Password: {user.username}</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="new-password">New Password</label>
<input
id="new-password"
type="password"
className="input"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="Enter new password"
required
/>
</div>
<div className="form-group">
<label htmlFor="confirm-password">Confirm Password</label>
<input
id="confirm-password"
type="password"
className="input"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
required
/>
</div>
{error && <div className="error-message">{error}</div>}
<div className="flex gap-sm mt-lg">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Resetting...' : 'Reset Password'}
</button>
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancel</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,90 +1,202 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
interface User { interface Category {
id: number id: number
username: string name: string
role: string description: string | null
} }
export function Dashboard() { interface BiomarkerResult {
const navigate = useNavigate() biomarker_id: number
const [user, setUser] = useState<User | null>(null) name: string
category_id: number
unit: string
value: number | null
measured_at: string | null
ref_min: number | null
ref_max: number | null
label: string
severity: number
}
// Severity to color mapping
const severityColors: Record<number, string> = {
0: 'var(--indicator-normal)', // Normal - green
1: 'var(--indicator-warning)', // Mild - yellow/orange
2: '#ff8c00', // Moderate - dark orange
3: 'var(--indicator-critical)', // Severe - red
4: '#8b0000', // Critical - dark red
}
export function DashboardPage() {
const [categories, setCategories] = useState<Category[]>([])
const [results, setResults] = useState<BiomarkerResult[]>([])
const [expandedCategories, setExpandedCategories] = useState<Set<number>>(new Set())
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
fetch('/api/auth/me', { credentials: 'include' }) const fetchData = async () => {
.then(res => res.json()) try {
.then(data => { // Get current user
if (data.user) { const authRes = await fetch('/api/auth/me', { credentials: 'include' })
setUser(data.user) if (!authRes.ok) return
} else { const authData = await authRes.json()
navigate('/login') const user = authData.user
} if (!user) return // Not authenticated
})
.catch(() => navigate('/login'))
.finally(() => setLoading(false))
}, [navigate])
const handleLogout = async () => { // Fetch categories and results in parallel
await fetch('/api/auth/logout', { const [catsRes, resultsRes] = await Promise.all([
method: 'POST', fetch('/api/categories', { credentials: 'include' }),
credentials: 'include', fetch(`/api/users/${user.id}/results`, { credentials: 'include' }),
])
if (catsRes.ok && resultsRes.ok) {
setCategories(await catsRes.json())
setResults(await resultsRes.json())
}
} catch (error) {
console.error('Failed to load dashboard data:', error)
} finally {
setLoading(false)
}
}
fetchData()
}, [])
const toggleCategory = (categoryId: number) => {
setExpandedCategories(prev => {
const next = new Set(prev)
if (next.has(categoryId)) {
next.delete(categoryId)
} else {
next.add(categoryId)
}
return next
}) })
navigate('/login') }
const getResultsForCategory = (categoryId: number) => {
return results.filter(r => r.category_id === categoryId)
}
// Calculate scale bar position (0-100%)
const getScalePosition = (result: BiomarkerResult): number | null => {
if (result.value === null || result.ref_min === null || result.ref_max === null) {
return null
}
const range = result.ref_max - result.ref_min
if (range <= 0) return 50
// Clamp to 5-95% for visual bounds
const pos = ((result.value - result.ref_min) / range) * 100
return Math.max(5, Math.min(95, pos))
} }
if (loading) { if (loading) {
return <div style={{ padding: 'var(--space-lg)' }}>Loading...</div> return <div className="page-loading">Loading biomarkers...</div>
}
if (!user) {
return null
}
const toggleTheme = () => {
const current = document.documentElement.getAttribute('data-theme')
const newTheme = current === 'dark' ? 'light' : 'dark'
document.documentElement.setAttribute('data-theme', newTheme)
localStorage.setItem('theme', newTheme)
} }
return ( return (
<div style={{ padding: 'var(--space-lg)' }}> <div className="page">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}> <header className="page-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}> <h1>Dashboard</h1>
<img src="/logo.svg" alt="zhealth" style={{ width: '32px', height: '32px' }} /> <p className="text-secondary">Your latest biomarker results</p>
<h1>zhealth</h1>
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
<button className="btn btn-secondary" onClick={toggleTheme}>
Theme
</button>
<button className="btn btn-secondary" onClick={handleLogout}>
Logout
</button>
</div>
</header> </header>
<main> <section>
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}> <h2 className="mb-md">Biomarker Categories</h2>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}> <div className="flex-col gap-sm">
<div> {categories.map(category => {
<h3 style={{ marginBottom: 'var(--space-sm)' }}>Welcome, {user.username}</h3> const categoryResults = getResultsForCategory(category.id)
<p className="text-secondary text-sm"> const isExpanded = expandedCategories.has(category.id)
Role: <span className="indicator indicator-info">{user.role}</span> // Count how many have data
</p> const withData = categoryResults.filter(r => r.value !== null).length
</div>
<Link to="/profile" className="btn btn-secondary">
Edit Profile
</Link>
</div>
</div>
<p className="text-secondary text-sm"> return (
Dashboard coming soon... <div key={category.id} className="card category-card">
</p> <button
</main> className="collapsible-header w-full p-md flex-between"
onClick={() => toggleCategory(category.id)}
>
<div>
<span className="category-name">{category.name}</span>
<span className="text-secondary text-sm ml-sm">
({withData}/{categoryResults.length} biomarkers)
</span>
</div>
<img
src="/icons/general/icons8-collapse-arrow-50.png"
alt="expand"
className="theme-icon collapse-icon"
style={{
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0)'
}}
/>
</button>
{isExpanded && (
<div className="category-content border-t p-sm">
{categoryResults.length === 0 ? (
<p className="text-secondary text-sm p-sm">
No biomarkers in this category
</p>
) : (
<div className="biomarker-list">
{categoryResults.map(result => {
const scalePos = getScalePosition(result)
const dotColor = result.value !== null
? severityColors[result.severity] || severityColors[0]
: 'var(--text-secondary)'
return (
<div key={result.biomarker_id} className="biomarker-row">
<div
className="biomarker-dot"
title={result.label}
style={{ backgroundColor: dotColor }}
/>
<div className="biomarker-info">
<span className="biomarker-name">{result.name}</span>
{result.value !== null ? (
<span className="biomarker-value">
{result.value.toFixed(2)} {result.unit}
</span>
) : (
<span className="biomarker-unit text-muted">
No data
</span>
)}
</div>
<div className="biomarker-scale">
<div className="scale-bar">
{scalePos !== null && (
<div
className="scale-marker"
style={{
left: `${scalePos}%`,
backgroundColor: dotColor
}}
/>
)}
</div>
{result.ref_min !== null && result.ref_max !== null && (
<div className="scale-labels">
<span>{result.ref_min}</span>
<span>{result.ref_max}</span>
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)}
</div>
)
})}
</div>
</section>
</div> </div>
) )
} }

View File

@@ -0,0 +1,18 @@
export function InsightsPage() {
return (
<div className="page">
<header className="page-header">
<h1>Insights</h1>
<p className="text-secondary">AI-powered analysis of your health data</p>
</header>
<div className="card text-center p-xl">
<div className="coming-soon-icon mb-md">🚀</div>
<h3>Coming Soon</h3>
<p className="text-secondary">
AI-generated health insights and recommendations based on your biomarker data.
</p>
</div>
</div>
)
}

View File

@@ -91,7 +91,7 @@ export function LoginPage() {
</button> </button>
</form> </form>
<p className="text-secondary text-sm" style={{ marginTop: 'var(--space-md)', textAlign: 'center' }}> <p className="text-secondary text-sm mt-md text-center">
Don't have an account? <Link to="/signup">Sign up</Link> Don't have an account? <Link to="/signup">Sign up</Link>
</p> </p>
</div> </div>

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
interface Diet { interface Diet {
id: number id: number
@@ -10,6 +9,7 @@ interface Diet {
interface UserProfile { interface UserProfile {
id: number id: number
username: string username: string
name: string | null
role: string role: string
height_cm: number | null height_cm: number | null
blood_type: string | null blood_type: string | null
@@ -17,10 +17,11 @@ interface UserProfile {
smoking: boolean | null smoking: boolean | null
alcohol: boolean | null alcohol: boolean | null
diet: string | null diet: string | null
avatar_url: string | null
has_mistral_key: boolean
} }
export function ProfilePage() { export function ProfilePage() {
const navigate = useNavigate()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
@@ -29,32 +30,39 @@ export function ProfilePage() {
// Form state // Form state
const [userId, setUserId] = useState<number | null>(null) const [userId, setUserId] = useState<number | null>(null)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [name, setName] = useState('')
const [heightCm, setHeightCm] = useState('') const [heightCm, setHeightCm] = useState('')
const [bloodType, setBloodType] = useState('') const [bloodType, setBloodType] = useState('')
const [birthdate, setBirthdate] = useState('') const [birthdate, setBirthdate] = useState('')
const [smoking, setSmoking] = useState<boolean | null>(null) const [smoking, setSmoking] = useState<boolean | null>(null)
const [alcohol, setAlcohol] = useState<boolean | null>(null) const [alcohol, setAlcohol] = useState<boolean | null>(null)
const [dietId, setDietId] = useState<number | null>(null) const [dietId, setDietId] = useState<number | null>(null)
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
const [mistralApiKey, setMistralApiKey] = useState('')
const [hasMistralKey, setHasMistralKey] = useState(false)
const avatarOptions = [
...[1, 2, 3, 4, 5, 6, 7].map(i => `/icons/user/icons8-male-user-50${i === 1 ? '' : `-${i}`}.png`),
...['', '-2', '-3', '-4', '-5', '-7', '-8'].map(s => `/icons/user/icons8-user-50${s}.png`),
]
useEffect(() => { useEffect(() => {
// Fetch current user and diets // Fetch current user and diets (Layout already ensures auth)
Promise.all([ Promise.all([
fetch('/api/auth/me', { credentials: 'include' }).then(r => r.json()), fetch('/api/auth/me', { credentials: 'include' }).then(r => r.json()),
fetch('/api/diets', { credentials: 'include' }).then(r => r.json()), fetch('/api/diets', { credentials: 'include' }).then(r => r.json()),
]) ])
.then(([authData, dietsData]) => { .then(([authData, dietsData]) => {
if (!authData.user) { if (!authData.user) return
navigate('/login')
return
}
setDiets(dietsData) setDiets(dietsData)
// Now fetch full user profile // Fetch full user profile
return fetch(`/api/users/${authData.user.id}`, { credentials: 'include' }) return fetch(`/api/users/${authData.user.id}`, { credentials: 'include' })
.then(r => r.json()) .then(r => r.json())
.then((profile: UserProfile) => { .then((profile: UserProfile) => {
setUserId(profile.id) setUserId(profile.id)
setUsername(profile.username) setUsername(profile.username)
setName(profile.name || '')
setHeightCm(profile.height_cm?.toString() || '') setHeightCm(profile.height_cm?.toString() || '')
setBloodType(profile.blood_type || '') setBloodType(profile.blood_type || '')
setBirthdate(profile.birthdate || '') setBirthdate(profile.birthdate || '')
@@ -63,11 +71,19 @@ export function ProfilePage() {
// Find diet ID from name // Find diet ID from name
const diet = dietsData.find((d: Diet) => d.name === profile.diet) const diet = dietsData.find((d: Diet) => d.name === profile.diet)
setDietId(diet?.id || null) setDietId(diet?.id || null)
setAvatarUrl(profile.avatar_url)
setHasMistralKey(profile.has_mistral_key)
}) })
}) })
.catch(() => navigate('/login')) .finally(() => {
.finally(() => setLoading(false)) setLoading(false)
}, [navigate]) // Show success message if we just saved
if (localStorage.getItem('profileSaved') === 'true') {
setMessage({ type: 'success', text: 'Profile updated successfully' })
localStorage.removeItem('profileSaved')
}
})
}, [])
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -82,17 +98,21 @@ export function ProfilePage() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ body: JSON.stringify({
name: name || null,
height_cm: heightCm ? parseFloat(heightCm) : null, height_cm: heightCm ? parseFloat(heightCm) : null,
blood_type: bloodType || null, blood_type: bloodType || null,
birthdate: birthdate || null, birthdate: birthdate || null,
smoking, smoking,
alcohol, alcohol,
diet_id: dietId, diet_id: dietId,
avatar_url: avatarUrl,
mistral_api_key: mistralApiKey || null,
}), }),
}) })
if (res.ok) { if (res.ok) {
setMessage({ type: 'success', text: 'Profile updated successfully' }) localStorage.setItem('profileSaved', 'true')
window.location.reload()
} else { } else {
setMessage({ type: 'error', text: 'Failed to update profile' }) setMessage({ type: 'error', text: 'Failed to update profile' })
} }
@@ -104,29 +124,54 @@ export function ProfilePage() {
} }
if (loading) { if (loading) {
return <div style={{ padding: 'var(--space-lg)' }}>Loading...</div> return <div className="page-loading">Loading...</div>
} }
return ( return (
<div style={{ padding: 'var(--space-lg)', maxWidth: '600px', margin: '0 auto' }}> <div className="page max-w-md">
<header style={{ marginBottom: 'var(--space-xl)' }}> <header className="page-header">
<Link to="/" className="text-secondary text-sm"> Back to Dashboard</Link> <h1>Profile</h1>
<h1 style={{ marginTop: 'var(--space-sm)' }}>Profile</h1> <p className="text-secondary">Manage your account and health information</p>
</header> </header>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{/* Username (read-only) */} {/* Account Info */}
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}> <div className="card mb-lg">
<h3 style={{ marginBottom: 'var(--space-md)' }}>Account</h3> <h3 className="mb-md">Account</h3>
<div className="form-group"> <div className="form-group">
<label>Username</label> <label>Username</label>
<input type="text" className="input" value={username} disabled /> <input type="text" className="input" value={username} disabled />
</div> </div>
<div className="form-group">
<label htmlFor="name">Display Name</label>
<input
id="name"
type="text"
className="input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your full name"
/>
</div>
<div className="form-group">
<label>Profile Avatar</label>
<div className="avatar-picker">
{avatarOptions.map(option => (
<div
key={option}
className={`avatar-option ${avatarUrl === option ? 'selected' : ''}`}
onClick={() => setAvatarUrl(option)}
>
<img src={option} alt="Avatar option" />
</div>
))}
</div>
</div>
</div> </div>
{/* Physical Info */} {/* Physical Info */}
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}> <div className="card mb-lg">
<h3 style={{ marginBottom: 'var(--space-md)' }}>Physical Info</h3> <h3 className="mb-md">Physical Info</h3>
<div className="form-group"> <div className="form-group">
<label htmlFor="height">Height (cm)</label> <label htmlFor="height">Height (cm)</label>
@@ -174,8 +219,8 @@ export function ProfilePage() {
</div> </div>
{/* Lifestyle */} {/* Lifestyle */}
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}> <div className="card mb-lg">
<h3 style={{ marginBottom: 'var(--space-md)' }}>Lifestyle</h3> <h3 className="mb-md">Lifestyle</h3>
<div className="form-group"> <div className="form-group">
<label htmlFor="diet">Diet</label> <label htmlFor="diet">Diet</label>
@@ -237,6 +282,27 @@ export function ProfilePage() {
</div> </div>
</div> </div>
{/* API Keys */}
<div className="card mb-lg">
<h3 className="mb-md">API Keys</h3>
<p className="text-secondary text-sm mb-md">Use your own Mistral API key for document parsing (optional)</p>
<div className="form-group">
<label htmlFor="mistralKey">Mistral API Key</label>
<input
id="mistralKey"
type="password"
className="input"
value={mistralApiKey}
onChange={(e) => setMistralApiKey(e.target.value)}
placeholder={hasMistralKey ? '••••••••••••••••' : 'Enter your API key'}
/>
{hasMistralKey && (
<span className="text-xs text-secondary mt-xs">You have an API key configured. Enter a new one to update.</span>
)}
</div>
</div>
{message && ( {message && (
<div className={message.type === 'success' ? 'success-message' : 'error-message'}> <div className={message.type === 'success' ? 'success-message' : 'error-message'}>
{message.text} {message.text}

View File

@@ -98,7 +98,7 @@ export function SignupPage() {
</button> </button>
</form> </form>
<p className="text-secondary text-sm" style={{ marginTop: 'var(--space-md)', textAlign: 'center' }}> <p className="text-secondary text-sm mt-md text-center">
Already have an account? <Link to="/login">Sign in</Link> Already have an account? <Link to="/login">Sign in</Link>
</p> </p>
</div> </div>

View File

@@ -0,0 +1,282 @@
import { useEffect, useRef, useState } from 'react'
interface Source {
id: number
user_id: number
name: string
file_path: string
file_type: string
file_size: number
status: string
biomarker_count: number | null
ocr_data: string | null
description: string | null
uploaded_at: string
}
export function SourcesPage() {
const [sources, setSources] = useState<Source[]>([])
const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [dragOver, setDragOver] = useState(false)
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
const [parsingId, setParsingId] = useState<number | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// Fetch sources on mount
useEffect(() => {
fetchSources()
}, [])
const fetchSources = async () => {
try {
const res = await fetch('/api/sources', { credentials: 'include' })
if (res.ok) {
const data = await res.json()
setSources(data)
}
} catch (e) {
console.error('Failed to fetch sources:', e)
} finally {
setLoading(false)
}
}
const handleUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return
setUploading(true)
setError(null)
// Get current user ID from session
const authRes = await fetch('/api/auth/me', { credentials: 'include' })
const authData = await authRes.json()
if (!authData.user) {
setError('Please log in to upload files')
setUploading(false)
return
}
for (const file of Array.from(files)) {
const formData = new FormData()
formData.append('file', file)
formData.append('user_id', authData.user.id.toString())
formData.append('name', file.name)
try {
const res = await fetch('/api/sources', {
method: 'POST',
credentials: 'include',
body: formData,
})
if (!res.ok) {
const err = await res.text()
throw new Error(err || 'Upload failed')
}
} catch (e) {
setError(`Failed to upload ${file.name}`)
console.error(e)
}
}
setUploading(false)
fetchSources() // Refresh the list
}
const handleDelete = async (id: number) => {
try {
const res = await fetch(`/api/sources/${id}`, {
method: 'DELETE',
credentials: 'include',
})
if (res.ok) {
setSources(sources.filter(s => s.id !== id))
}
} catch (e) {
console.error('Failed to delete:', e)
} finally {
setDeleteConfirmId(null)
}
}
const handleParse = async (id: number) => {
setParsingId(id)
setError(null)
try {
const res = await fetch(`/api/sources/${id}/parse`, {
method: 'POST',
credentials: 'include',
})
if (res.ok) {
const data = await res.json()
// Refresh sources to show updated status
fetchSources()
console.log('Parsed:', data)
} else {
const err = await res.json()
setError(err.error || 'Parse failed')
}
} catch (e) {
console.error('Failed to parse:', e)
setError('Failed to parse document')
} finally {
setParsingId(null)
}
}
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
}
return (
<div className="page">
<header className="page-header">
<h1>Sources</h1>
<p className="text-secondary">Upload and manage your health data sources</p>
</header>
<div className="card">
<h3 className="mb-md">Upload Data</h3>
<p className="text-secondary text-sm mb-lg">
Upload lab reports in PDF, CSV, or Excel format to import your biomarker data.
</p>
{error && (
<div className="alert alert-error mb-md">
{error}
</div>
)}
<input
type="file"
ref={fileInputRef}
className="hidden"
multiple
accept=".pdf,.csv,.xlsx,.xls,.jpg,.jpeg,.png"
onChange={(e) => handleUpload(e.target.files)}
/>
<div
className={`upload-zone ${dragOver ? 'drag-over' : ''}`}
onClick={() => !uploading && fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault()
setDragOver(false)
handleUpload(e.dataTransfer.files)
}}
>
{uploading ? (
<>
<div className="mb-sm text-center">
<img src="/icons/general/icons8-clock-50.png" alt="Uploading" className="upload-icon theme-icon" />
</div>
<p className="text-secondary">Uploading...</p>
</>
) : (
<>
<div className="mb-sm text-center">
<img src="/icons/general/icons8-upload-to-the-cloud-50.png" alt="Upload" className="upload-icon theme-icon" />
</div>
<p className="text-secondary">
Drag & drop files here, or click to browse
</p>
<p className="text-secondary text-xs mt-sm">
Supported: PDF, CSV, XLSX, Images
</p>
</>
)}
</div>
</div>
<div className="card mt-lg">
<h3 className="mb-md">Recent Uploads</h3>
{loading ? (
<p className="text-secondary text-sm">Loading...</p>
) : sources.length === 0 ? (
<p className="text-secondary text-sm">No files uploaded yet.</p>
) : (
<div className="sources-list">
{sources.map(source => (
<div key={source.id} className="source-item flex-between">
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{source.name}
</div>
<div className="text-secondary text-xs">
{source.file_type} {formatFileSize(source.file_size)} {formatDate(source.uploaded_at)}
</div>
</div>
<div className="flex gap-sm items-center">
{source.status === 'parsed' ? (
<span className="status-parsed flex items-center gap-xs text-xs">
<img src="/icons/general/icons8-checkmark-50.png" alt="Parsed" className="icon-sm" />
{source.biomarker_count ? `${source.biomarker_count} biomarkers` : 'Parsed'}
</span>
) : source.status === 'processing' ? (
<span className="status-processing text-xs text-secondary">
Processing...
</span>
) : source.status === 'failed' ? (
<button
className="btn btn-primary btn-sm"
onClick={() => handleParse(source.id)}
disabled={parsingId === source.id}
>
Retry
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleParse(source.id)}
disabled={parsingId === source.id}
>
Parse
</button>
)}
<button
className="btn btn-danger btn-sm"
onClick={() => setDeleteConfirmId(source.id)}
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmId !== null && (
<div className="modal-overlay">
<div className="card modal-content">
<h3 className="mb-md">Delete File?</h3>
<p className="text-secondary mb-lg">
Are you sure you want to delete this file? This action cannot be undone.
</p>
<div className="flex gap-sm justify-center">
<button className="btn" onClick={() => setDeleteConfirmId(null)}>
Cancel
</button>
<button className="btn btn-danger" onClick={() => handleDelete(deleteConfirmId)}>
Delete
</button>
</div>
</div>
</div>
)}
</div>
)
}