Merge feat/dose-schedule-health-enc-tests
Dose scheduling model + health stats zero-knowledge encryption.
This commit is contained in:
commit
288e776a8c
12 changed files with 416 additions and 255 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(),
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,16 @@ import {
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useHealthStore } from '../../store/useStore';
|
import { useHealthStore } from '../../store/useStore';
|
||||||
import { HealthStatType, type CreateHealthStatRequest, type TrendData } from '../../types/api';
|
import { HealthStatType, type TrendData } from '../../types/api';
|
||||||
|
|
||||||
|
// Domain form type (decrypted fields the UI collects; the store encrypts them).
|
||||||
|
interface HealthFormData {
|
||||||
|
stat_type: HealthStatType;
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
measured_at: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const STAT_TYPES = Object.values(HealthStatType);
|
const STAT_TYPES = Object.values(HealthStatType);
|
||||||
|
|
||||||
|
|
@ -73,7 +82,7 @@ export const HealthStats: FC = () => {
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [chartType, setChartType] = useState<HealthStatType>(HealthStatType.Weight);
|
const [chartType, setChartType] = useState<HealthStatType>(HealthStatType.Weight);
|
||||||
|
|
||||||
const [form, setForm] = useState<CreateHealthStatRequest>({
|
const [form, setForm] = useState<HealthFormData>({
|
||||||
stat_type: HealthStatType.Weight,
|
stat_type: HealthStatType.Weight,
|
||||||
value: 0,
|
value: 0,
|
||||||
unit: UNIT_DEFAULTS[HealthStatType.Weight],
|
unit: UNIT_DEFAULTS[HealthStatType.Weight],
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@ import {
|
||||||
DrugInteraction,
|
DrugInteraction,
|
||||||
CheckInteractionRequest,
|
CheckInteractionRequest,
|
||||||
CheckNewMedicationRequest,
|
CheckNewMedicationRequest,
|
||||||
HealthStat,
|
|
||||||
CreateHealthStatRequest,
|
CreateHealthStatRequest,
|
||||||
TrendData,
|
|
||||||
ApiError,
|
ApiError,
|
||||||
DoseLog,
|
DoseLog,
|
||||||
LogDoseRequest,
|
LogDoseRequest,
|
||||||
|
|
@ -20,6 +18,8 @@ import {
|
||||||
MedicationWireResponse,
|
MedicationWireResponse,
|
||||||
AppointmentWireResponse,
|
AppointmentWireResponse,
|
||||||
ProfileWireResponse,
|
ProfileWireResponse,
|
||||||
|
HealthStatWireResponse,
|
||||||
|
UpdateHealthStatRequest,
|
||||||
EncryptedFieldWire,
|
EncryptedFieldWire,
|
||||||
CreateAppointmentRequest,
|
CreateAppointmentRequest,
|
||||||
UpdateAppointmentRequest,
|
UpdateAppointmentRequest,
|
||||||
|
|
@ -330,25 +330,25 @@ class ApiService {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Health Statistics ----
|
// ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ----
|
||||||
|
|
||||||
async getHealthStats(): Promise<HealthStat[]> {
|
async getHealthStats(): Promise<HealthStatWireResponse[]> {
|
||||||
const response = await this.client.get<HealthStat[]>('/health-stats');
|
const response = await this.client.get<HealthStatWireResponse[]>('/health-stats');
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHealthStat(id: string): Promise<HealthStat> {
|
async getHealthStat(id: string): Promise<HealthStatWireResponse> {
|
||||||
const response = await this.client.get<HealthStat>(`/health-stats/${id}`);
|
const response = await this.client.get<HealthStatWireResponse>(`/health-stats/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createHealthStat(data: CreateHealthStatRequest): Promise<HealthStat> {
|
async createHealthStat(data: CreateHealthStatRequest): Promise<HealthStatWireResponse> {
|
||||||
const response = await this.client.post<HealthStat>('/health-stats', data);
|
const response = await this.client.post<HealthStatWireResponse>('/health-stats', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateHealthStat(id: string, data: Partial<CreateHealthStatRequest>): Promise<HealthStat> {
|
async updateHealthStat(id: string, data: UpdateHealthStatRequest): Promise<HealthStatWireResponse> {
|
||||||
const response = await this.client.put<HealthStat>(`/health-stats/${id}`, data);
|
const response = await this.client.put<HealthStatWireResponse>(`/health-stats/${id}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -356,11 +356,6 @@ class ApiService {
|
||||||
await this.client.delete(`/health-stats/${id}`);
|
await this.client.delete(`/health-stats/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHealthTrends(): Promise<TrendData[]> {
|
|
||||||
const response = await this.client.get<TrendData[]>('/health-stats/trends');
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: lab-results endpoints are intentionally absent — the backend has no
|
// NOTE: lab-results endpoints are intentionally absent — the backend has no
|
||||||
// /lab-results routes yet. The LabResult type stays in types/api.ts for when
|
// /lab-results routes yet. The LabResult type stays in types/api.ts for when
|
||||||
// those routes land.
|
// those routes land.
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
User,
|
User,
|
||||||
Medication,
|
Medication,
|
||||||
HealthStat,
|
HealthStat,
|
||||||
|
HealthStatType,
|
||||||
DrugInteraction,
|
DrugInteraction,
|
||||||
AdherenceStats,
|
AdherenceStats,
|
||||||
Profile,
|
Profile,
|
||||||
|
|
@ -342,6 +343,7 @@ export const useMedicationStore = create<MedicationState>()(
|
||||||
notes: data.notes as string | undefined,
|
notes: data.notes as string | undefined,
|
||||||
tags: data.tags as string[] | undefined,
|
tags: data.tags as string[] | undefined,
|
||||||
active: w.active,
|
active: w.active,
|
||||||
|
dose_schedule: (w as any).dose_schedule ?? undefined,
|
||||||
created_at: w.created_at,
|
created_at: w.created_at,
|
||||||
updated_at: w.updated_at,
|
updated_at: w.updated_at,
|
||||||
} as Medication;
|
} as Medication;
|
||||||
|
|
@ -506,10 +508,31 @@ export const useHealthStore = create<HealthState>()(
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
loadStats: async () => {
|
loadStats: async () => {
|
||||||
|
const key = getEncKey();
|
||||||
|
if (!key) {
|
||||||
|
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const stats = await apiService.getHealthStats();
|
const wireStats = await apiService.getHealthStats();
|
||||||
set({ stats, isLoading: false });
|
const stats = await Promise.all(
|
||||||
|
wireStats.map(async (w) => {
|
||||||
|
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
||||||
|
return {
|
||||||
|
stat_id: w.id,
|
||||||
|
user_id: w.user_id,
|
||||||
|
stat_type: (data.stat_type as HealthStatType) ?? HealthStatType.Other,
|
||||||
|
value: (data.value as number) ?? 0,
|
||||||
|
unit: (data.unit as string) ?? '',
|
||||||
|
measured_at: w.recorded_at,
|
||||||
|
notes: data.notes as string | undefined,
|
||||||
|
} as HealthStat;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Compute trends client-side (server can't compute on ciphertext).
|
||||||
|
const computedTrends = computeTrends(stats);
|
||||||
|
set({ stats, trends: computedTrends, isLoading: false });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
set({
|
set({
|
||||||
error: error.message || 'Failed to load health stats',
|
error: error.message || 'Failed to load health stats',
|
||||||
|
|
@ -519,9 +542,33 @@ export const useHealthStore = create<HealthState>()(
|
||||||
},
|
},
|
||||||
|
|
||||||
createStat: async (data: any) => {
|
createStat: async (data: any) => {
|
||||||
|
const key = getEncKey();
|
||||||
|
if (!key) {
|
||||||
|
set({ error: 'No encryption key — cannot encrypt data' });
|
||||||
|
throw new Error('No encryption key');
|
||||||
|
}
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const stat = await apiService.createHealthStat(data);
|
const d = data as any;
|
||||||
|
const encrypted_data = await encryptJson({
|
||||||
|
stat_type: d.stat_type,
|
||||||
|
value: d.value,
|
||||||
|
unit: d.unit,
|
||||||
|
notes: d.notes,
|
||||||
|
}, key);
|
||||||
|
const wire = await apiService.createHealthStat({
|
||||||
|
encrypted_data,
|
||||||
|
recorded_at: d.measured_at,
|
||||||
|
});
|
||||||
|
const stat: HealthStat = {
|
||||||
|
stat_id: wire.id,
|
||||||
|
user_id: wire.user_id,
|
||||||
|
stat_type: d.stat_type,
|
||||||
|
value: d.value,
|
||||||
|
unit: d.unit,
|
||||||
|
measured_at: wire.recorded_at,
|
||||||
|
notes: d.notes,
|
||||||
|
};
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
stats: [...state.stats, stat],
|
stats: [...state.stats, stat],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|
@ -536,58 +583,80 @@ export const useHealthStore = create<HealthState>()(
|
||||||
},
|
},
|
||||||
|
|
||||||
updateStat: async (id: string, data: any) => {
|
updateStat: async (id: string, data: any) => {
|
||||||
set({ isLoading: true, error: null });
|
const key = getEncKey();
|
||||||
|
if (!key) {
|
||||||
|
set({ error: 'No encryption key — cannot encrypt data' });
|
||||||
|
throw new Error('No encryption key');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const updated = await apiService.updateHealthStat(id, data);
|
const d = data as any;
|
||||||
|
const encrypted_data = await encryptJson({
|
||||||
|
stat_type: d.stat_type,
|
||||||
|
value: d.value,
|
||||||
|
unit: d.unit,
|
||||||
|
notes: d.notes,
|
||||||
|
}, key);
|
||||||
|
await apiService.updateHealthStat(id, { encrypted_data });
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
stats: state.stats.map((stat) =>
|
stats: state.stats.map((s) =>
|
||||||
stat.stat_id === id ? updated : stat
|
s.stat_id === id ? { ...s, ...d } : s,
|
||||||
),
|
),
|
||||||
isLoading: false,
|
|
||||||
}));
|
}));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
set({
|
set({ error: error.message || 'Failed to update stat' });
|
||||||
error: error.message || 'Failed to update stat',
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteStat: async (id: string) => {
|
deleteStat: async (id: string) => {
|
||||||
set({ isLoading: true, error: null });
|
|
||||||
try {
|
try {
|
||||||
await apiService.deleteHealthStat(id);
|
await apiService.deleteHealthStat(id);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
stats: state.stats.filter((stat) => stat.stat_id !== id),
|
stats: state.stats.filter((s) => s.stat_id !== id),
|
||||||
isLoading: false,
|
|
||||||
}));
|
}));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
set({
|
set({ error: error.message || 'Failed to delete stat' });
|
||||||
error: error.message || 'Failed to delete stat',
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
loadTrends: async () => {
|
loadTrends: async () => {
|
||||||
set({ isLoading: true, error: null });
|
// Trends are now computed client-side in loadStats.
|
||||||
try {
|
const computedTrends = computeTrends(get().stats);
|
||||||
const trends = await apiService.getHealthTrends();
|
set({ trends: computedTrends });
|
||||||
set({ trends, isLoading: false });
|
|
||||||
} catch (error: any) {
|
|
||||||
set({
|
|
||||||
error: error.message || 'Failed to load trends',
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Compute trend data from decrypted stats (client-side, since the server
|
||||||
|
* can't compute trends on ciphertext). */
|
||||||
|
function computeTrends(stats: HealthStat[]): any[] {
|
||||||
|
const byType: Record<string, HealthStat[]> = {};
|
||||||
|
for (const s of stats) {
|
||||||
|
const key = String(s.stat_type);
|
||||||
|
(byType[key] ??= []).push(s);
|
||||||
|
}
|
||||||
|
const trends: any[] = [];
|
||||||
|
for (const [type, items] of Object.entries(byType)) {
|
||||||
|
const values = items.map((s) => s.value);
|
||||||
|
if (values.length === 0) continue;
|
||||||
|
const avg = values.reduce((a, b) => a + b, 0) / values.length;
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
trends.push({
|
||||||
|
stat_type: type,
|
||||||
|
average: avg,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
trend: 'stable' as const,
|
||||||
|
data_points: items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return trends;
|
||||||
|
}
|
||||||
|
|
||||||
// Interaction Store (Phase 2.8)
|
// Interaction Store (Phase 2.8)
|
||||||
export const useInteractionStore = create<InteractionState>()(
|
export const useInteractionStore = create<InteractionState>()(
|
||||||
devtools((set, get) => ({
|
devtools((set, get) => ({
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ export interface Medication {
|
||||||
notes?: string;
|
notes?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
dose_schedule?: DoseSchedule;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -150,6 +151,7 @@ export interface MedicationWireResponse {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
dose_schedule?: DoseSchedule;
|
||||||
encrypted_data: EncryptedFieldWire;
|
encrypted_data: EncryptedFieldWire;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|
@ -160,11 +162,13 @@ export interface CreateMedicationRequest {
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
encrypted_data: EncryptedFieldWire;
|
encrypted_data: EncryptedFieldWire;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
dose_schedule?: DoseSchedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMedicationRequest {
|
export interface UpdateMedicationRequest {
|
||||||
encrypted_data?: EncryptedFieldWire;
|
encrypted_data?: EncryptedFieldWire;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
dose_schedule?: DoseSchedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drug Interaction Types (Phase 2.8)
|
// Drug Interaction Types (Phase 2.8)
|
||||||
|
|
@ -204,24 +208,32 @@ export enum HealthStatType {
|
||||||
Other = 'other'
|
Other = 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Domain type (decrypted, used by UI)
|
||||||
export interface HealthStat {
|
export interface HealthStat {
|
||||||
stat_id?: string;
|
stat_id?: string;
|
||||||
user_id: string;
|
user_id?: string;
|
||||||
stat_type: HealthStatType;
|
stat_type: HealthStatType;
|
||||||
value: number;
|
value: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
measured_at: string;
|
measured_at: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
created_at?: string;
|
}
|
||||||
updated_at?: string;
|
|
||||||
|
// Wire response (opaque blob from the server)
|
||||||
|
export interface HealthStatWireResponse {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
encrypted_data: EncryptedFieldWire;
|
||||||
|
recorded_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateHealthStatRequest {
|
export interface CreateHealthStatRequest {
|
||||||
stat_type: HealthStatType;
|
encrypted_data: EncryptedFieldWire;
|
||||||
value: number;
|
recorded_at?: string;
|
||||||
unit: string;
|
}
|
||||||
measured_at: string;
|
|
||||||
notes?: string;
|
export interface UpdateHealthStatRequest {
|
||||||
|
encrypted_data: EncryptedFieldWire;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrendData {
|
export interface TrendData {
|
||||||
|
|
@ -233,6 +245,12 @@ export interface TrendData {
|
||||||
data_points: HealthStat[];
|
data_points: HealthStat[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dose schedule (plaintext metadata for adherence calc)
|
||||||
|
export interface DoseSchedule {
|
||||||
|
times_per_day: number;
|
||||||
|
days_of_week?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
// Lab Results Types
|
// Lab Results Types
|
||||||
export interface LabResult {
|
export interface LabResult {
|
||||||
lab_id?: string;
|
lab_id?: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue