From 4890cfb5415940fd8586919d01efcfbeeeee6afe Mon Sep 17 00:00:00 2001 From: abhishekbhakat Date: Fri, 19 Dec 2025 16:21:12 +0530 Subject: [PATCH] feat: implement session-based authentication and core API handlers for user management --- backend/Cargo.toml | 4 + backend/src/auth.rs | 157 +++++++++++++ backend/src/handlers/auth.rs | 92 ++++++++ backend/src/handlers/biomarkers.rs | 121 ++++++++++ backend/src/handlers/categories.rs | 36 +++ backend/src/handlers/diets.rs | 36 +++ backend/src/handlers/entries.rs | 123 ++++++++++ backend/src/handlers/mod.rs | 8 + backend/src/handlers/users.rs | 359 +++++++++++++++++++++++++++++ backend/src/main.rs | 88 ++++++- 10 files changed, 1019 insertions(+), 5 deletions(-) create mode 100644 backend/src/auth.rs create mode 100644 backend/src/handlers/auth.rs create mode 100644 backend/src/handlers/biomarkers.rs create mode 100644 backend/src/handlers/categories.rs create mode 100644 backend/src/handlers/diets.rs create mode 100644 backend/src/handlers/entries.rs create mode 100644 backend/src/handlers/mod.rs create mode 100644 backend/src/handlers/users.rs diff --git a/backend/Cargo.toml b/backend/Cargo.toml index aa19782..cc5fb6c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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" diff --git a/backend/src/auth.rs b/backend/src/auth.rs new file mode 100644 index 0000000..0dd045c --- /dev/null +++ b/backend/src/auth.rs @@ -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), +} + +#[async_trait] +impl AuthnBackend for AuthBackend { + type User = AuthenticatedUser; + type Credentials = Credentials; + type Error = AuthError; + + async fn authenticate( + &self, + creds: Self::Credentials, + ) -> Result, 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) -> Result, 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; + +/// Hash a password using Argon2. +pub fn hash_password(password: &str) -> Result { + 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() +} diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs new file mode 100644 index 0000000..5bb5b41 --- /dev/null +++ b/backend/src/handlers/auth.rs @@ -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, +} + +#[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, +) -> Result, 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, 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, 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, + })), + } +} diff --git a/backend/src/handlers/biomarkers.rs b/backend/src/handlers/biomarkers.rs new file mode 100644 index 0000000..ac7f5b1 --- /dev/null +++ b/backend/src/handlers/biomarkers.rs @@ -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, + pub description: Option, + pub reference_rules: Vec, +} + +/// Response for a reference rule. +#[derive(Serialize)] +pub struct ReferenceRuleResponse { + pub id: i32, + pub rule_type: String, + pub sex: String, + pub age_min: Option, + pub age_max: Option, + pub time_of_day: Option, + pub life_stage: Option, + pub value_min: Option, + pub value_max: Option, + pub expected_value: Option, + 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, +} + +/// GET /api/biomarkers - List all biomarkers. +pub async fn list_biomarkers( + State(db): State, +) -> Result>, StatusCode> { + let biomarkers = biomarker::Entity::find() + .all(&db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let items: Vec = 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, + Path(id): Path, +) -> Result, 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)) +} diff --git a/backend/src/handlers/categories.rs b/backend/src/handlers/categories.rs new file mode 100644 index 0000000..9ff464c --- /dev/null +++ b/backend/src/handlers/categories.rs @@ -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, +} + +/// GET /api/categories - List all biomarker categories. +pub async fn list_categories( + State(db): State, +) -> Result>, StatusCode> { + let categories = biomarker_category::Entity::find() + .all(&db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let items: Vec = categories + .into_iter() + .map(|c| CategoryResponse { + id: c.id, + name: c.name, + description: c.description, + }) + .collect(); + + Ok(Json(items)) +} diff --git a/backend/src/handlers/diets.rs b/backend/src/handlers/diets.rs new file mode 100644 index 0000000..31b79a8 --- /dev/null +++ b/backend/src/handlers/diets.rs @@ -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, +} + +/// GET /api/diets - List all diet types for UI dropdown. +pub async fn list_diets( + State(db): State, +) -> Result>, StatusCode> { + let diets = diet::Entity::find() + .all(&db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let items: Vec = diets + .into_iter() + .map(|d| DietResponse { + id: d.id, + name: d.name, + description: d.description, + }) + .collect(); + + Ok(Json(items)) +} diff --git a/backend/src/handlers/entries.rs b/backend/src/handlers/entries.rs new file mode 100644 index 0000000..b1a3ce4 --- /dev/null +++ b/backend/src/handlers/entries.rs @@ -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, // ISO 8601 datetime, defaults to now + pub notes: Option, +} + +/// 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, +} + +/// POST /api/entries - Create a new biomarker entry. +pub async fn create_entry( + State(db): State, + Json(req): Json, +) -> Result, 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, + Path(user_id): Path, +) -> Result>, 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 = 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 = biomarkers + .into_iter() + .map(|b| (b.id, b.name)) + .collect(); + + let items: Vec = 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)) +} diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs new file mode 100644 index 0000000..4774f5c --- /dev/null +++ b/backend/src/handlers/mod.rs @@ -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; diff --git a/backend/src/handlers/users.rs b/backend/src/handlers/users.rs new file mode 100644 index 0000000..321d320 --- /dev/null +++ b/backend/src/handlers/users.rs @@ -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, // defaults to "user" + pub height_cm: Option, + pub blood_type: Option, + pub birthdate: Option, // YYYY-MM-DD + pub smoking: Option, + pub alcohol: Option, + pub diet_id: Option, +} + +/// Request to update a user. +#[derive(Deserialize)] +pub struct UpdateUserRequest { + pub email: Option, + pub height_cm: Option, + pub blood_type: Option, + pub birthdate: Option, + pub smoking: Option, + pub alcohol: Option, + pub diet_id: Option, +} + +/// 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, + pub blood_type: Option, + pub birthdate: Option, + pub smoking: Option, + pub alcohol: Option, + pub diet: Option, + pub created_at: String, +} + +/// GET /api/users - List all users. +pub async fn list_users( + State(db): State, +) -> Result>, 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 = roles + .into_iter() + .map(|r| (r.id, r.name)) + .collect(); + + let diet_map: std::collections::HashMap = diets + .into_iter() + .map(|d| (d.id, d.name)) + .collect(); + + let items: Vec = 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, + Path(id): Path, +) -> Result, 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, + Json(req): Json, +) -> Result, 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, + Path(id): Path, + Json(req): Json, +) -> Result, 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, + Path(id): Path, +) -> Result { + 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, +) -> Result>, StatusCode> { + let roles = role::Entity::find() + .all(&db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let items: Vec = 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, +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 1dc4715..678633d 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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()