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:
goose 2026-07-04 08:41:48 -03:00
parent 69faa43a72
commit 09589b3bb2
8 changed files with 270 additions and 200 deletions

View file

@ -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<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(
@ -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<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
}