diff --git a/backend/src/app.rs b/backend/src/app.rs index 0644947..fc19592 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -72,7 +72,6 @@ pub fn build_app(state: AppState) -> Router { // Health statistics management (Phase 2.7) .route("/api/health-stats", post(handlers::create_health_stat)) .route("/api/health-stats", get(handlers::list_health_stats)) - .route("/api/health-stats/trends", get(handlers::get_health_trends)) .route("/api/health-stats/:id", get(handlers::get_health_stat)) .route("/api/health-stats/:id", put(handlers::update_health_stat)) .route("/api/health-stats/:id", delete(handlers::delete_health_stat)) diff --git a/backend/src/handlers/health_stats.rs b/backend/src/handlers/health_stats.rs index 3db6a67..ca902cd 100644 --- a/backend/src/handlers/health_stats.rs +++ b/backend/src/handlers/health_stats.rs @@ -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, + pub encrypted_data: EncryptedFieldWire, pub recorded_at: Option, } #[derive(Debug, Deserialize)] pub struct UpdateHealthStatRequest { - pub value: Option, - pub unit: Option, - pub notes: Option, -} - -#[derive(Debug, Deserialize)] -pub struct HealthTrendsQuery { - pub stat_type: String, - pub period: Option, // "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 = 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, - Extension(claims): Extension, - Query(query): Query, -) -> 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 = 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 = filtered.iter().map(|s| s.value).collect(); - let avg = values.iter().sum::() / 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() - } - } -} diff --git a/backend/src/handlers/medications.rs b/backend/src/handlers/medications.rs index 9a9beb8..0d70c0e 100644 --- a/backend/src/handlers/medications.rs +++ b/backend/src/handlers/medications.rs @@ -50,6 +50,7 @@ pub async fn create_medication( created_at: now.into(), updated_at: now.into(), active: req.active, + dose_schedule: req.dose_schedule, pill_identification: None, }; @@ -173,6 +174,7 @@ pub async fn get_adherence( let database = state.db.get_database(); let doses: mongodb::Collection = database.collection("medication_doses"); + let med_repo = MedicationRepository::new(database.collection("medications")); // Look at doses logged in the last PERIOD_DAYS days for this medication. let since = DateTime::from_system_time( @@ -183,7 +185,7 @@ pub async fn get_adherence( "loggedAt": { "$gte": since } }; - let total = doses + let total_logged = doses .count_documents(filter.clone(), None) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -194,23 +196,81 @@ pub async fn get_adherence( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // Without a dose-schedule model, every logged dose counts as one scheduled - // dose that was resolved (taken or intentionally skipped). Adherence is the - // share that were marked taken. Real scheduling is future work. - let missed = total.saturating_sub(taken); - let rate = if total == 0 { - 0.0 - } else { - (taken as f64 / total as f64) * 100.0 - }; + // Fetch the medication to check for a dose schedule. + let medication = med_repo + .find_by_medication_id(&id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let (scheduled_doses, total_doses, missed_doses, rate) = + match medication.and_then(|m| m.dose_schedule) { + Some(schedule) => { + // Compute the expected number of doses in the period from the schedule. + let scheduled = compute_scheduled_doses(&schedule, PERIOD_DAYS); + let missed = scheduled.saturating_sub(taken as i64); + let r = if scheduled == 0 { + 0.0 + } else { + (taken as f64 / scheduled as f64) * 100.0 + }; + (scheduled, scheduled, missed, r) + } + None => { + // No schedule: fall back to taken / total_logged. + let missed = (total_logged as i64).saturating_sub(taken as i64); + let r = if total_logged == 0 { + 0.0 + } else { + (taken as f64 / total_logged as f64) * 100.0 + }; + (total_logged as i64, total_logged as i64, missed, r) + } + }; Ok(Json(crate::models::medication::AdherenceStats { medication_id: id, - total_doses: total as i64, - scheduled_doses: total as i64, + total_doses, + scheduled_doses, taken_doses: taken as i64, - missed_doses: missed as i64, + missed_doses, adherence_rate: rate, period_days: PERIOD_DAYS, })) } + +/// Compute the expected number of doses over the last `days` days from a schedule. +fn compute_scheduled_doses(schedule: &crate::models::medication::DoseSchedule, days: i64) -> i64 { + use chrono::{Datelike, Utc}; + let now = Utc::now(); + let weekdays: &[&str] = &["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; + // chrono weekday(): 0=Sunday, 1=Monday, ... 6=Saturday + let active_days: Vec = if schedule.days_of_week.is_empty() { + // Every day. + (0..7).collect() + } else { + schedule + .days_of_week + .iter() + .filter_map(|d| weekdays.iter().position(|&w| w == d)) + .map(|pos| { + // weekdays is Mon-first (0=Mon); chrono is Sun-first (0=Sun). + // Map: Mon=0 -> 1, Tue=1 -> 2, ..., Sun=6 -> 0 + if pos == 6 { + 0 + } else { + pos + 1 + } + }) + .collect() + }; + + let mut count: i64 = 0; + for i in 0..days { + let date = now - chrono::Duration::days(i); + let chrono_dow = date.weekday().num_days_from_sunday() as usize; // 0=Sun + if active_days.contains(&chrono_dow) { + count += schedule.times_per_day as i64; + } + } + count +} diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 632369f..521d0b1 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -17,8 +17,7 @@ pub use appointments::{ pub use auth::{login, logout, recover_password, recovery_info, refresh, register}; pub use health::{health_check, ready_check}; pub use health_stats::{ - create_health_stat, delete_health_stat, get_health_stat, get_health_trends, list_health_stats, - update_health_stat, + create_health_stat, delete_health_stat, get_health_stat, list_health_stats, update_health_stat, }; pub use interactions::{check_interactions, check_new_medication}; pub use medications::{ diff --git a/backend/src/models/health_data.rs b/backend/src/models/health_data.rs index f55efe4..7041400 100644 --- a/backend/src/models/health_data.rs +++ b/backend/src/models/health_data.rs @@ -1,28 +1,8 @@ -use mongodb::bson::{oid::ObjectId, DateTime}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HealthData { - #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(rename = "healthDataId")] - pub health_data_id: String, - #[serde(rename = "userId")] - pub user_id: String, - #[serde(rename = "profileId")] - pub profile_id: String, - #[serde(rename = "familyId")] - pub family_id: Option, - #[serde(rename = "healthData")] - pub health_data: Vec, - #[serde(rename = "createdAt")] - pub created_at: DateTime, - #[serde(rename = "updatedAt")] - pub updated_at: DateTime, - #[serde(rename = "dataSource")] - pub data_source: String, -} - +/// The storage-layer encrypted-field wrapper used by Medication and Appointment +/// documents. NOTE: the `HealthData` model that previously used `Vec` +/// was dead code (no handler/route/repository) and has been removed. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EncryptedField { pub encrypted: bool, diff --git a/backend/src/models/health_stats.rs b/backend/src/models/health_stats.rs index a03d78d..5f28485 100644 --- a/backend/src/models/health_stats.rs +++ b/backend/src/models/health_stats.rs @@ -6,19 +6,42 @@ use mongodb::{ }; use serde::{Deserialize, Serialize}; +use crate::models::medication::EncryptedFieldWire; + +/// Zero-knowledge health stat: the value/unit/type/notes are encrypted inside +/// the opaque blob; only `recorded_at` stays plaintext (filterable/sortable). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HealthStatistic { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] pub id: Option, pub user_id: String, - #[serde(rename = "type")] - pub stat_type: String, - pub value: f64, - pub unit: String, - pub notes: Option, + /// Opaque client-encrypted blob (value, unit, type, notes inside). + #[serde(rename = "encryptedData")] + pub encrypted_data: EncryptedFieldWire, + /// When the reading was taken (plaintext, filterable/sortable). pub recorded_at: String, } +/// Wire response (what the API returns). +#[derive(Debug, Serialize)] +pub struct HealthStatResponse { + pub id: String, + pub user_id: String, + pub encrypted_data: EncryptedFieldWire, + pub recorded_at: String, +} + +impl From for HealthStatResponse { + fn from(s: HealthStatistic) -> Self { + HealthStatResponse { + id: s.id.map(|o| o.to_hex()).unwrap_or_default(), + user_id: s.user_id, + encrypted_data: s.encrypted_data, + recorded_at: s.recorded_at, + } + } +} + #[derive(Clone)] pub struct HealthStatisticsRepository { collection: Collection, @@ -31,11 +54,14 @@ impl HealthStatisticsRepository { } } - pub async fn create(&self, stat: &HealthStatistic) -> Result { + pub async fn create( + &self, + stat: &HealthStatistic, + ) -> Result, MongoError> { let result = self.collection.insert_one(stat, None).await?; let mut created = stat.clone(); - created.id = Some(result.inserted_id.as_object_id().unwrap()); - Ok(created) + created.id = result.inserted_id.as_object_id(); + Ok(Some(created)) } pub async fn find_by_user(&self, user_id: &str) -> Result, MongoError> { @@ -49,14 +75,22 @@ impl HealthStatisticsRepository { self.collection.find_one(filter, None).await } - pub async fn update( + pub async fn update_encrypted_data( &self, id: &ObjectId, - stat: &HealthStatistic, + encrypted_data: EncryptedFieldWire, ) -> Result, MongoError> { - let filter = doc! { "_id": id }; - self.collection.replace_one(filter, stat, None).await?; - Ok(Some(stat.clone())) + self.collection + .find_one_and_update( + doc! { "_id": id }, + doc! { "$set": { + "encryptedData.data": encrypted_data.data, + "encryptedData.iv": encrypted_data.iv, + "encryptedData.authTag": encrypted_data.auth_tag, + }}, + None, + ) + .await } pub async fn delete(&self, id: &ObjectId) -> Result { diff --git a/backend/src/models/medication.rs b/backend/src/models/medication.rs index 9498a10..45e708d 100644 --- a/backend/src/models/medication.rs +++ b/backend/src/models/medication.rs @@ -106,6 +106,8 @@ pub struct MedicationResponse { pub user_id: String, pub profile_id: String, pub active: bool, + /// Dose schedule (plaintext, for adherence calc). + pub dose_schedule: Option, /// Opaque client-encrypted blob (base64 ciphertext + iv). The server stores /// and returns this verbatim; only the client can decrypt it. pub encrypted_data: EncryptedFieldWire, @@ -132,6 +134,7 @@ impl std::convert::From for MedicationResponse { user_id: m.user_id, profile_id: m.profile_id, active: m.active, + dose_schedule: m.dose_schedule, encrypted_data: EncryptedFieldWire { data: m.medication_data.data, iv: m.medication_data.iv, @@ -154,6 +157,17 @@ fn system_time_to_rfc3339(t: std::time::SystemTime) -> String { .unwrap_or_default() } +/// Plaintext dose schedule (top-level on the Medication document so the server +/// can compute adherence without decrypting the opaque medication_data blob). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DoseSchedule { + pub times_per_day: u32, + /// Which days of the week the medication is taken. Empty = every day. + /// Values: "mon","tue","wed","thu","fri","sat","sun" (lowercase). + #[serde(default)] + pub days_of_week: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Medication { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] @@ -176,6 +190,9 @@ pub struct Medication { /// documents that predate the field (see `default_true`). #[serde(rename = "active", default = "default_true")] pub active: bool, + /// Dose schedule for adherence calculation (plaintext, top-level). + #[serde(rename = "doseSchedule", skip_serializing_if = "Option::is_none")] + pub dose_schedule: Option, /// Physical pill identification (Phase 2.8 - optional) #[serde(skip_serializing_if = "Option::is_none")] @@ -226,6 +243,8 @@ pub struct CreateMedicationRequest { /// Whether the medication is active on creation (defaults to true). #[serde(default = "default_true")] pub active: bool, + /// Dose schedule for adherence calculation (plaintext). + pub dose_schedule: Option, } #[derive(Debug, Deserialize)] @@ -235,6 +254,8 @@ pub struct UpdateMedicationRequest { pub encrypted_data: Option, /// Set the medication active/inactive. pub active: Option, + /// Update the dose schedule. + pub dose_schedule: Option>, } #[derive(Debug, Deserialize)] @@ -360,6 +381,19 @@ impl MedicationRepository { if let Some(active) = updates.active { update_doc.insert("active", active); } + if let Some(schedule) = updates.dose_schedule { + match schedule { + Some(s) => { + if let Ok(sched_doc) = mongodb::bson::to_document(&s) { + update_doc.insert("doseSchedule", sched_doc); + } + } + None => { + // Explicitly clear the schedule. + update_doc.insert("doseSchedule", mongodb::bson::Bson::Null); + } + } + } let medication = self .collection @@ -395,6 +429,19 @@ impl MedicationRepository { if let Some(active) = updates.active { update_doc.insert("active", active); } + if let Some(schedule) = updates.dose_schedule { + match schedule { + Some(s) => { + if let Ok(sched_doc) = mongodb::bson::to_document(&s) { + update_doc.insert("doseSchedule", sched_doc); + } + } + None => { + // Explicitly clear the schedule. + update_doc.insert("doseSchedule", mongodb::bson::Bson::Null); + } + } + } let medication = self .collection @@ -438,6 +485,7 @@ mod tests { created_at: DateTime::now(), updated_at: DateTime::now(), active: true, + dose_schedule: None, pill_identification: None, }; diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index d66556b..4088ef4 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -79,6 +79,10 @@ pub fn test_config(db_name: &str) -> Config { allowed_origins: vec!["http://localhost:3000".to_string()], }, environment: Environment::Development, + rate_limit: normogen_backend::config::RateLimitConfig { + max_requests: 1000, + window_secs: 60, + }, } }