feat: Implement source management with file upload, display, and deletion functionality on a new Sources page, backed by new backend models and handlers.

This commit is contained in:
2025-12-20 15:55:16 +05:30
parent 89815e7e21
commit d9f6694b2f
19 changed files with 612 additions and 15 deletions

1
.gitignore vendored
View File

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

View File

@@ -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"] }

View File

@@ -9,6 +9,7 @@ server:
paths:
database: "./data/zhealth.db"
logs: "./logs"
uploads: "./data/uploads"
logging:
level: "info" # Options: trace | debug | info | warn | error

View File

@@ -24,6 +24,7 @@ pub struct ServerConfig {
pub struct PathsConfig {
pub database: String,
pub logs: String,
pub uploads: String,
}
#[derive(Debug, Deserialize)]

View File

@@ -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),
];

View File

@@ -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),
};

View File

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

View File

@@ -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<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,
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,
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),
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<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 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,
ocr_data: updated.ocr_data,
description: updated.description,
uploaded_at: updated.uploaded_at.to_string(),
}))
}

View File

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

View File

@@ -26,6 +26,9 @@ pub struct Model {
#[sea_orm(column_type = "Text", nullable)]
pub notes: Option<String>,
/// Optional foreign key to source document
pub source_id: Option<i32>,
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<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 {}

View File

@@ -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;

View File

@@ -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<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 {}

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: 625 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 B

View File

@@ -90,9 +90,17 @@ export function DashboardPage() {
({categoryBiomarkers.length} biomarkers)
</span>
</div>
<span style={{ fontSize: '18px', transition: 'transform 0.2s', transform: isExpanded ? 'rotate(180deg)' : 'rotate(0)' }}>
</span>
<img
src="/icons/general/icons8-collapse-arrow-50.png"
alt="expand"
className="theme-icon"
style={{
width: 18,
height: 18,
transition: 'transform 0.2s',
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0)'
}}
/>
</button>
{isExpanded && (

View File

@@ -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<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 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 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">
@@ -12,30 +122,137 @@ export function SourcesPage() {
Upload lab reports in PDF, CSV, or Excel format to import your biomarker data.
</p>
{error && (
<div className="alert alert-error" style={{ marginBottom: 'var(--space-md)' }}>
{error}
</div>
)}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
multiple
accept=".pdf,.csv,.xlsx,.xls,.jpg,.jpeg,.png"
onChange={(e) => handleUpload(e.target.files)}
/>
<div
className="upload-zone"
className={`upload-zone ${dragOver ? 'drag-over' : ''}`}
style={{
border: '2px dashed var(--border)',
border: `2px dashed ${dragOver ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)',
padding: 'var(--space-xl)',
textAlign: 'center',
cursor: 'pointer',
cursor: uploading ? 'wait' : 'pointer',
backgroundColor: dragOver ? 'color-mix(in srgb, var(--accent) 5%, var(--bg-secondary))' : 'transparent',
transition: 'all 0.2s',
}}
onClick={() => !uploading && fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault()
setDragOver(false)
handleUpload(e.dataTransfer.files)
}}
>
<div style={{ fontSize: '36px', marginBottom: 'var(--space-sm)' }}>📤</div>
<p className="text-secondary">
Drag & drop files here, or click to browse
</p>
<p className="text-secondary text-xs" style={{ marginTop: 'var(--space-sm)' }}>
Supported: PDF, CSV, XLSX
</p>
{uploading ? (
<>
<div style={{ marginBottom: 'var(--space-sm)', textAlign: 'center' }}>
<img src="/icons/general/icons8-clock-50.png" alt="Uploading" className="theme-icon" style={{ width: 36, height: 36, display: 'block', margin: '0 auto' }} />
</div>
<p className="text-secondary">Uploading...</p>
</>
) : (
<>
<div style={{ marginBottom: 'var(--space-sm)', textAlign: 'center' }}>
<img src="/icons/general/icons8-upload-to-the-cloud-50.png" alt="Upload" className="theme-icon" style={{ width: 36, height: 36, display: 'block', margin: '0 auto' }} />
</div>
<p className="text-secondary">
Drag & drop files here, or click to browse
</p>
<p className="text-secondary text-xs" style={{ marginTop: 'var(--space-sm)' }}>
Supported: PDF, CSV, XLSX, Images
</p>
</>
)}
</div>
</div>
<div className="card" style={{ marginTop: 'var(--space-lg)' }}>
<h3 style={{ marginBottom: 'var(--space-md)' }}>Recent Uploads</h3>
<p className="text-secondary text-sm">No files uploaded yet.</p>
{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" style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: 'var(--space-sm) 0',
borderBottom: '1px solid var(--border)',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{source.name}
</div>
<div className="text-secondary text-xs">
{source.file_type} {formatFileSize(source.file_size)} {formatDate(source.uploaded_at)}
</div>
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
{source.ocr_data ? (
<span style={{ color: 'var(--success)', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<img src="/icons/general/icons8-checkmark-50.png" alt="Parsed" style={{ width: 14, height: 14 }} /> Parsed
</span>
) : (
<span style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>Pending</span>
)}
<button
className="btn btn-danger btn-sm"
onClick={() => setDeleteConfirmId(source.id)}
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmId !== null && (
<div style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}>
<div className="card" style={{ maxWidth: 400, textAlign: 'center' }}>
<h3 style={{ marginBottom: 'var(--space-md)' }}>Delete File?</h3>
<p className="text-secondary" style={{ marginBottom: 'var(--space-lg)' }}>
Are you sure you want to delete this file? This action cannot be undone.
</p>
<div style={{ display: 'flex', gap: 'var(--space-sm)', justifyContent: 'center' }}>
<button className="btn" onClick={() => setDeleteConfirmId(null)}>
Cancel
</button>
<button className="btn btn-danger" onClick={() => handleDelete(deleteConfirmId)}>
Delete
</button>
</div>
</div>
</div>
)}
</div>
)
}