Compare commits

..

2 Commits

12 changed files with 1047 additions and 5 deletions

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

@@ -22,11 +22,15 @@ serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
# Auth
axum-login = "0.17"
tower-sessions = "0.14"
argon2 = "0.5"
rand = "0.8"
async-trait = "0.1"
# Time
chrono = { version = "0.4", features = ["serde"] }
time = "0.3"
# Logging
tracing = "0.1"

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

@@ -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,121 @@
//! 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 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,
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,123 @@
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 crate::models::bio::{biomarker, biomarker_entry};
/// 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>,
}
/// 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()),
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: std::collections::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))
}

View File

@@ -0,0 +1,8 @@
//! API Handlers module.
pub mod auth;
pub mod biomarkers;
pub mod categories;
pub mod diets;
pub mod entries;
pub mod users;

View File

@@ -0,0 +1,359 @@
//! 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 email: 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 email: 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>,
}
/// Response for a user.
#[derive(Serialize)]
pub struct UserResponse {
pub id: i32,
pub username: String,
pub email: 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 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,
email: u.email,
role: role_map.get(&u.role_id).cloned().unwrap_or_default(),
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: u.diet_id.and_then(|id| diet_map.get(&id).cloned()),
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,
email: u.email,
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,
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()),
email: Set(req.email.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),
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,
email: inserted.email,
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,
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 let Some(email) = req.email {
active.email = Set(email);
}
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);
}
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,
email: updated.email,
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,
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)
}
/// 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,26 @@
mod auth;
mod cli;
mod config;
mod db;
mod handlers;
mod models;
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 time::Duration;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use auth::AuthBackend;
use cli::{Args, Command};
const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -67,10 +80,8 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("Seed data synced.");
// Start server
tracing::info!("Starting zhealth...");
let app = Router::new()
.route("/", get(root))
.route("/health", get(health_check));
tracing::info!("Starting zhealth API server...");
let app = create_router(db, &config);
let addr: SocketAddr = format!("{}:{}", config.server.host, config.server.port)
.parse()
@@ -86,6 +97,73 @@ async fn main() -> anyhow::Result<()> {
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))
// Entries API
.route("/api/entries", post(handlers::entries::create_entry))
.route("/api/users/{user_id}/entries", get(handlers::entries::list_user_entries))
.route_layer(middleware::from_fn(require_auth));
Router::new()
.merge(public_routes)
.merge(protected_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) {
let log_level = config.logging.level.parse().unwrap_or(tracing::Level::INFO);
tracing_subscriber::registry()