diff --git a/.gitignore b/.gitignore index bbf0752..ccb7059 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ config.yaml backend/data/zhealth.db backend/data/zhealth.db-wal backend/data/zhealth.db-shm +backend/data/uploads/ # FRONTEND diff --git a/backend/Cargo.toml b/backend/Cargo.toml index cc5fb6c..6f2ea85 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -10,7 +10,9 @@ path = "src/main.rs" [dependencies] # Web Framework axum = "0.8" +axum-extra = { version = "0.10", features = ["multipart"] } tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["io"] } tower = "0.5" tower-http = { version = "0.6", features = ["cors", "trace"] } diff --git a/backend/sample.config.yaml b/backend/sample.config.yaml index fe8deb1..22344bc 100644 --- a/backend/sample.config.yaml +++ b/backend/sample.config.yaml @@ -9,6 +9,7 @@ server: paths: database: "./data/zhealth.db" logs: "./logs" + uploads: "./data/uploads" logging: level: "info" # Options: trace | debug | info | warn | error diff --git a/backend/src/config.rs b/backend/src/config.rs index 0d030cf..dc1b126 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -24,6 +24,7 @@ pub struct ServerConfig { pub struct PathsConfig { pub database: String, pub logs: String, + pub uploads: String, } #[derive(Debug, Deserialize)] diff --git a/backend/src/db.rs b/backend/src/db.rs index 9167eb2..c0e07ae 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -4,7 +4,7 @@ use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbBackend, DbErr, S use sea_orm::sea_query::SqliteQueryBuilder; 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}; /// 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::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), ]; diff --git a/backend/src/handlers/entries.rs b/backend/src/handlers/entries.rs index b1a3ce4..e5a76c9 100644 --- a/backend/src/handlers/entries.rs +++ b/backend/src/handlers/entries.rs @@ -60,6 +60,7 @@ pub async fn create_entry( value: Set(req.value), measured_at: Set(measured_at), notes: Set(req.notes.clone()), + source_id: Set(None), created_at: Set(now), }; diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 4774f5c..01640a1 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -5,4 +5,5 @@ pub mod biomarkers; pub mod categories; pub mod diets; pub mod entries; +pub mod sources; pub mod users; diff --git a/backend/src/handlers/sources.rs b/backend/src/handlers/sources.rs new file mode 100644 index 0000000..92292bf --- /dev/null +++ b/backend/src/handlers/sources.rs @@ -0,0 +1,263 @@ +//! 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, DatabaseConnection, EntityTrait, Set}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use tokio::fs; +use tokio::io::AsyncWriteExt; + +use crate::models::bio::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 ocr_data: Option, + pub description: Option, + 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, + // TODO: Get user_id from session +) -> Result>, StatusCode> { + let sources = source::Entity::find() + .all(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let items: Vec = 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, + 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, + Path(id): Path, +) -> Result, 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, + 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, + mut multipart: Multipart, +) -> Result, StatusCode> { + let mut file_name: Option = None; + let mut file_type: Option = None; + let mut file_data: Option> = None; + let mut name: Option = None; + let mut description: Option = None; + let mut user_id: Option = 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), + 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, + 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, + Path(id): Path, +) -> Result { + // 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 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, + Path(id): Path, + Json(req): Json, +) -> Result, 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, + ocr_data: updated.ocr_data, + description: updated.description, + uploaded_at: updated.uploaded_at.to_string(), + })) +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 61c41a1..d3dd95b 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -17,6 +17,7 @@ use axum_login::{ }; use sea_orm::DatabaseConnection; use std::net::SocketAddr; +use std::path::PathBuf; use time::Duration; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -144,9 +145,24 @@ fn create_router(db: DatabaseConnection, config: &config::Config) -> Router { .route("/api/users/{user_id}/entries", get(handlers::entries::list_user_entries)) .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)) + .route_layer(middleware::from_fn(require_auth)) + .with_state(sources_state); + Router::new() .merge(public_routes) .merge(protected_routes) + .merge(sources_routes) .layer(auth_layer) .with_state(db) } diff --git a/backend/src/models/bio/biomarker_entry.rs b/backend/src/models/bio/biomarker_entry.rs index 98758f5..3885556 100644 --- a/backend/src/models/bio/biomarker_entry.rs +++ b/backend/src/models/bio/biomarker_entry.rs @@ -26,6 +26,9 @@ pub struct Model { #[sea_orm(column_type = "Text", nullable)] pub notes: Option, + /// Optional foreign key to source document + pub source_id: Option, + pub created_at: DateTime, } @@ -44,6 +47,13 @@ pub enum Relation { to = "crate::models::user::user::Column::Id" )] User, + + #[sea_orm( + belongs_to = "super::source::Entity", + from = "Column::SourceId", + to = "super::source::Column::Id" + )] + Source, } impl Related for Entity { @@ -58,4 +68,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::Source.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/bio/mod.rs b/backend/src/models/bio/mod.rs index bfd7e57..9ee7624 100644 --- a/backend/src/models/bio/mod.rs +++ b/backend/src/models/bio/mod.rs @@ -4,8 +4,10 @@ pub mod biomarker; pub mod biomarker_category; pub mod biomarker_entry; pub mod biomarker_reference_rule; +pub mod source; pub use biomarker::Entity as Biomarker; pub use biomarker_category::Entity as BiomarkerCategory; pub use biomarker_entry::Entity as BiomarkerEntry; pub use biomarker_reference_rule::Entity as BiomarkerReferenceRule; +pub use source::Entity as Source; diff --git a/backend/src/models/bio/source.rs b/backend/src/models/bio/source.rs new file mode 100644 index 0000000..9c13520 --- /dev/null +++ b/backend/src/models/bio/source.rs @@ -0,0 +1,67 @@ +//! 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, + + /// OCR parsed data as JSON + #[sea_orm(column_type = "Text", nullable)] + pub ocr_data: Option, + + /// Optional description/notes + #[sea_orm(column_type = "Text", nullable)] + pub description: Option, + + /// 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 for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::BiomarkerEntries.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/frontend/public/icons/general/icons8-checkmark-50.png b/frontend/public/icons/general/icons8-checkmark-50.png new file mode 100644 index 0000000..91316e6 Binary files /dev/null and b/frontend/public/icons/general/icons8-checkmark-50.png differ diff --git a/frontend/public/icons/general/icons8-clock-50.png b/frontend/public/icons/general/icons8-clock-50.png new file mode 100644 index 0000000..0d6d776 Binary files /dev/null and b/frontend/public/icons/general/icons8-clock-50.png differ diff --git a/frontend/public/icons/general/icons8-collapse-arrow-50.png b/frontend/public/icons/general/icons8-collapse-arrow-50.png new file mode 100644 index 0000000..1b62198 Binary files /dev/null and b/frontend/public/icons/general/icons8-collapse-arrow-50.png differ diff --git a/frontend/public/icons/general/icons8-trash-50.png b/frontend/public/icons/general/icons8-trash-50.png new file mode 100644 index 0000000..0bca7c2 Binary files /dev/null and b/frontend/public/icons/general/icons8-trash-50.png differ diff --git a/frontend/public/icons/general/icons8-upload-to-the-cloud-50.png b/frontend/public/icons/general/icons8-upload-to-the-cloud-50.png new file mode 100644 index 0000000..b5c6578 Binary files /dev/null and b/frontend/public/icons/general/icons8-upload-to-the-cloud-50.png differ diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f325944..12c8d8e 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -90,9 +90,17 @@ export function DashboardPage() { ({categoryBiomarkers.length} biomarkers) - - ▼ - + expand {isExpanded && ( diff --git a/frontend/src/pages/Sources.tsx b/frontend/src/pages/Sources.tsx index edcacc0..5780083 100644 --- a/frontend/src/pages/Sources.tsx +++ b/frontend/src/pages/Sources.tsx @@ -1,4 +1,114 @@ +import { useEffect, useRef, useState } from 'react' + +interface Source { + id: number + user_id: number + name: string + file_path: string + file_type: string + file_size: number + ocr_data: string | null + description: string | null + uploaded_at: string +} + export function SourcesPage() { + const [sources, setSources] = useState([]) + const [loading, setLoading] = useState(true) + const [uploading, setUploading] = useState(false) + const [error, setError] = useState(null) + const [dragOver, setDragOver] = useState(false) + const [deleteConfirmId, setDeleteConfirmId] = useState(null) + const fileInputRef = useRef(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 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 (
@@ -12,30 +122,137 @@ export function SourcesPage() { Upload lab reports in PDF, CSV, or Excel format to import your biomarker data.

+ {error && ( +
+ {error} +
+ )} + + handleUpload(e.target.files)} + /> +
!uploading && fileInputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setDragOver(true) }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault() + setDragOver(false) + handleUpload(e.dataTransfer.files) }} > -
📤
-

- Drag & drop files here, or click to browse -

-

- Supported: PDF, CSV, XLSX -

+ {uploading ? ( + <> +
+ Uploading +
+

Uploading...

+ + ) : ( + <> +
+ Upload +
+

+ Drag & drop files here, or click to browse +

+

+ Supported: PDF, CSV, XLSX, Images +

+ + )}

Recent Uploads

-

No files uploaded yet.

+ + {loading ? ( +

Loading...

+ ) : sources.length === 0 ? ( +

No files uploaded yet.

+ ) : ( +
+ {sources.map(source => ( +
+
+
+ {source.name} +
+
+ {source.file_type} • {formatFileSize(source.file_size)} • {formatDate(source.uploaded_at)} +
+
+
+ {source.ocr_data ? ( + + Parsed Parsed + + ) : ( + Pending + )} + +
+
+ ))} +
+ )}
+ + {/* Delete Confirmation Modal */} + {deleteConfirmId !== null && ( +
+
+

Delete File?

+

+ Are you sure you want to delete this file? This action cannot be undone. +

+
+ + +
+
+
+ )} ) }