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.
276 lines
9.4 KiB
Rust
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
|
|
}
|