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
|
|
@ -72,7 +72,6 @@ pub fn build_app(state: AppState) -> Router {
|
||||||
// Health statistics management (Phase 2.7)
|
// Health statistics management (Phase 2.7)
|
||||||
.route("/api/health-stats", post(handlers::create_health_stat))
|
.route("/api/health-stats", post(handlers::create_health_stat))
|
||||||
.route("/api/health-stats", get(handlers::list_health_stats))
|
.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", get(handlers::get_health_stat))
|
||||||
.route("/api/health-stats/:id", put(handlers::update_health_stat))
|
.route("/api/health-stats/:id", put(handlers::update_health_stat))
|
||||||
.route("/api/health-stats/:id", delete(handlers::delete_health_stat))
|
.route("/api/health-stats/:id", delete(handlers::delete_health_stat))
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
use crate::auth::jwt::Claims;
|
use crate::auth::jwt::Claims;
|
||||||
use crate::config::AppState;
|
use crate::config::AppState;
|
||||||
use crate::models::health_stats::HealthStatistic;
|
use crate::models::health_stats::{HealthStatResponse, HealthStatistic};
|
||||||
|
use crate::models::medication::EncryptedFieldWire;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
Extension, Json,
|
Extension, Json,
|
||||||
|
|
@ -12,24 +13,13 @@ use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateHealthStatRequest {
|
pub struct CreateHealthStatRequest {
|
||||||
pub stat_type: String,
|
pub encrypted_data: EncryptedFieldWire,
|
||||||
pub value: serde_json::Value, // Support complex values like blood pressure
|
|
||||||
pub unit: String,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
pub recorded_at: Option<String>,
|
pub recorded_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateHealthStatRequest {
|
pub struct UpdateHealthStatRequest {
|
||||||
pub value: Option<serde_json::Value>,
|
pub encrypted_data: EncryptedFieldWire,
|
||||||
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 async fn create_health_stat(
|
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 {
|
let stat = HealthStatistic {
|
||||||
id: None,
|
id: None,
|
||||||
user_id: claims.sub.clone(),
|
user_id: claims.sub.clone(),
|
||||||
stat_type: req.stat_type,
|
encrypted_data: req.encrypted_data,
|
||||||
value: value_num,
|
recorded_at: req
|
||||||
unit: req.unit,
|
.recorded_at
|
||||||
notes: req.notes,
|
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339()),
|
||||||
recorded_at: req.recorded_at.unwrap_or_else(|| {
|
|
||||||
use chrono::Utc;
|
|
||||||
Utc::now().to_rfc3339()
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
match repo.create(&stat).await {
|
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) => {
|
Err(e) => {
|
||||||
eprintln!("Error creating health stat: {:?}", e);
|
eprintln!("Error creating health stat: {:?}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"Failed to create health stat",
|
Json(serde_json::json!({ "error": "database error" })),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
@ -99,15 +82,15 @@ pub async fn list_health_stats(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match repo.find_by_user(&claims.sub).await {
|
match repo.find_by_user(&claims.sub).await {
|
||||||
Ok(stats) => (StatusCode::OK, Json(stats)).into_response(),
|
Ok(stats) => {
|
||||||
Err(e) => {
|
let resp: Vec<HealthStatResponse> = stats.into_iter().map(Into::into).collect();
|
||||||
eprintln!("Error fetching health stats: {:?}", e);
|
(StatusCode::OK, Json(resp)).into_response()
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to fetch health stats",
|
|
||||||
)
|
|
||||||
.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) {
|
let object_id = match ObjectId::parse_str(&id) {
|
||||||
Ok(oid) => oid,
|
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 {
|
match repo.find_by_id(&object_id).await {
|
||||||
|
|
@ -136,17 +125,18 @@ pub async fn get_health_stat(
|
||||||
if stat.user_id != claims.sub {
|
if stat.user_id != claims.sub {
|
||||||
return (StatusCode::FORBIDDEN, "Access denied").into_response();
|
return (StatusCode::FORBIDDEN, "Access denied").into_response();
|
||||||
}
|
}
|
||||||
(StatusCode::OK, Json(stat)).into_response()
|
(StatusCode::OK, Json(HealthStatResponse::from(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()
|
|
||||||
}
|
}
|
||||||
|
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) {
|
let object_id = match ObjectId::parse_str(&id) {
|
||||||
Ok(oid) => oid,
|
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(_) => {
|
Err(_) => {
|
||||||
return (
|
return (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::BAD_REQUEST,
|
||||||
"Failed to fetch health stat",
|
Json(serde_json::json!({ "error": "invalid id" })),
|
||||||
)
|
)
|
||||||
.into_response()
|
.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();
|
return (StatusCode::FORBIDDEN, "Access denied").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(value) = req.value {
|
match repo
|
||||||
let value_num = match value {
|
.update_encrypted_data(&object_id, req.encrypted_data)
|
||||||
serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0),
|
.await
|
||||||
_ => 0.0,
|
{
|
||||||
};
|
Ok(Some(updated)) => {
|
||||||
stat.value = value_num;
|
(StatusCode::OK, Json(HealthStatResponse::from(updated))).into_response()
|
||||||
}
|
}
|
||||||
if let Some(unit) = req.unit {
|
Ok(None) => (
|
||||||
stat.unit = unit;
|
StatusCode::NOT_FOUND,
|
||||||
}
|
Json(serde_json::json!({ "error": "not found" })),
|
||||||
if let Some(notes) = req.notes {
|
)
|
||||||
stat.notes = Some(notes);
|
.into_response(),
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
|
||||||
Err(_) => (
|
Err(_) => (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"Failed to update health stat",
|
Json(serde_json::json!({ "error": "database error" })),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
}
|
}
|
||||||
|
|
@ -229,16 +226,28 @@ pub async fn delete_health_stat(
|
||||||
};
|
};
|
||||||
let object_id = match ObjectId::parse_str(&id) {
|
let object_id = match ObjectId::parse_str(&id) {
|
||||||
Ok(oid) => oid,
|
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 {
|
let stat = match repo.find_by_id(&object_id).await {
|
||||||
Ok(Some(s)) => s,
|
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(_) => {
|
Err(_) => {
|
||||||
return (
|
return (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"Failed to fetch health stat",
|
Json(serde_json::json!({ "error": "database error" })),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
@ -252,71 +261,8 @@ pub async fn delete_health_stat(
|
||||||
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
||||||
_ => (
|
_ => (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"Failed to delete health stat",
|
Json(serde_json::json!({ "error": "database error" })),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ pub async fn create_medication(
|
||||||
created_at: now.into(),
|
created_at: now.into(),
|
||||||
updated_at: now.into(),
|
updated_at: now.into(),
|
||||||
active: req.active,
|
active: req.active,
|
||||||
|
dose_schedule: req.dose_schedule,
|
||||||
pill_identification: None,
|
pill_identification: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -173,6 +174,7 @@ pub async fn get_adherence(
|
||||||
|
|
||||||
let database = state.db.get_database();
|
let database = state.db.get_database();
|
||||||
let doses: mongodb::Collection<MedicationDose> = database.collection("medication_doses");
|
let doses: mongodb::Collection<MedicationDose> = 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.
|
// Look at doses logged in the last PERIOD_DAYS days for this medication.
|
||||||
let since = DateTime::from_system_time(
|
let since = DateTime::from_system_time(
|
||||||
|
|
@ -183,7 +185,7 @@ pub async fn get_adherence(
|
||||||
"loggedAt": { "$gte": since }
|
"loggedAt": { "$gte": since }
|
||||||
};
|
};
|
||||||
|
|
||||||
let total = doses
|
let total_logged = doses
|
||||||
.count_documents(filter.clone(), None)
|
.count_documents(filter.clone(), None)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
@ -194,23 +196,81 @@ pub async fn get_adherence(
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
// Without a dose-schedule model, every logged dose counts as one scheduled
|
// Fetch the medication to check for a dose schedule.
|
||||||
// dose that was resolved (taken or intentionally skipped). Adherence is the
|
let medication = med_repo
|
||||||
// share that were marked taken. Real scheduling is future work.
|
.find_by_medication_id(&id)
|
||||||
let missed = total.saturating_sub(taken);
|
.await
|
||||||
let rate = if total == 0 {
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
0.0
|
|
||||||
} else {
|
let (scheduled_doses, total_doses, missed_doses, rate) =
|
||||||
(taken as f64 / total as f64) * 100.0
|
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 {
|
Ok(Json(crate::models::medication::AdherenceStats {
|
||||||
medication_id: id,
|
medication_id: id,
|
||||||
total_doses: total as i64,
|
total_doses,
|
||||||
scheduled_doses: total as i64,
|
scheduled_doses,
|
||||||
taken_doses: taken as i64,
|
taken_doses: taken as i64,
|
||||||
missed_doses: missed as i64,
|
missed_doses,
|
||||||
adherence_rate: rate,
|
adherence_rate: rate,
|
||||||
period_days: PERIOD_DAYS,
|
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<usize> = 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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,7 @@ pub use appointments::{
|
||||||
pub use auth::{login, logout, recover_password, recovery_info, refresh, register};
|
pub use auth::{login, logout, recover_password, recovery_info, refresh, register};
|
||||||
pub use health::{health_check, ready_check};
|
pub use health::{health_check, ready_check};
|
||||||
pub use health_stats::{
|
pub use health_stats::{
|
||||||
create_health_stat, delete_health_stat, get_health_stat, get_health_trends, list_health_stats,
|
create_health_stat, delete_health_stat, get_health_stat, list_health_stats, update_health_stat,
|
||||||
update_health_stat,
|
|
||||||
};
|
};
|
||||||
pub use interactions::{check_interactions, check_new_medication};
|
pub use interactions::{check_interactions, check_new_medication};
|
||||||
pub use medications::{
|
pub use medications::{
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,8 @@
|
||||||
use mongodb::bson::{oid::ObjectId, DateTime};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
/// The storage-layer encrypted-field wrapper used by Medication and Appointment
|
||||||
pub struct HealthData {
|
/// documents. NOTE: the `HealthData` model that previously used `Vec<EncryptedField>`
|
||||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
/// was dead code (no handler/route/repository) and has been removed.
|
||||||
pub id: Option<ObjectId>,
|
|
||||||
#[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<String>,
|
|
||||||
#[serde(rename = "healthData")]
|
|
||||||
pub health_data: Vec<EncryptedField>,
|
|
||||||
#[serde(rename = "createdAt")]
|
|
||||||
pub created_at: DateTime,
|
|
||||||
#[serde(rename = "updatedAt")]
|
|
||||||
pub updated_at: DateTime,
|
|
||||||
#[serde(rename = "dataSource")]
|
|
||||||
pub data_source: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct EncryptedField {
|
pub struct EncryptedField {
|
||||||
pub encrypted: bool,
|
pub encrypted: bool,
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,42 @@ use mongodb::{
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct HealthStatistic {
|
pub struct HealthStatistic {
|
||||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||||
pub id: Option<ObjectId>,
|
pub id: Option<ObjectId>,
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
#[serde(rename = "type")]
|
/// Opaque client-encrypted blob (value, unit, type, notes inside).
|
||||||
pub stat_type: String,
|
#[serde(rename = "encryptedData")]
|
||||||
pub value: f64,
|
pub encrypted_data: EncryptedFieldWire,
|
||||||
pub unit: String,
|
/// When the reading was taken (plaintext, filterable/sortable).
|
||||||
pub notes: Option<String>,
|
|
||||||
pub recorded_at: String,
|
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<HealthStatistic> 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)]
|
#[derive(Clone)]
|
||||||
pub struct HealthStatisticsRepository {
|
pub struct HealthStatisticsRepository {
|
||||||
collection: Collection<HealthStatistic>,
|
collection: Collection<HealthStatistic>,
|
||||||
|
|
@ -31,11 +54,14 @@ impl HealthStatisticsRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create(&self, stat: &HealthStatistic) -> Result<HealthStatistic, MongoError> {
|
pub async fn create(
|
||||||
|
&self,
|
||||||
|
stat: &HealthStatistic,
|
||||||
|
) -> Result<Option<HealthStatistic>, MongoError> {
|
||||||
let result = self.collection.insert_one(stat, None).await?;
|
let result = self.collection.insert_one(stat, None).await?;
|
||||||
let mut created = stat.clone();
|
let mut created = stat.clone();
|
||||||
created.id = Some(result.inserted_id.as_object_id().unwrap());
|
created.id = result.inserted_id.as_object_id();
|
||||||
Ok(created)
|
Ok(Some(created))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_by_user(&self, user_id: &str) -> Result<Vec<HealthStatistic>, MongoError> {
|
pub async fn find_by_user(&self, user_id: &str) -> Result<Vec<HealthStatistic>, MongoError> {
|
||||||
|
|
@ -49,14 +75,22 @@ impl HealthStatisticsRepository {
|
||||||
self.collection.find_one(filter, None).await
|
self.collection.find_one(filter, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update(
|
pub async fn update_encrypted_data(
|
||||||
&self,
|
&self,
|
||||||
id: &ObjectId,
|
id: &ObjectId,
|
||||||
stat: &HealthStatistic,
|
encrypted_data: EncryptedFieldWire,
|
||||||
) -> Result<Option<HealthStatistic>, MongoError> {
|
) -> Result<Option<HealthStatistic>, MongoError> {
|
||||||
let filter = doc! { "_id": id };
|
self.collection
|
||||||
self.collection.replace_one(filter, stat, None).await?;
|
.find_one_and_update(
|
||||||
Ok(Some(stat.clone()))
|
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<bool, MongoError> {
|
pub async fn delete(&self, id: &ObjectId) -> Result<bool, MongoError> {
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,8 @@ pub struct MedicationResponse {
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
pub profile_id: String,
|
pub profile_id: String,
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
|
/// Dose schedule (plaintext, for adherence calc).
|
||||||
|
pub dose_schedule: Option<DoseSchedule>,
|
||||||
/// Opaque client-encrypted blob (base64 ciphertext + iv). The server stores
|
/// Opaque client-encrypted blob (base64 ciphertext + iv). The server stores
|
||||||
/// and returns this verbatim; only the client can decrypt it.
|
/// and returns this verbatim; only the client can decrypt it.
|
||||||
pub encrypted_data: EncryptedFieldWire,
|
pub encrypted_data: EncryptedFieldWire,
|
||||||
|
|
@ -132,6 +134,7 @@ impl std::convert::From<Medication> for MedicationResponse {
|
||||||
user_id: m.user_id,
|
user_id: m.user_id,
|
||||||
profile_id: m.profile_id,
|
profile_id: m.profile_id,
|
||||||
active: m.active,
|
active: m.active,
|
||||||
|
dose_schedule: m.dose_schedule,
|
||||||
encrypted_data: EncryptedFieldWire {
|
encrypted_data: EncryptedFieldWire {
|
||||||
data: m.medication_data.data,
|
data: m.medication_data.data,
|
||||||
iv: m.medication_data.iv,
|
iv: m.medication_data.iv,
|
||||||
|
|
@ -154,6 +157,17 @@ fn system_time_to_rfc3339(t: std::time::SystemTime) -> String {
|
||||||
.unwrap_or_default()
|
.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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Medication {
|
pub struct Medication {
|
||||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -176,6 +190,9 @@ pub struct Medication {
|
||||||
/// documents that predate the field (see `default_true`).
|
/// documents that predate the field (see `default_true`).
|
||||||
#[serde(rename = "active", default = "default_true")]
|
#[serde(rename = "active", default = "default_true")]
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
|
/// Dose schedule for adherence calculation (plaintext, top-level).
|
||||||
|
#[serde(rename = "doseSchedule", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dose_schedule: Option<DoseSchedule>,
|
||||||
|
|
||||||
/// Physical pill identification (Phase 2.8 - optional)
|
/// Physical pill identification (Phase 2.8 - optional)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -226,6 +243,8 @@ pub struct CreateMedicationRequest {
|
||||||
/// Whether the medication is active on creation (defaults to true).
|
/// Whether the medication is active on creation (defaults to true).
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
|
/// Dose schedule for adherence calculation (plaintext).
|
||||||
|
pub dose_schedule: Option<DoseSchedule>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -235,6 +254,8 @@ pub struct UpdateMedicationRequest {
|
||||||
pub encrypted_data: Option<EncryptedFieldWire>,
|
pub encrypted_data: Option<EncryptedFieldWire>,
|
||||||
/// Set the medication active/inactive.
|
/// Set the medication active/inactive.
|
||||||
pub active: Option<bool>,
|
pub active: Option<bool>,
|
||||||
|
/// Update the dose schedule.
|
||||||
|
pub dose_schedule: Option<Option<DoseSchedule>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -360,6 +381,19 @@ impl MedicationRepository {
|
||||||
if let Some(active) = updates.active {
|
if let Some(active) = updates.active {
|
||||||
update_doc.insert("active", 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
|
let medication = self
|
||||||
.collection
|
.collection
|
||||||
|
|
@ -395,6 +429,19 @@ impl MedicationRepository {
|
||||||
if let Some(active) = updates.active {
|
if let Some(active) = updates.active {
|
||||||
update_doc.insert("active", 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
|
let medication = self
|
||||||
.collection
|
.collection
|
||||||
|
|
@ -438,6 +485,7 @@ mod tests {
|
||||||
created_at: DateTime::now(),
|
created_at: DateTime::now(),
|
||||||
updated_at: DateTime::now(),
|
updated_at: DateTime::now(),
|
||||||
active: true,
|
active: true,
|
||||||
|
dose_schedule: None,
|
||||||
pill_identification: None,
|
pill_identification: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,10 @@ pub fn test_config(db_name: &str) -> Config {
|
||||||
allowed_origins: vec!["http://localhost:3000".to_string()],
|
allowed_origins: vec!["http://localhost:3000".to_string()],
|
||||||
},
|
},
|
||||||
environment: Environment::Development,
|
environment: Environment::Development,
|
||||||
|
rate_limit: normogen_backend::config::RateLimitConfig {
|
||||||
|
max_requests: 1000,
|
||||||
|
window_secs: 60,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue