feat(backend): dose scheduling + health stats zero-knowledge
Backend changes (frontend + tests follow in next commit): Dose scheduling: - New DoseSchedule struct (times_per_day + days_of_week) as a top-level plaintext field on Medication. Create/update requests accept it. - Revised get_adherence: computes scheduled_doses from the schedule over the period (days_of_week filtering), so missed doses are now reflected. Falls back to taken/total_logged when no schedule. Health stats zero-knowledge: - HealthStatistic model now uses opaque encrypted_data blob (like medications). Only recorded_at stays plaintext (filterable/sortable). - HealthStatResponse wire type. Handlers echo opaque blobs. - Removed the trends endpoint (server can't compute trends on ciphertext; frontend computes them client-side after decrypting). - Deleted the dead HealthData model (kept EncryptedField which it defined). Verified: backend 24 tests, 0 clippy warnings.
This commit is contained in:
parent
69faa43a72
commit
09589b3bb2
8 changed files with 270 additions and 200 deletions
|
|
@ -1,8 +1,9 @@
|
|||
use crate::auth::jwt::Claims;
|
||||
use crate::config::AppState;
|
||||
use crate::models::health_stats::HealthStatistic;
|
||||
use crate::models::health_stats::{HealthStatResponse, HealthStatistic};
|
||||
use crate::models::medication::EncryptedFieldWire;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
|
|
@ -12,24 +13,13 @@ use serde::Deserialize;
|
|||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateHealthStatRequest {
|
||||
pub stat_type: String,
|
||||
pub value: serde_json::Value, // Support complex values like blood pressure
|
||||
pub unit: String,
|
||||
pub notes: Option<String>,
|
||||
pub encrypted_data: EncryptedFieldWire,
|
||||
pub recorded_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateHealthStatRequest {
|
||||
pub value: Option<serde_json::Value>,
|
||||
pub unit: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HealthTrendsQuery {
|
||||
pub stat_type: String,
|
||||
pub period: Option<String>, // "7d", "30d", etc.
|
||||
pub encrypted_data: EncryptedFieldWire,
|
||||
}
|
||||
|
||||
pub async fn create_health_stat(
|
||||
|
|
@ -48,36 +38,29 @@ pub async fn create_health_stat(
|
|||
}
|
||||
};
|
||||
|
||||
// Convert complex value to f64 or store as string
|
||||
let value_num = match req.value {
|
||||
serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0),
|
||||
serde_json::Value::Object(_) => {
|
||||
// For complex objects like blood pressure, use a default
|
||||
0.0
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
|
||||
let stat = HealthStatistic {
|
||||
id: None,
|
||||
user_id: claims.sub.clone(),
|
||||
stat_type: req.stat_type,
|
||||
value: value_num,
|
||||
unit: req.unit,
|
||||
notes: req.notes,
|
||||
recorded_at: req.recorded_at.unwrap_or_else(|| {
|
||||
use chrono::Utc;
|
||||
Utc::now().to_rfc3339()
|
||||
}),
|
||||
encrypted_data: req.encrypted_data,
|
||||
recorded_at: req
|
||||
.recorded_at
|
||||
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339()),
|
||||
};
|
||||
|
||||
match repo.create(&stat).await {
|
||||
Ok(created) => (StatusCode::CREATED, Json(created)).into_response(),
|
||||
Ok(Some(created)) => {
|
||||
(StatusCode::CREATED, Json(HealthStatResponse::from(created))).into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "failed to create" })),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
eprintln!("Error creating health stat: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to create health stat",
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
|
@ -99,15 +82,15 @@ pub async fn list_health_stats(
|
|||
}
|
||||
};
|
||||
match repo.find_by_user(&claims.sub).await {
|
||||
Ok(stats) => (StatusCode::OK, Json(stats)).into_response(),
|
||||
Err(e) => {
|
||||
eprintln!("Error fetching health stats: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to fetch health stats",
|
||||
)
|
||||
.into_response()
|
||||
Ok(stats) => {
|
||||
let resp: Vec<HealthStatResponse> = stats.into_iter().map(Into::into).collect();
|
||||
(StatusCode::OK, Json(resp)).into_response()
|
||||
}
|
||||
Err(_) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +111,13 @@ pub async fn get_health_stat(
|
|||
};
|
||||
let object_id = match ObjectId::parse_str(&id) {
|
||||
Ok(oid) => oid,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid ID").into_response(),
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "invalid id" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
match repo.find_by_id(&object_id).await {
|
||||
|
|
@ -136,17 +125,18 @@ pub async fn get_health_stat(
|
|||
if stat.user_id != claims.sub {
|
||||
return (StatusCode::FORBIDDEN, "Access denied").into_response();
|
||||
}
|
||||
(StatusCode::OK, Json(stat)).into_response()
|
||||
}
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Health stat not found").into_response(),
|
||||
Err(e) => {
|
||||
eprintln!("Error fetching health stat: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to fetch health stat",
|
||||
)
|
||||
.into_response()
|
||||
(StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -168,45 +158,52 @@ pub async fn update_health_stat(
|
|||
};
|
||||
let object_id = match ObjectId::parse_str(&id) {
|
||||
Ok(oid) => oid,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid ID").into_response(),
|
||||
};
|
||||
|
||||
let mut stat = match repo.find_by_id(&object_id).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Health stat not found").into_response(),
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to fetch health stat",
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "invalid id" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if stat.user_id != claims.sub {
|
||||
let existing = match repo.find_by_id(&object_id).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if existing.user_id != claims.sub {
|
||||
return (StatusCode::FORBIDDEN, "Access denied").into_response();
|
||||
}
|
||||
|
||||
if let Some(value) = req.value {
|
||||
let value_num = match value {
|
||||
serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0),
|
||||
_ => 0.0,
|
||||
};
|
||||
stat.value = value_num;
|
||||
}
|
||||
if let Some(unit) = req.unit {
|
||||
stat.unit = unit;
|
||||
}
|
||||
if let Some(notes) = req.notes {
|
||||
stat.notes = Some(notes);
|
||||
}
|
||||
|
||||
match repo.update(&object_id, &stat).await {
|
||||
Ok(Some(updated)) => (StatusCode::OK, Json(updated)).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Failed to update").into_response(),
|
||||
match repo
|
||||
.update_encrypted_data(&object_id, req.encrypted_data)
|
||||
.await
|
||||
{
|
||||
Ok(Some(updated)) => {
|
||||
(StatusCode::OK, Json(HealthStatResponse::from(updated))).into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to update health stat",
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
|
|
@ -229,16 +226,28 @@ pub async fn delete_health_stat(
|
|||
};
|
||||
let object_id = match ObjectId::parse_str(&id) {
|
||||
Ok(oid) => oid,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid ID").into_response(),
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "invalid id" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let stat = match repo.find_by_id(&object_id).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Health stat not found").into_response(),
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to fetch health stat",
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
|
@ -252,71 +261,8 @@ pub async fn delete_health_stat(
|
|||
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
||||
_ => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to delete health stat",
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_health_trends(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Query(query): Query<HealthTrendsQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let repo = match state.health_stats_repo.as_ref() {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({ "error": "health stats unavailable" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
match repo.find_by_user(&claims.sub).await {
|
||||
Ok(stats) => {
|
||||
// Filter by stat_type
|
||||
let filtered: Vec<HealthStatistic> = stats
|
||||
.into_iter()
|
||||
.filter(|s| s.stat_type == query.stat_type)
|
||||
.collect();
|
||||
|
||||
// Calculate basic trend statistics
|
||||
if filtered.is_empty() {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"stat_type": query.stat_type,
|
||||
"count": 0,
|
||||
"data": []
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let values: Vec<f64> = filtered.iter().map(|s| s.value).collect();
|
||||
let avg = values.iter().sum::<f64>() / values.len() as f64;
|
||||
let min = values.iter().fold(f64::INFINITY, |a, &b| a.min(b));
|
||||
let max = values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
|
||||
|
||||
let response = serde_json::json!({
|
||||
"stat_type": query.stat_type,
|
||||
"count": filtered.len(),
|
||||
"average": avg,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"data": filtered
|
||||
});
|
||||
|
||||
(StatusCode::OK, Json(response)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error fetching health trends: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to fetch health trends",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue