Compare commits
2 Commits
b9fbbbbbd6
...
2d3ad4f567
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d3ad4f567 | |||
| 4890cfb541 |
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
28
assets/logo.svg
Normal file
28
assets/logo.svg
Normal 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 |
@@ -22,11 +22,15 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
|
axum-login = "0.17"
|
||||||
|
tower-sessions = "0.14"
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
# Time
|
# Time
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
time = "0.3"
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
157
backend/src/auth.rs
Normal file
157
backend/src/auth.rs
Normal 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()
|
||||||
|
}
|
||||||
92
backend/src/handlers/auth.rs
Normal file
92
backend/src/handlers/auth.rs
Normal 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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
121
backend/src/handlers/biomarkers.rs
Normal file
121
backend/src/handlers/biomarkers.rs
Normal 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))
|
||||||
|
}
|
||||||
36
backend/src/handlers/categories.rs
Normal file
36
backend/src/handlers/categories.rs
Normal 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))
|
||||||
|
}
|
||||||
36
backend/src/handlers/diets.rs
Normal file
36
backend/src/handlers/diets.rs
Normal 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))
|
||||||
|
}
|
||||||
123
backend/src/handlers/entries.rs
Normal file
123
backend/src/handlers/entries.rs
Normal 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))
|
||||||
|
}
|
||||||
8
backend/src/handlers/mod.rs
Normal file
8
backend/src/handlers/mod.rs
Normal 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;
|
||||||
359
backend/src/handlers/users.rs
Normal file
359
backend/src/handlers/users.rs
Normal 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>,
|
||||||
|
}
|
||||||
@@ -1,13 +1,26 @@
|
|||||||
|
mod auth;
|
||||||
mod cli;
|
mod cli;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod handlers;
|
||||||
mod models;
|
mod models;
|
||||||
mod seed;
|
mod seed;
|
||||||
|
|
||||||
use axum::{routing::get, Router};
|
use axum::{
|
||||||
|
middleware,
|
||||||
|
routing::{get, post, put, delete},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use axum_login::{
|
||||||
|
tower_sessions::{Expiry, MemoryStore, SessionManagerLayer},
|
||||||
|
AuthManagerLayerBuilder,
|
||||||
|
};
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use time::Duration;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
use auth::AuthBackend;
|
||||||
use cli::{Args, Command};
|
use cli::{Args, Command};
|
||||||
|
|
||||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
@@ -67,10 +80,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::info!("Seed data synced.");
|
tracing::info!("Seed data synced.");
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
tracing::info!("Starting zhealth...");
|
tracing::info!("Starting zhealth API server...");
|
||||||
let app = Router::new()
|
let app = create_router(db, &config);
|
||||||
.route("/", get(root))
|
|
||||||
.route("/health", get(health_check));
|
|
||||||
|
|
||||||
let addr: SocketAddr = format!("{}:{}", config.server.host, config.server.port)
|
let addr: SocketAddr = format!("{}:{}", config.server.host, config.server.port)
|
||||||
.parse()
|
.parse()
|
||||||
@@ -86,6 +97,73 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create the API router with all routes.
|
||||||
|
fn create_router(db: DatabaseConnection, config: &config::Config) -> Router {
|
||||||
|
// Session layer (in-memory for now, can be switched to SQLite later)
|
||||||
|
let session_store = MemoryStore::default();
|
||||||
|
let session_layer = SessionManagerLayer::new(session_store)
|
||||||
|
.with_secure(false) // Set to true in production with HTTPS
|
||||||
|
.with_expiry(Expiry::OnInactivity(Duration::hours(
|
||||||
|
config.auth.session_expiry_hours as i64,
|
||||||
|
)));
|
||||||
|
|
||||||
|
// Auth backend
|
||||||
|
let auth_backend = AuthBackend::new(db.clone());
|
||||||
|
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build();
|
||||||
|
|
||||||
|
// Public routes (no auth required)
|
||||||
|
let public_routes = Router::new()
|
||||||
|
.route("/", get(root))
|
||||||
|
.route("/health", get(health_check))
|
||||||
|
// Auth endpoints
|
||||||
|
.route("/api/auth/login", post(handlers::auth::login))
|
||||||
|
.route("/api/auth/logout", post(handlers::auth::logout))
|
||||||
|
.route("/api/auth/me", get(handlers::auth::me))
|
||||||
|
// Public read endpoints
|
||||||
|
.route("/api/biomarkers", get(handlers::biomarkers::list_biomarkers))
|
||||||
|
.route("/api/biomarkers/{id}", get(handlers::biomarkers::get_biomarker))
|
||||||
|
.route("/api/categories", get(handlers::categories::list_categories))
|
||||||
|
.route("/api/diets", get(handlers::diets::list_diets))
|
||||||
|
.route("/api/roles", get(handlers::users::list_roles))
|
||||||
|
// User registration (public)
|
||||||
|
.route("/api/users", post(handlers::users::create_user));
|
||||||
|
|
||||||
|
// Protected routes (require auth)
|
||||||
|
let protected_routes = Router::new()
|
||||||
|
// User management
|
||||||
|
.route("/api/users", get(handlers::users::list_users))
|
||||||
|
.route("/api/users/{id}", get(handlers::users::get_user)
|
||||||
|
.put(handlers::users::update_user)
|
||||||
|
.delete(handlers::users::delete_user))
|
||||||
|
// 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) {
|
fn init_logging(config: &config::Config) {
|
||||||
let log_level = config.logging.level.parse().unwrap_or(tracing::Level::INFO);
|
let log_level = config.logging.level.parse().unwrap_or(tracing::Level::INFO);
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
|
|||||||
Reference in New Issue
Block a user