Compare commits

...

19 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
f2f2d1dec7 feat: Enhance Makefile with frontend commands, database seeding, and improved serve and clean targets. 2025-12-19 17:51:36 +05:30
0f6ef74f6c feat: add user profile page with editable physical info and lifestyle settings 2025-12-19 17:46:54 +05:30
b2ad488043 feat: add React frontend with login, signup, dashboard pages and admin seeding support 2025-12-19 17:33:23 +05:30
2d3ad4f567 feat: add application logo in PNG and SVG formats 2025-12-19 16:21:22 +05:30
4890cfb541 feat: implement session-based authentication and core API handlers for user management 2025-12-19 16:21:12 +05:30
83 changed files with 9355 additions and 44 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,29 +1,39 @@
# zhealth Makefile # zhealth Makefile
# Run `make help` to see available commands # Run `make help` to see available commands
.PHONY: help dev 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 dev - Start development 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"
@echo " make seed - Seed database with initial data"
@echo " make lint - Run linters (Clippy + ESLint)" @echo " make lint - Run linters (Clippy + ESLint)"
@echo " make typecheck - Type checking (Rust + TypeScript)" @echo " make typecheck - Type checking (Rust + TypeScript)"
@echo " make test - Run all tests" @echo " make test - Run all tests"
@echo " make serve - Serve production build locally"
@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 .PHONY: backend-dev backend-build backend-release backend-lint backend-test migrate seed
migrate: migrate:
cd backend && ./target/debug/zhealth migrate cd backend && cargo run -- migrate
seed:
cd backend && cargo run -- seed
backend-dev: backend-dev:
cd backend && ./target/debug/zhealth serve cd backend && cargo run -- serve
backend-build: backend-build:
cd backend && cargo build cd backend && cargo build
@@ -37,26 +47,28 @@ backend-lint:
backend-test: backend-test:
cd backend && cargo test cd backend && cargo test
# Frontend commands (placeholder for when frontend is set up) # Frontend commands
.PHONY: frontend-dev frontend-build frontend-release frontend-lint frontend-test .PHONY: frontend-dev frontend-build frontend-release frontend-lint frontend-test frontend-install
frontend-install:
cd frontend && npm install
frontend-dev: frontend-dev:
@echo "Frontend not yet configured" cd frontend && npm run dev -- --host 0.0.0.0
frontend-build: frontend-build:
@echo "Frontend not yet configured" cd frontend && npm run build
frontend-release: frontend-release:
@echo "Frontend not yet configured" cd frontend && npm run build
frontend-lint: frontend-lint:
@echo "Frontend not yet configured" cd frontend && npm run lint
frontend-test: frontend-test:
@echo "Frontend not yet configured" @echo "Frontend tests not yet configured"
# Combined commands # Combined commands
dev: backend-dev
build: backend-build frontend-build build: backend-build frontend-build
@@ -69,8 +81,11 @@ typecheck: backend-lint frontend-lint
test: backend-test frontend-test test: backend-test frontend-test
serve: serve:
cd backend && cargo run --release @echo "Starting backend (port 3000) and frontend (port 5173)..."
@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
@echo "Cleaned backend artifacts" cd frontend && rm -rf node_modules dist
@echo "Cleaned build artifacts"

View File

@@ -0,0 +1,74 @@
# Design System Specification
## Core Principles
1. **Function over flash** — No decorative elements
2. **Typography-first hierarchy** — Size, weight, spacing do the work
3. **Semantic colors only** — Colors indicate meaning, not decoration
4. **Selective interactivity** — Hover effects only on interactive elements
---
## Color System
### Duo-Tone Base
| Token | Light Mode | Dark Mode | Usage |
|-------|------------|-----------|-------|
| `--bg-primary` | `#ffffff` | `#0f0f10` | Page background |
| `--bg-secondary` | `#f4f4f5` | `#18181b` | Cards, panels |
| `--text-primary` | `#09090b` | `#fafafa` | Headings, body |
| `--text-secondary` | `#71717a` | `#a1a1aa` | Labels, hints |
| `--border` | `#e4e4e7` | `#27272a` | Dividers, inputs |
| `--accent` | `#0d9488` | `#14b8a6` | Primary actions |
### Semantic Indicators
| Token | Value | Usage |
|-------|-------|-------|
| `--indicator-normal` | `#22c55e` | In range, healthy |
| `--indicator-warning` | `#eab308` | Borderline, attention |
| `--indicator-critical` | `#ef4444` | Out of range, urgent |
| `--indicator-info` | `#3b82f6` | Neutral info |
---
## Typography
- **Font**: System stack (`-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`)
- **Scale**: 12 / 14 / 16 / 18 / 24 / 32px
- **Weights**: 400 (regular), 500 (medium), 600 (semibold)
---
## Restrictions
- ❌ No gradients
- ❌ No shadows deeper than `0 1px 2px`
- ❌ No decorative animations
- ❌ No hover effects on non-interactive elements (table rows, cards)
- ✅ Transitions only for state changes (expand/collapse, loading)
---
## Component Guidelines
### Buttons
- Primary: filled with `--accent`
- Secondary: outlined
- Hover: darken 10%, no scale/glow
### Tables
- No row hover
- Alternating bg optional (use `--bg-secondary`)
- Color indicators in dedicated status column
### Cards
- Flat, bordered
- No shadow
- Consistent padding (16px)
### Forms
- Label above input
- Border on focus only
- Error states use `--indicator-critical`

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

28
assets/logo.svg Normal file
View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" xmlns:bx="https://boxy-svg.com">
<defs>
<linearGradient id="mainGradient" x1="56" y1="56" x2="456" y2="456" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.083017, 0, 0, 1.084585, -79.817192, -37.105466)">
<stop stop-color="#0EA5E9"/>
<stop offset="1" stop-color="#10B981"/>
</linearGradient>
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="10" stdDeviation="15" flood-color="#0f172a" flood-opacity="0.15"/>
</filter>
<filter id="innerShadow">
<feOffset dx="4" dy="6"/>
<feGaussianBlur stdDeviation="6" result="offset-blur"/>
<feComposite operator="out" in="SourceAlpha" in2="offset-blur" result="inverse"/>
<feFlood flood-color="black" flood-opacity="0.3" result="color"/>
<feComposite operator="in" in="color" in2="inverse" result="shadow"/>
<feComposite operator="over" in="shadow" in2="SourceGraphic"/>
</filter>
<bx:export>
<bx:file format="svg" href="#object-0"/>
<bx:file format="svg" href="#object-1" path="Untitled 2.svg"/>
<bx:file format="svg" href="#object-2" path="Untitled 3.svg"/>
</bx:export>
</defs>
<circle cx="244.973" cy="245.191" r="249.763" fill="white" filter="url(#softShadow)" style="stroke-width: 1px;" transform="matrix(1, 0, 0, 1.000724, 5.062476, 5.065786)" id="object-0"/>
<path fill="url(#mainGradient)" filter="url(#innerShadow)" fill-rule="evenodd" clip-rule="evenodd" d="M 145.967 125.463 L 354.104 125.463 C 359.828 125.463 364.511 130.149 364.511 135.877 L 364.511 167.12 C 364.511 170.244 362.949 173.369 360.347 175.451 L 204.245 323.335 L 354.104 323.335 C 359.828 323.335 364.511 328.021 364.511 333.749 L 364.511 364.992 C 364.511 370.72 359.828 375.407 354.104 375.407 L 145.967 375.407 C 140.244 375.407 135.561 370.72 135.561 364.992 L 135.561 333.749 C 135.561 330.624 137.122 327.501 139.724 325.417 L 295.826 177.534 L 145.967 177.534 C 140.244 177.534 135.561 172.848 135.561 167.12 L 135.561 135.877 C 135.561 130.149 140.244 125.463 145.967 125.463 Z" style="stroke-width: 1px;" id="object-1"/>
<path fill="url(#mainGradient)" filter="url(#innerShadow)" fill-rule="evenodd" clip-rule="evenodd" d="M 369.579 199.953 L 400.8 199.953 C 403.714 199.953 406.004 202.244 406.004 205.16 L 406.004 231.196 L 432.021 231.196 C 434.935 231.196 437.224 233.487 437.224 236.403 L 437.224 267.646 C 437.224 270.562 434.935 272.853 432.021 272.853 L 406.004 272.853 L 406.004 298.889 C 406.004 301.805 403.714 304.096 400.8 304.096 L 369.579 304.096 C 366.666 304.096 364.377 301.805 364.377 298.889 L 364.377 272.853 L 338.36 272.853 C 335.445 272.853 333.156 270.562 333.156 267.646 L 333.156 236.403 C 333.156 233.487 335.445 231.196 338.36 231.196 L 364.377 231.196 L 364.377 205.16 C 364.377 202.244 366.666 199.953 369.579 199.953 Z" style="stroke-width: 1px;" id="object-2"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

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"] }
@@ -22,11 +24,15 @@ serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9" serde_yaml = "0.9"
# Auth # Auth
axum-login = "0.17"
tower-sessions = "0.14"
argon2 = "0.5" argon2 = "0.5"
rand = "0.8" rand = "0.8"
async-trait = "0.1"
# Time # Time
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
time = "0.3"
# Logging # Logging
tracing = "0.1" tracing = "0.1"
@@ -41,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
@@ -19,7 +21,20 @@ auth:
cookie_name: "zhealth_session" cookie_name: "zhealth_session"
cookie_secure: false # Set to true in production with HTTPS cookie_secure: false # Set to true in production with HTTPS
# Default admin user (created on first startup)
admin:
username: "admin"
password: "${ADMIN_PASSWORD}" # Use env var in production
ai: 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: "%"

157
backend/src/auth.rs Normal file
View File

@@ -0,0 +1,157 @@
//! Authentication backend using axum-login.
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use async_trait::async_trait;
use axum_login::{AuthUser, AuthnBackend, UserId};
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use serde::{Deserialize, Serialize};
use crate::models::user::{role, user};
/// Authenticated user struct (what's stored in session).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthenticatedUser {
pub id: i32,
pub username: String,
pub role: String,
password_hash: String,
}
impl AuthUser for AuthenticatedUser {
type Id = i32;
fn id(&self) -> Self::Id {
self.id
}
fn session_auth_hash(&self) -> &[u8] {
self.password_hash.as_bytes()
}
}
/// Credentials for login.
#[derive(Clone, Debug, Deserialize)]
pub struct Credentials {
pub username: String,
pub password: String,
}
/// Authentication backend.
#[derive(Clone)]
pub struct AuthBackend {
db: DatabaseConnection,
}
impl AuthBackend {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
}
/// Error type for auth operations.
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Invalid credentials")]
InvalidCredentials,
#[error("Database error: {0}")]
Database(#[from] sea_orm::DbErr),
#[error("Session error")]
Session(#[source] Box<dyn std::error::Error + Send + Sync>),
}
#[async_trait]
impl AuthnBackend for AuthBackend {
type User = AuthenticatedUser;
type Credentials = Credentials;
type Error = AuthError;
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
// Find user by username
let user_entity = user::Entity::find()
.filter(user::Column::Username.eq(&creds.username))
.one(&self.db)
.await?;
let Some(user_entity) = user_entity else {
return Ok(None);
};
// Verify password
let parsed_hash = PasswordHash::new(&user_entity.password_hash)
.map_err(|_| AuthError::InvalidCredentials)?;
if Argon2::default()
.verify_password(creds.password.as_bytes(), &parsed_hash)
.is_err()
{
return Ok(None);
}
// Get role name
let role_name = role::Entity::find_by_id(user_entity.role_id)
.one(&self.db)
.await?
.map(|r| r.name)
.unwrap_or_else(|| "user".to_string());
Ok(Some(AuthenticatedUser {
id: user_entity.id,
username: user_entity.username,
role: role_name,
password_hash: user_entity.password_hash,
}))
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
let user_entity = user::Entity::find_by_id(*user_id)
.one(&self.db)
.await?;
let Some(user_entity) = user_entity else {
return Ok(None);
};
let role_name = role::Entity::find_by_id(user_entity.role_id)
.one(&self.db)
.await?
.map(|r| r.name)
.unwrap_or_else(|| "user".to_string());
Ok(Some(AuthenticatedUser {
id: user_entity.id,
username: user_entity.username,
role: role_name,
password_hash: user_entity.password_hash,
}))
}
}
// Type aliases for convenience
pub type AuthSession = axum_login::AuthSession<AuthBackend>;
/// Hash a password using Argon2.
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2.hash_password(password.as_bytes(), &salt)?;
Ok(hash.to_string())
}
/// Verify a password against a hash.
pub fn verify_password(password: &str, hash: &str) -> bool {
let parsed_hash = match PasswordHash::new(hash) {
Ok(h) => h,
Err(_) => return false,
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok()
}

View File

@@ -10,7 +10,9 @@ pub struct Config {
pub paths: PathsConfig, pub paths: PathsConfig,
pub logging: LoggingConfig, pub logging: LoggingConfig,
pub auth: AuthConfig, pub auth: AuthConfig,
pub admin: AdminConfig,
pub ai: AiConfig, pub ai: AiConfig,
pub mistral: MistralConfig,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -19,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)]
@@ -38,6 +42,12 @@ pub struct AuthConfig {
pub cookie_secure: bool, pub cookie_secure: bool,
} }
#[derive(Debug, Deserialize, Clone)]
pub struct AdminConfig {
pub username: String,
pub password: String,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct AiConfig { pub struct AiConfig {
pub provider: String, pub provider: String,
@@ -45,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

@@ -0,0 +1,92 @@
//! Authentication handlers (login/logout).
use axum::{http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use crate::auth::{AuthSession, Credentials};
/// Login request.
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
/// Login response.
#[derive(Serialize)]
pub struct LoginResponse {
pub message: String,
pub user: Option<UserInfo>,
}
#[derive(Serialize)]
pub struct UserInfo {
pub id: i32,
pub username: String,
pub role: String,
}
/// POST /api/auth/login - Login with username and password.
pub async fn login(
mut auth_session: AuthSession,
Json(req): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, StatusCode> {
let creds = Credentials {
username: req.username,
password: req.password,
};
let user = auth_session
.authenticate(creds)
.await
.map_err(|e| {
tracing::error!("Auth error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::UNAUTHORIZED)?;
auth_session.login(&user).await.map_err(|e| {
tracing::error!("Login error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(LoginResponse {
message: "Login successful".to_string(),
user: Some(UserInfo {
id: user.id,
username: user.username,
role: user.role,
}),
}))
}
/// POST /api/auth/logout - Logout current user.
pub async fn logout(mut auth_session: AuthSession) -> Result<Json<LoginResponse>, StatusCode> {
auth_session.logout().await.map_err(|e| {
tracing::error!("Logout error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(LoginResponse {
message: "Logged out".to_string(),
user: None,
}))
}
/// GET /api/auth/me - Get current authenticated user.
pub async fn me(auth_session: AuthSession) -> Result<Json<LoginResponse>, StatusCode> {
match auth_session.user {
Some(user) => Ok(Json(LoginResponse {
message: "Authenticated".to_string(),
user: Some(UserInfo {
id: user.id,
username: user.username,
role: user.role,
}),
})),
None => Ok(Json(LoginResponse {
message: "Not authenticated".to_string(),
user: None,
})),
}
}

View File

@@ -0,0 +1,123 @@
//! Biomarker API handlers.
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use sea_orm::{DatabaseConnection, EntityTrait, LoaderTrait};
use serde::Serialize;
use crate::models::bio::{biomarker, biomarker_reference_rule};
/// Response for a single biomarker with its reference rules.
#[derive(Serialize)]
pub struct BiomarkerResponse {
pub id: i32,
pub name: String,
pub test_category: String,
pub unit: String,
pub methodology: Option<String>,
pub description: Option<String>,
pub reference_rules: Vec<ReferenceRuleResponse>,
}
/// Response for a reference rule.
#[derive(Serialize)]
pub struct ReferenceRuleResponse {
pub id: i32,
pub rule_type: String,
pub sex: String,
pub age_min: Option<i32>,
pub age_max: Option<i32>,
pub time_of_day: Option<String>,
pub life_stage: Option<String>,
pub value_min: Option<f64>,
pub value_max: Option<f64>,
pub expected_value: Option<String>,
pub label: String,
pub severity: i32,
}
/// List item for biomarkers (without full reference rules).
#[derive(Serialize)]
pub struct BiomarkerListItem {
pub id: i32,
pub category_id: i32,
pub name: String,
pub test_category: String,
pub unit: String,
pub methodology: Option<String>,
}
/// GET /api/biomarkers - List all biomarkers.
pub async fn list_biomarkers(
State(db): State<DatabaseConnection>,
) -> Result<Json<Vec<BiomarkerListItem>>, StatusCode> {
let biomarkers = biomarker::Entity::find()
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let items: Vec<BiomarkerListItem> = biomarkers
.into_iter()
.map(|b| BiomarkerListItem {
id: b.id,
category_id: b.category_id,
name: b.name,
test_category: b.test_category,
unit: b.unit,
methodology: b.methodology,
})
.collect();
Ok(Json(items))
}
/// GET /api/biomarkers/:id - Get a biomarker with its reference rules.
pub async fn get_biomarker(
State(db): State<DatabaseConnection>,
Path(id): Path<i32>,
) -> Result<Json<BiomarkerResponse>, StatusCode> {
let bm = biomarker::Entity::find_by_id(id)
.one(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
let rules = vec![bm.clone()]
.load_many(biomarker_reference_rule::Entity, &db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.into_iter()
.next()
.unwrap_or_default();
let response = BiomarkerResponse {
id: bm.id,
name: bm.name,
test_category: bm.test_category,
unit: bm.unit,
methodology: bm.methodology,
description: bm.description,
reference_rules: rules
.into_iter()
.map(|r| ReferenceRuleResponse {
id: r.id,
rule_type: r.rule_type,
sex: r.sex,
age_min: r.age_min,
age_max: r.age_max,
time_of_day: r.time_of_day,
life_stage: r.life_stage,
value_min: r.value_min,
value_max: r.value_max,
expected_value: r.expected_value,
label: r.label,
severity: r.severity,
})
.collect(),
};
Ok(Json(response))
}

View File

@@ -0,0 +1,36 @@
//! Biomarker category API handlers.
use axum::{extract::State, http::StatusCode, Json};
use sea_orm::{DatabaseConnection, EntityTrait};
use serde::Serialize;
use crate::models::bio::biomarker_category;
/// Response for a biomarker category.
#[derive(Serialize)]
pub struct CategoryResponse {
pub id: i32,
pub name: String,
pub description: Option<String>,
}
/// GET /api/categories - List all biomarker categories.
pub async fn list_categories(
State(db): State<DatabaseConnection>,
) -> Result<Json<Vec<CategoryResponse>>, StatusCode> {
let categories = biomarker_category::Entity::find()
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let items: Vec<CategoryResponse> = categories
.into_iter()
.map(|c| CategoryResponse {
id: c.id,
name: c.name,
description: c.description,
})
.collect();
Ok(Json(items))
}

View File

@@ -0,0 +1,36 @@
//! Diet API handlers.
use axum::{extract::State, http::StatusCode, Json};
use sea_orm::{DatabaseConnection, EntityTrait};
use serde::Serialize;
use crate::models::user::diet;
/// Response for a diet type.
#[derive(Serialize)]
pub struct DietResponse {
pub id: i32,
pub name: String,
pub description: Option<String>,
}
/// GET /api/diets - List all diet types for UI dropdown.
pub async fn list_diets(
State(db): State<DatabaseConnection>,
) -> Result<Json<Vec<DietResponse>>, StatusCode> {
let diets = diet::Entity::find()
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let items: Vec<DietResponse> = diets
.into_iter()
.map(|d| DietResponse {
id: d.id,
name: d.name,
description: d.description,
})
.collect();
Ok(Json(items))
}

View File

@@ -0,0 +1,283 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::models::bio::{biomarker, biomarker_entry, biomarker_reference_rule};
use crate::models::user::user;
/// Request to create a new biomarker entry.
#[derive(Deserialize)]
pub struct CreateEntryRequest {
pub biomarker_id: i32,
pub user_id: i32,
pub value: f64,
pub measured_at: Option<String>, // ISO 8601 datetime, defaults to now
pub notes: Option<String>,
}
/// Response for a biomarker entry.
#[derive(Serialize)]
pub struct EntryResponse {
pub biomarker_id: i32,
pub biomarker_name: String,
pub user_id: i32,
pub value: f64,
pub measured_at: 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.
pub async fn create_entry(
State(db): State<DatabaseConnection>,
Json(req): Json<CreateEntryRequest>,
) -> Result<Json<EntryResponse>, StatusCode> {
// Verify biomarker exists
let bm = biomarker::Entity::find_by_id(req.biomarker_id)
.one(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::BAD_REQUEST)?;
// Parse measured_at or use now
let measured_at = if let Some(dt_str) = &req.measured_at {
chrono::DateTime::parse_from_rfc3339(dt_str)
.map_err(|_| StatusCode::BAD_REQUEST)?
.with_timezone(&Utc)
.naive_utc()
} else {
Utc::now().naive_utc()
};
let now = Utc::now().naive_utc();
let new_entry = biomarker_entry::ActiveModel {
biomarker_id: Set(req.biomarker_id),
user_id: Set(req.user_id),
value: Set(req.value),
measured_at: Set(measured_at),
notes: Set(req.notes.clone()),
source_id: Set(None),
created_at: Set(now),
};
// Use exec for composite primary key table
biomarker_entry::Entity::insert(new_entry)
.exec(&db)
.await
.map_err(|e| {
tracing::error!("Failed to insert entry: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(EntryResponse {
biomarker_id: req.biomarker_id,
biomarker_name: bm.name,
user_id: req.user_id,
value: req.value,
measured_at: measured_at.to_string(),
notes: req.notes,
}))
}
/// GET /api/users/:user_id/entries - List all entries for a user.
pub async fn list_user_entries(
State(db): State<DatabaseConnection>,
Path(user_id): Path<i32>,
) -> Result<Json<Vec<EntryResponse>>, StatusCode> {
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)?;
// Fetch biomarker names
let biomarker_ids: Vec<i32> = entries.iter().map(|e| e.biomarker_id).collect();
let biomarkers = biomarker::Entity::find()
.filter(biomarker::Column::Id.is_in(biomarker_ids))
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let bm_map: HashMap<i32, String> = biomarkers
.into_iter()
.map(|b| (b.id, b.name))
.collect();
let items: Vec<EntryResponse> = entries
.into_iter()
.map(|e| EntryResponse {
biomarker_id: e.biomarker_id,
biomarker_name: bm_map.get(&e.biomarker_id).cloned().unwrap_or_default(),
user_id: e.user_id,
value: e.value,
measured_at: e.measured_at.to_string(),
notes: e.notes,
})
.collect();
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

@@ -0,0 +1,10 @@
//! API Handlers module.
pub mod auth;
pub mod biomarkers;
pub mod categories;
pub mod diets;
pub mod entries;
pub mod ocr;
pub mod sources;
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

@@ -0,0 +1,417 @@
//! User API handlers.
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize};
use crate::models::user::{diet, role, user};
/// Request to create a new user.
#[derive(Deserialize)]
pub struct CreateUserRequest {
pub username: String,
pub password: String,
pub role_name: Option<String>, // defaults to "user"
pub height_cm: Option<f32>,
pub blood_type: Option<String>,
pub birthdate: Option<String>, // YYYY-MM-DD
pub smoking: Option<bool>,
pub alcohol: Option<bool>,
pub diet_id: Option<i32>,
}
/// Request to update a user.
#[derive(Deserialize)]
pub struct UpdateUserRequest {
pub name: Option<String>,
pub height_cm: Option<f32>,
pub blood_type: Option<String>,
pub birthdate: Option<String>,
pub smoking: Option<bool>,
pub alcohol: Option<bool>,
pub diet_id: Option<i32>,
pub avatar_url: Option<String>,
pub mistral_api_key: Option<String>,
}
/// Response for a user.
#[derive(Serialize)]
pub struct UserResponse {
pub id: i32,
pub username: String,
pub name: Option<String>,
pub role: String,
pub height_cm: Option<f32>,
pub blood_type: Option<String>,
pub birthdate: Option<String>,
pub smoking: Option<bool>,
pub alcohol: Option<bool>,
pub diet: Option<String>,
pub avatar_url: Option<String>,
pub has_mistral_key: bool,
pub created_at: String,
}
/// GET /api/users - List all users.
pub async fn list_users(
State(db): State<DatabaseConnection>,
) -> Result<Json<Vec<UserResponse>>, StatusCode> {
let users = user::Entity::find()
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let roles = role::Entity::find()
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let diets = diet::Entity::find()
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let role_map: std::collections::HashMap<i32, String> = roles
.into_iter()
.map(|r| (r.id, r.name))
.collect();
let diet_map: std::collections::HashMap<i32, String> = diets
.into_iter()
.map(|d| (d.id, d.name))
.collect();
let items: Vec<UserResponse> = users
.into_iter()
.map(|u| UserResponse {
id: u.id,
username: u.username.clone(),
name: u.name.clone(),
role: role_map.get(&u.role_id).cloned().unwrap_or_default(),
height_cm: u.height_cm,
blood_type: u.blood_type.clone(),
birthdate: u.birthdate.map(|d| d.to_string()),
smoking: u.smoking,
alcohol: u.alcohol,
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(),
})
.collect();
Ok(Json(items))
}
/// GET /api/users/:id - Get a user by ID.
pub async fn get_user(
State(db): State<DatabaseConnection>,
Path(id): Path<i32>,
) -> Result<Json<UserResponse>, StatusCode> {
let u = user::Entity::find_by_id(id)
.one(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
let role_name = role::Entity::find_by_id(u.role_id)
.one(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map(|r| r.name)
.unwrap_or_default();
let diet_name = if let Some(diet_id) = u.diet_id {
diet::Entity::find_by_id(diet_id)
.one(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map(|d| d.name)
} else {
None
};
Ok(Json(UserResponse {
id: u.id,
username: u.username,
name: u.name,
role: role_name,
height_cm: u.height_cm,
blood_type: u.blood_type,
birthdate: u.birthdate.map(|d| d.to_string()),
smoking: u.smoking,
alcohol: u.alcohol,
diet: diet_name,
avatar_url: u.avatar_url,
has_mistral_key: u.mistral_api_key.is_some(),
created_at: u.created_at.to_string(),
}))
}
/// POST /api/users - Create a new user.
pub async fn create_user(
State(db): State<DatabaseConnection>,
Json(req): Json<CreateUserRequest>,
) -> Result<Json<UserResponse>, StatusCode> {
// Look up role by name (default to "user")
let role_name = req.role_name.unwrap_or_else(|| "user".to_string());
let role_entity = role::Entity::find()
.filter(role::Column::Name.eq(&role_name))
.one(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::BAD_REQUEST)?;
// Hash password with Argon2
let password_hash = crate::auth::hash_password(&req.password)
.map_err(|e| {
tracing::error!("Password hashing failed: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Parse birthdate
let birthdate = if let Some(bd) = &req.birthdate {
Some(
chrono::NaiveDate::parse_from_str(bd, "%Y-%m-%d")
.map_err(|_| StatusCode::BAD_REQUEST)?
)
} else {
None
};
let now = Utc::now().naive_utc();
let new_user = user::ActiveModel {
username: Set(req.username.clone()),
password_hash: Set(password_hash),
role_id: Set(role_entity.id),
height_cm: Set(req.height_cm),
blood_type: Set(req.blood_type.clone()),
birthdate: Set(birthdate),
smoking: Set(req.smoking),
alcohol: Set(req.alcohol),
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),
updated_at: Set(now),
..Default::default()
};
let inserted = new_user
.insert(&db)
.await
.map_err(|e| {
tracing::error!("Failed to create user: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let diet_name = if let Some(diet_id) = req.diet_id {
diet::Entity::find_by_id(diet_id)
.one(&db)
.await
.ok()
.flatten()
.map(|d| d.name)
} else {
None
};
Ok(Json(UserResponse {
id: inserted.id,
username: inserted.username,
name: inserted.name,
role: role_name,
height_cm: inserted.height_cm,
blood_type: inserted.blood_type,
birthdate: inserted.birthdate.map(|d| d.to_string()),
smoking: inserted.smoking,
alcohol: inserted.alcohol,
diet: diet_name,
avatar_url: inserted.avatar_url,
has_mistral_key: inserted.mistral_api_key.is_some(),
created_at: inserted.created_at.to_string(),
}))
}
/// PUT /api/users/:id - Update a user.
pub async fn update_user(
State(db): State<DatabaseConnection>,
Path(id): Path<i32>,
Json(req): Json<UpdateUserRequest>,
) -> Result<Json<UserResponse>, StatusCode> {
let existing = user::Entity::find_by_id(id)
.one(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
let birthdate = if let Some(bd) = &req.birthdate {
Some(
chrono::NaiveDate::parse_from_str(bd, "%Y-%m-%d")
.map_err(|_| StatusCode::BAD_REQUEST)?
)
} else {
existing.birthdate
};
let now = Utc::now().naive_utc();
let mut active: user::ActiveModel = existing.into();
if req.name.is_some() {
active.name = Set(req.name);
}
if req.height_cm.is_some() {
active.height_cm = Set(req.height_cm);
}
if req.blood_type.is_some() {
active.blood_type = Set(req.blood_type);
}
active.birthdate = Set(birthdate);
if req.smoking.is_some() {
active.smoking = Set(req.smoking);
}
if req.alcohol.is_some() {
active.alcohol = Set(req.alcohol);
}
if req.diet_id.is_some() {
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);
let updated = active
.update(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let role_name = role::Entity::find_by_id(updated.role_id)
.one(&db)
.await
.ok()
.flatten()
.map(|r| r.name)
.unwrap_or_default();
let diet_name = if let Some(diet_id) = updated.diet_id {
diet::Entity::find_by_id(diet_id)
.one(&db)
.await
.ok()
.flatten()
.map(|d| d.name)
} else {
None
};
Ok(Json(UserResponse {
id: updated.id,
username: updated.username,
name: updated.name,
role: role_name,
height_cm: updated.height_cm,
blood_type: updated.blood_type,
birthdate: updated.birthdate.map(|d| d.to_string()),
smoking: updated.smoking,
alcohol: updated.alcohol,
diet: diet_name,
avatar_url: updated.avatar_url,
has_mistral_key: updated.mistral_api_key.is_some(),
created_at: updated.created_at.to_string(),
}))
}
/// DELETE /api/users/:id - Delete a user.
pub async fn delete_user(
State(db): State<DatabaseConnection>,
Path(id): Path<i32>,
) -> Result<StatusCode, StatusCode> {
let result = user::Entity::delete_by_id(id)
.exec(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if result.rows_affected == 0 {
return Err(StatusCode::NOT_FOUND);
}
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.
pub async fn list_roles(
State(db): State<DatabaseConnection>,
) -> Result<Json<Vec<RoleResponse>>, StatusCode> {
let roles = role::Entity::find()
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let items: Vec<RoleResponse> = roles
.into_iter()
.map(|r| RoleResponse {
id: r.id,
name: r.name,
description: r.description,
})
.collect();
Ok(Json(items))
}
#[derive(Serialize)]
pub struct RoleResponse {
pub id: i32,
pub name: String,
pub description: Option<String>,
}

View File

@@ -1,13 +1,27 @@
mod auth;
mod cli; mod cli;
mod config; mod config;
mod db; mod db;
mod handlers;
mod models; mod models;
mod seed; mod seed;
use axum::{routing::get, Router}; use axum::{
middleware,
routing::{get, post, put, delete},
Router,
};
use axum_login::{
tower_sessions::{Expiry, MemoryStore, SessionManagerLayer},
AuthManagerLayerBuilder,
};
use sea_orm::DatabaseConnection;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::PathBuf;
use time::Duration;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use auth::AuthBackend;
use cli::{Args, Command}; use cli::{Args, Command};
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -64,13 +78,14 @@ async fn main() -> anyhow::Result<()> {
let biomarker_data = seed::BiomarkerSeedData::load("seed_biomarkers.yaml")?; let biomarker_data = seed::BiomarkerSeedData::load("seed_biomarkers.yaml")?;
seed::sync_biomarker_data(&db, &biomarker_data).await?; seed::sync_biomarker_data(&db, &biomarker_data).await?;
// Create admin user from config
seed::sync_admin_user(&db, &config.admin).await?;
tracing::info!("Seed data synced."); tracing::info!("Seed data synced.");
// Start server // Start server
tracing::info!("Starting zhealth..."); tracing::info!("Starting zhealth API server...");
let app = Router::new() let app = create_router(db, &config);
.route("/", get(root))
.route("/health", get(health_check));
let addr: SocketAddr = format!("{}:{}", config.server.host, config.server.port) let addr: SocketAddr = format!("{}:{}", config.server.host, config.server.port)
.parse() .parse()
@@ -86,11 +101,116 @@ async fn main() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
/// Create the API router with all routes.
fn create_router(db: DatabaseConnection, config: &config::Config) -> Router {
// Session layer (in-memory for now, can be switched to SQLite later)
let session_store = MemoryStore::default();
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(false) // Set to true in production with HTTPS
.with_expiry(Expiry::OnInactivity(Duration::hours(
config.auth.session_expiry_hours as i64,
)));
// Auth backend
let auth_backend = AuthBackend::new(db.clone());
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build();
// Public routes (no auth required)
let public_routes = Router::new()
.route("/", get(root))
.route("/health", get(health_check))
// Auth endpoints
.route("/api/auth/login", post(handlers::auth::login))
.route("/api/auth/logout", post(handlers::auth::logout))
.route("/api/auth/me", get(handlers::auth::me))
// Public read endpoints
.route("/api/biomarkers", get(handlers::biomarkers::list_biomarkers))
.route("/api/biomarkers/{id}", get(handlers::biomarkers::get_biomarker))
.route("/api/categories", get(handlers::categories::list_categories))
.route("/api/diets", get(handlers::diets::list_diets))
.route("/api/roles", get(handlers::users::list_roles))
// User registration (public)
.route("/api/users", post(handlers::users::create_user));
// Protected routes (require auth)
let protected_routes = Router::new()
// User management
.route("/api/users", get(handlers::users::list_users))
.route("/api/users/{id}", get(handlers::users::get_user)
.put(handlers::users::update_user)
.delete(handlers::users::delete_user))
.route("/api/users/{id}/reset-password", post(handlers::users::reset_password))
// Entries API
.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}/results", get(handlers::entries::get_user_results))
.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()
.merge(public_routes)
.merge(protected_routes)
.merge(sources_routes)
.merge(ocr_routes)
.layer(auth_layer)
.with_state(db)
}
/// Middleware to require authentication.
async fn require_auth(
auth_session: auth::AuthSession,
request: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
if auth_session.user.is_some() {
next.run(request).await
} else {
axum::response::Response::builder()
.status(axum::http::StatusCode::UNAUTHORIZED)
.header("Content-Type", "application/json")
.body(axum::body::Body::from(r#"{"error": "Authentication required"}"#))
.unwrap()
}
}
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

@@ -15,12 +15,12 @@ pub struct Model {
#[sea_orm(column_type = "Text")] #[sea_orm(column_type = "Text")]
pub password_hash: String, pub password_hash: String,
#[sea_orm(unique)]
pub email: String,
/// 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>,
@@ -41,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,
} }

View File

@@ -1,13 +1,15 @@
//! Seed data loading and syncing. //! Seed data loading and syncing.
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use serde::Deserialize; use serde::Deserialize;
use serde_yaml::Value; use serde_yaml::Value;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use crate::config::AdminConfig;
use crate::models::bio::{biomarker, biomarker_category, biomarker_reference_rule}; use crate::models::bio::{biomarker, biomarker_category, biomarker_reference_rule};
use crate::models::user::{diet, role}; use crate::models::user::{diet, role, user};
// ============================================================================ // ============================================================================
// Seed Data Structures // Seed Data Structures
@@ -475,3 +477,44 @@ pub async fn sync_biomarker_data(db: &DatabaseConnection, seed: &BiomarkerSeedDa
Ok(()) Ok(())
} }
/// Sync admin user from config (create if not exists).
pub async fn sync_admin_user(db: &DatabaseConnection, admin_config: &AdminConfig) -> anyhow::Result<()> {
// Check if admin already exists
let existing = user::Entity::find()
.filter(user::Column::Username.eq(&admin_config.username))
.one(db)
.await?;
if existing.is_some() {
tracing::debug!("Admin user already exists");
return Ok(());
}
// Get admin role
let admin_role = role::Entity::find()
.filter(role::Column::Name.eq("admin"))
.one(db)
.await?
.ok_or_else(|| anyhow::anyhow!("Admin role not found - run seed first"))?;
// Hash password
let password_hash = crate::auth::hash_password(&admin_config.password)
.map_err(|e| anyhow::anyhow!("Failed to hash admin password: {:?}", e))?;
let now = Utc::now().naive_utc();
let new_admin = user::ActiveModel {
username: Set(admin_config.username.clone()),
password_hash: Set(password_hash),
role_id: Set(admin_role.id),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
new_admin.insert(db).await?;
tracing::info!("Created admin user: {}", admin_config.username);
Ok(())
}

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>zhealth</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3283
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
frontend/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.11.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

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

28
frontend/public/logo.svg Normal file
View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" xmlns:bx="https://boxy-svg.com">
<defs>
<linearGradient id="mainGradient" x1="56" y1="56" x2="456" y2="456" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.083017, 0, 0, 1.084585, -79.817192, -37.105466)">
<stop stop-color="#0EA5E9"/>
<stop offset="1" stop-color="#10B981"/>
</linearGradient>
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="10" stdDeviation="15" flood-color="#0f172a" flood-opacity="0.15"/>
</filter>
<filter id="innerShadow">
<feOffset dx="4" dy="6"/>
<feGaussianBlur stdDeviation="6" result="offset-blur"/>
<feComposite operator="out" in="SourceAlpha" in2="offset-blur" result="inverse"/>
<feFlood flood-color="black" flood-opacity="0.3" result="color"/>
<feComposite operator="in" in="color" in2="inverse" result="shadow"/>
<feComposite operator="over" in="shadow" in2="SourceGraphic"/>
</filter>
<bx:export>
<bx:file format="svg" href="#object-0"/>
<bx:file format="svg" href="#object-1" path="Untitled 2.svg"/>
<bx:file format="svg" href="#object-2" path="Untitled 3.svg"/>
</bx:export>
</defs>
<circle cx="244.973" cy="245.191" r="249.763" fill="white" filter="url(#softShadow)" style="stroke-width: 1px;" transform="matrix(1, 0, 0, 1.000724, 5.062476, 5.065786)" id="object-0"/>
<path fill="url(#mainGradient)" filter="url(#innerShadow)" fill-rule="evenodd" clip-rule="evenodd" d="M 145.967 125.463 L 354.104 125.463 C 359.828 125.463 364.511 130.149 364.511 135.877 L 364.511 167.12 C 364.511 170.244 362.949 173.369 360.347 175.451 L 204.245 323.335 L 354.104 323.335 C 359.828 323.335 364.511 328.021 364.511 333.749 L 364.511 364.992 C 364.511 370.72 359.828 375.407 354.104 375.407 L 145.967 375.407 C 140.244 375.407 135.561 370.72 135.561 364.992 L 135.561 333.749 C 135.561 330.624 137.122 327.501 139.724 325.417 L 295.826 177.534 L 145.967 177.534 C 140.244 177.534 135.561 172.848 135.561 167.12 L 135.561 135.877 C 135.561 130.149 140.244 125.463 145.967 125.463 Z" style="stroke-width: 1px;" id="object-1"/>
<path fill="url(#mainGradient)" filter="url(#innerShadow)" fill-rule="evenodd" clip-rule="evenodd" d="M 369.579 199.953 L 400.8 199.953 C 403.714 199.953 406.004 202.244 406.004 205.16 L 406.004 231.196 L 432.021 231.196 C 434.935 231.196 437.224 233.487 437.224 236.403 L 437.224 267.646 C 437.224 270.562 434.935 272.853 432.021 272.853 L 406.004 272.853 L 406.004 298.889 C 406.004 301.805 403.714 304.096 400.8 304.096 L 369.579 304.096 C 366.666 304.096 364.377 301.805 364.377 298.889 L 364.377 272.853 L 338.36 272.853 C 335.445 272.853 333.156 270.562 333.156 267.646 L 333.156 236.403 C 333.156 233.487 335.445 231.196 338.36 231.196 L 364.377 231.196 L 364.377 205.16 C 364.377 202.244 366.666 199.953 369.579 199.953 Z" style="stroke-width: 1px;" id="object-2"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

33
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { LoginPage } from './pages/Login'
import { SignupPage } from './pages/Signup'
import { DashboardPage } from './pages/Dashboard'
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() {
return (
<BrowserRouter>
<Routes>
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
{/* 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 />} />
</Routes>
</BrowserRouter>
)
}
export default App

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

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>
)
}

1124
frontend/src/index.css Normal file

File diff suppressed because it is too large Load Diff

22
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
// Initialize theme from localStorage or system preference
const initTheme = () => {
const stored = localStorage.getItem('theme')
if (stored) {
document.documentElement.setAttribute('data-theme', stored)
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-theme', 'dark')
}
}
initTheme()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

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

@@ -0,0 +1,202 @@
import { useEffect, useState } from 'react'
interface Category {
id: number
name: string
description: string | null
}
interface BiomarkerResult {
biomarker_id: number
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)
useEffect(() => {
const fetchData = async () => {
try {
// Get current user
const authRes = await fetch('/api/auth/me', { credentials: 'include' })
if (!authRes.ok) return
const authData = await authRes.json()
const user = authData.user
if (!user) return // Not authenticated
// Fetch categories and results in parallel
const [catsRes, resultsRes] = await Promise.all([
fetch('/api/categories', { 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
})
}
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) {
return <div className="page-loading">Loading biomarkers...</div>
}
return (
<div className="page">
<header className="page-header">
<h1>Dashboard</h1>
<p className="text-secondary">Your latest biomarker results</p>
</header>
<section>
<h2 className="mb-md">Biomarker Categories</h2>
<div className="flex-col gap-sm">
{categories.map(category => {
const categoryResults = getResultsForCategory(category.id)
const isExpanded = expandedCategories.has(category.id)
// Count how many have data
const withData = categoryResults.filter(r => r.value !== null).length
return (
<div key={category.id} className="card category-card">
<button
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>
)
}

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

@@ -0,0 +1,100 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
export function LoginPage() {
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
if (res.status === 401) {
setError('Invalid username or password')
} else {
setError('Login failed. Please try again.')
}
return
}
// Redirect to dashboard on success
navigate('/')
} catch {
setError('Network error. Is the server running?')
} finally {
setLoading(false)
}
}
return (
<div className="login-container">
<div className="login-card">
<div className="login-header">
<img src="/logo.svg" alt="zhealth" className="login-logo" />
<h1>zhealth</h1>
</div>
<p className="text-secondary text-sm">Sign in to continue</p>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
className="input"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
className="input"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
{error && (
<div className="error-message">
{error}
</div>
)}
<button
type="submit"
className="btn btn-primary btn-block"
disabled={loading}
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
<p className="text-secondary text-sm mt-md text-center">
Don't have an account? <Link to="/signup">Sign up</Link>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,318 @@
import { useEffect, useState } from 'react'
interface Diet {
id: number
name: string
description: string
}
interface UserProfile {
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
avatar_url: string | null
has_mistral_key: boolean
}
export function ProfilePage() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [diets, setDiets] = useState<Diet[]>([])
// Form state
const [userId, setUserId] = useState<number | null>(null)
const [username, setUsername] = useState('')
const [name, setName] = useState('')
const [heightCm, setHeightCm] = useState('')
const [bloodType, setBloodType] = useState('')
const [birthdate, setBirthdate] = useState('')
const [smoking, setSmoking] = useState<boolean | null>(null)
const [alcohol, setAlcohol] = useState<boolean | 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(() => {
// Fetch current user and diets (Layout already ensures auth)
Promise.all([
fetch('/api/auth/me', { credentials: 'include' }).then(r => r.json()),
fetch('/api/diets', { credentials: 'include' }).then(r => r.json()),
])
.then(([authData, dietsData]) => {
if (!authData.user) return
setDiets(dietsData)
// Fetch full user profile
return fetch(`/api/users/${authData.user.id}`, { credentials: 'include' })
.then(r => r.json())
.then((profile: UserProfile) => {
setUserId(profile.id)
setUsername(profile.username)
setName(profile.name || '')
setHeightCm(profile.height_cm?.toString() || '')
setBloodType(profile.blood_type || '')
setBirthdate(profile.birthdate || '')
setSmoking(profile.smoking)
setAlcohol(profile.alcohol)
// Find diet ID from name
const diet = dietsData.find((d: Diet) => d.name === profile.diet)
setDietId(diet?.id || null)
setAvatarUrl(profile.avatar_url)
setHasMistralKey(profile.has_mistral_key)
})
})
.finally(() => {
setLoading(false)
// 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) => {
e.preventDefault()
if (!userId) return
setSaving(true)
setMessage(null)
try {
const res = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: name || null,
height_cm: heightCm ? parseFloat(heightCm) : null,
blood_type: bloodType || null,
birthdate: birthdate || null,
smoking,
alcohol,
diet_id: dietId,
avatar_url: avatarUrl,
mistral_api_key: mistralApiKey || null,
}),
})
if (res.ok) {
localStorage.setItem('profileSaved', 'true')
window.location.reload()
} else {
setMessage({ type: 'error', text: 'Failed to update profile' })
}
} catch {
setMessage({ type: 'error', text: 'Network error' })
} finally {
setSaving(false)
}
}
if (loading) {
return <div className="page-loading">Loading...</div>
}
return (
<div className="page max-w-md">
<header className="page-header">
<h1>Profile</h1>
<p className="text-secondary">Manage your account and health information</p>
</header>
<form onSubmit={handleSubmit}>
{/* Account Info */}
<div className="card mb-lg">
<h3 className="mb-md">Account</h3>
<div className="form-group">
<label>Username</label>
<input type="text" className="input" value={username} disabled />
</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>
{/* Physical Info */}
<div className="card mb-lg">
<h3 className="mb-md">Physical Info</h3>
<div className="form-group">
<label htmlFor="height">Height (cm)</label>
<input
id="height"
type="number"
className="input"
value={heightCm}
onChange={(e) => setHeightCm(e.target.value)}
placeholder="e.g. 175"
step="0.1"
/>
</div>
<div className="form-group">
<label htmlFor="bloodType">Blood Type</label>
<select
id="bloodType"
className="input"
value={bloodType}
onChange={(e) => setBloodType(e.target.value)}
>
<option value="">Select...</option>
<option value="A+">A+</option>
<option value="A-">A-</option>
<option value="B+">B+</option>
<option value="B-">B-</option>
<option value="AB+">AB+</option>
<option value="AB-">AB-</option>
<option value="O+">O+</option>
<option value="O-">O-</option>
</select>
</div>
<div className="form-group">
<label htmlFor="birthdate">Date of Birth</label>
<input
id="birthdate"
type="date"
className="input"
value={birthdate}
onChange={(e) => setBirthdate(e.target.value)}
/>
</div>
</div>
{/* Lifestyle */}
<div className="card mb-lg">
<h3 className="mb-md">Lifestyle</h3>
<div className="form-group">
<label htmlFor="diet">Diet</label>
<select
id="diet"
className="input"
value={dietId || ''}
onChange={(e) => setDietId(e.target.value ? parseInt(e.target.value) : null)}
>
<option value="">Select...</option>
{diets.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
<div className="form-group">
<label>Smoking</label>
<div className="radio-group">
<label className="radio-label">
<input
type="radio"
name="smoking"
checked={smoking === false}
onChange={() => setSmoking(false)}
/> No
</label>
<label className="radio-label">
<input
type="radio"
name="smoking"
checked={smoking === true}
onChange={() => setSmoking(true)}
/> Yes
</label>
</div>
</div>
<div className="form-group">
<label>Alcohol</label>
<div className="radio-group">
<label className="radio-label">
<input
type="radio"
name="alcohol"
checked={alcohol === false}
onChange={() => setAlcohol(false)}
/> No
</label>
<label className="radio-label">
<input
type="radio"
name="alcohol"
checked={alcohol === true}
onChange={() => setAlcohol(true)}
/> Yes
</label>
</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 && (
<div className={message.type === 'success' ? 'success-message' : 'error-message'}>
{message.text}
</div>
)}
<button type="submit" className="btn btn-primary btn-block" disabled={saving}>
{saving ? 'Saving...' : 'Save Profile'}
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,107 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
export function SignupPage() {
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
const text = await res.text()
setError(text || 'Registration failed')
return
}
// Auto-login after signup
const loginRes = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
})
if (loginRes.ok) {
navigate('/')
} else {
navigate('/login')
}
} catch {
setError('Network error')
} finally {
setLoading(false)
}
}
return (
<div className="login-container">
<div className="login-card">
<div className="login-header">
<img src="/logo.svg" alt="zhealth" className="login-logo" />
<h1>zhealth</h1>
</div>
<p className="text-secondary text-sm">Create an account</p>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
className="input"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
className="input"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
{error && (
<div className="error-message">
{error}
</div>
)}
<button
type="submit"
className="btn btn-primary btn-block"
disabled={loading}
>
{loading ? 'Creating account...' : 'Sign up'}
</button>
</form>
<p className="text-secondary text-sm mt-md text-center">
Already have an account? <Link to="/login">Sign in</Link>
</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>
)
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

15
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
},
},
},
})