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

@ -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))

View file

@ -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(), Ok(None) => (
Err(e) => { StatusCode::NOT_FOUND,
eprintln!("Error fetching health stat: {:?}", e); Json(serde_json::json!({ "error": "not found" })),
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to fetch health stat",
) )
.into_response() .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()
}
}
}

View file

@ -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)?;
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 0.0
} else { } else {
(taken as f64 / total as f64) * 100.0 (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
}

View file

@ -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::{

View file

@ -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,

View file

@ -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> {

View file

@ -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,
}; };

View file

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