normogen/backend/src/handlers/medications.rs
goose 09589b3bb2 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.
2026-07-04 08:41:48 -03:00

276 lines
9.4 KiB
Rust

use axum::{
extract::{Extension, Json, Path, Query, State},
http::StatusCode,
};
use std::time::SystemTime;
use crate::{
auth::jwt::Claims, // Fixed: import from auth::jwt instead of handlers::auth
config::AppState,
models::medication::{
CreateMedicationRequest, LogDoseRequest, Medication, MedicationDose, MedicationRepository,
MedicationResponse, UpdateMedicationRequest,
},
};
#[derive(serde::Deserialize)]
pub struct ListMedicationsQuery {
pub profile_id: Option<String>,
pub active: Option<bool>,
pub limit: Option<i64>,
}
pub async fn create_medication(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Json(req): Json<CreateMedicationRequest>,
) -> Result<Json<MedicationResponse>, StatusCode> {
let database = state.db.get_database();
let repo = MedicationRepository::new(database.collection("medications"));
let now = SystemTime::now();
let medication_id = uuid::Uuid::new_v4().to_string();
// Zero-knowledge: the client sends an already-encrypted blob; the server
// stores it verbatim and never inspects the contents.
let medication_data = crate::models::health_data::EncryptedField {
data: req.encrypted_data.data,
encrypted: true,
iv: req.encrypted_data.iv,
auth_tag: req.encrypted_data.auth_tag,
};
let medication = Medication {
id: None,
medication_id,
user_id: claims.sub,
profile_id: req.profile_id.clone(),
medication_data,
reminders: vec![],
created_at: now.into(),
updated_at: now.into(),
active: req.active,
dose_schedule: req.dose_schedule,
pill_identification: None,
};
match repo.create(medication).await {
Ok(med) => Ok(Json(med.into())),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn list_medications(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Query(query): Query<ListMedicationsQuery>,
) -> Result<Json<Vec<MedicationResponse>>, StatusCode> {
let database = state.db.get_database();
let repo = MedicationRepository::new(database.collection("medications"));
// Honor the active filter (and optional profile_id) when provided.
match repo
.find_by_user_filtered(&claims.sub, query.active, query.profile_id.as_deref())
.await
{
Ok(medications) => {
let resp: Vec<MedicationResponse> = medications.into_iter().map(Into::into).collect();
Ok(Json(resp))
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn get_medication(
State(state): State<AppState>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
) -> Result<Json<MedicationResponse>, StatusCode> {
let database = state.db.get_database();
let repo = MedicationRepository::new(database.collection("medications"));
// The path param is the application-level medication_id (a UUID), not the
// Mongo _id, so look it up directly instead of parsing as ObjectId.
match repo.find_by_medication_id(&id).await {
Ok(Some(medication)) => Ok(Json(medication.into())),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn update_medication(
State(state): State<AppState>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
Json(req): Json<UpdateMedicationRequest>,
) -> Result<Json<MedicationResponse>, StatusCode> {
let database = state.db.get_database();
let repo = MedicationRepository::new(database.collection("medications"));
// Look up by medication_id (UUID) — the path param is not a Mongo ObjectId.
match repo.update_by_medication_id(&id, req).await {
Ok(Some(medication)) => Ok(Json(medication.into())),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn delete_medication(
State(state): State<AppState>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
let database = state.db.get_database();
let repo = MedicationRepository::new(database.collection("medications"));
// Look up by medication_id (UUID), not Mongo _id.
match repo.delete_by_medication_id(&id).await {
Ok(true) => Ok(StatusCode::NO_CONTENT),
Ok(false) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn log_dose(
State(state): State<AppState>,
Extension(claims): Extension<Claims>,
Path(id): Path<String>,
Json(req): Json<LogDoseRequest>,
) -> Result<(StatusCode, Json<MedicationDose>), StatusCode> {
let database = state.db.get_database();
let now = SystemTime::now();
let mut dose = crate::models::medication::MedicationDose {
id: None,
medication_id: id.clone(),
user_id: claims.sub.clone(),
logged_at: now.into(),
scheduled_time: req.scheduled_time,
taken: req.taken.unwrap_or(true),
notes: req.notes,
};
let result = database
.collection::<crate::models::medication::MedicationDose>("medication_doses")
.insert_one(&dose, None)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Populate the generated _id so the caller gets the persisted dose back.
dose.id = result.inserted_id.as_object_id();
Ok((StatusCode::CREATED, Json(dose)))
}
pub async fn get_adherence(
State(state): State<AppState>,
Extension(_claims): Extension<Claims>,
Path(id): Path<String>,
) -> Result<Json<crate::models::medication::AdherenceStats>, StatusCode> {
use mongodb::bson::{doc, DateTime};
const PERIOD_DAYS: i64 = 30;
let database = state.db.get_database();
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.
let since = DateTime::from_system_time(
SystemTime::now() - std::time::Duration::from_secs(PERIOD_DAYS as u64 * 86400),
);
let filter = doc! {
"medicationId": &id,
"loggedAt": { "$gte": since }
};
let total_logged = doses
.count_documents(filter.clone(), None)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let taken_filter = doc! { "$and": [filter, doc! { "taken": true }] };
let taken = doses
.count_documents(taken_filter, None)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 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,
scheduled_doses,
taken_doses: taken 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<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
}