Compare commits
3 commits
69faa43a72
...
288e776a8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
288e776a8c | ||
|
|
8c123df490 | ||
|
|
09589b3bb2 |
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)
|
||||
.route("/api/health-stats", post(handlers::create_health_stat))
|
||||
.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", put(handlers::update_health_stat))
|
||||
.route("/api/health-stats/:id", delete(handlers::delete_health_stat))
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
use crate::auth::jwt::Claims;
|
||||
use crate::config::AppState;
|
||||
use crate::models::health_stats::HealthStatistic;
|
||||
use crate::models::health_stats::{HealthStatResponse, HealthStatistic};
|
||||
use crate::models::medication::EncryptedFieldWire;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
|
|
@ -12,24 +13,13 @@ use serde::Deserialize;
|
|||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateHealthStatRequest {
|
||||
pub stat_type: String,
|
||||
pub value: serde_json::Value, // Support complex values like blood pressure
|
||||
pub unit: String,
|
||||
pub notes: Option<String>,
|
||||
pub encrypted_data: EncryptedFieldWire,
|
||||
pub recorded_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateHealthStatRequest {
|
||||
pub value: Option<serde_json::Value>,
|
||||
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 encrypted_data: EncryptedFieldWire,
|
||||
}
|
||||
|
||||
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 {
|
||||
id: None,
|
||||
user_id: claims.sub.clone(),
|
||||
stat_type: req.stat_type,
|
||||
value: value_num,
|
||||
unit: req.unit,
|
||||
notes: req.notes,
|
||||
recorded_at: req.recorded_at.unwrap_or_else(|| {
|
||||
use chrono::Utc;
|
||||
Utc::now().to_rfc3339()
|
||||
}),
|
||||
encrypted_data: req.encrypted_data,
|
||||
recorded_at: req
|
||||
.recorded_at
|
||||
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339()),
|
||||
};
|
||||
|
||||
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) => {
|
||||
eprintln!("Error creating health stat: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to create health stat",
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
|
@ -99,15 +82,15 @@ pub async fn list_health_stats(
|
|||
}
|
||||
};
|
||||
match repo.find_by_user(&claims.sub).await {
|
||||
Ok(stats) => (StatusCode::OK, Json(stats)).into_response(),
|
||||
Err(e) => {
|
||||
eprintln!("Error fetching health stats: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to fetch health stats",
|
||||
)
|
||||
.into_response()
|
||||
Ok(stats) => {
|
||||
let resp: Vec<HealthStatResponse> = stats.into_iter().map(Into::into).collect();
|
||||
(StatusCode::OK, Json(resp)).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) {
|
||||
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 {
|
||||
|
|
@ -136,17 +125,18 @@ pub async fn get_health_stat(
|
|||
if stat.user_id != claims.sub {
|
||||
return (StatusCode::FORBIDDEN, "Access denied").into_response();
|
||||
}
|
||||
(StatusCode::OK, Json(stat)).into_response()
|
||||
}
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "Health stat not found").into_response(),
|
||||
Err(e) => {
|
||||
eprintln!("Error fetching health stat: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to fetch health stat",
|
||||
)
|
||||
.into_response()
|
||||
(StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
)
|
||||
.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) {
|
||||
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(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to fetch health stat",
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "invalid id" })),
|
||||
)
|
||||
.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();
|
||||
}
|
||||
|
||||
if let Some(value) = req.value {
|
||||
let value_num = match value {
|
||||
serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0),
|
||||
_ => 0.0,
|
||||
};
|
||||
stat.value = value_num;
|
||||
}
|
||||
if let Some(unit) = req.unit {
|
||||
stat.unit = unit;
|
||||
}
|
||||
if let Some(notes) = req.notes {
|
||||
stat.notes = Some(notes);
|
||||
}
|
||||
|
||||
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(),
|
||||
match repo
|
||||
.update_encrypted_data(&object_id, req.encrypted_data)
|
||||
.await
|
||||
{
|
||||
Ok(Some(updated)) => {
|
||||
(StatusCode::OK, Json(HealthStatResponse::from(updated))).into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "not found" })),
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to update health stat",
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
|
|
@ -229,16 +226,28 @@ pub async fn delete_health_stat(
|
|||
};
|
||||
let object_id = match ObjectId::parse_str(&id) {
|
||||
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 {
|
||||
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(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to fetch health stat",
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
|
@ -252,71 +261,8 @@ pub async fn delete_health_stat(
|
|||
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
||||
_ => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to delete health stat",
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
)
|
||||
.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(),
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,7 @@ pub use appointments::{
|
|||
pub use auth::{login, logout, recover_password, recovery_info, refresh, register};
|
||||
pub use health::{health_check, ready_check};
|
||||
pub use health_stats::{
|
||||
create_health_stat, delete_health_stat, get_health_stat, get_health_trends, list_health_stats,
|
||||
update_health_stat,
|
||||
create_health_stat, delete_health_stat, get_health_stat, list_health_stats, update_health_stat,
|
||||
};
|
||||
pub use interactions::{check_interactions, check_new_medication};
|
||||
pub use medications::{
|
||||
|
|
|
|||
|
|
@ -1,28 +1,8 @@
|
|||
use mongodb::bson::{oid::ObjectId, DateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthData {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
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,
|
||||
}
|
||||
|
||||
/// The storage-layer encrypted-field wrapper used by Medication and Appointment
|
||||
/// documents. NOTE: the `HealthData` model that previously used `Vec<EncryptedField>`
|
||||
/// was dead code (no handler/route/repository) and has been removed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EncryptedField {
|
||||
pub encrypted: bool,
|
||||
|
|
|
|||
|
|
@ -6,19 +6,42 @@ use mongodb::{
|
|||
};
|
||||
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)]
|
||||
pub struct HealthStatistic {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<ObjectId>,
|
||||
pub user_id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub stat_type: String,
|
||||
pub value: f64,
|
||||
pub unit: String,
|
||||
pub notes: Option<String>,
|
||||
/// Opaque client-encrypted blob (value, unit, type, notes inside).
|
||||
#[serde(rename = "encryptedData")]
|
||||
pub encrypted_data: EncryptedFieldWire,
|
||||
/// When the reading was taken (plaintext, filterable/sortable).
|
||||
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)]
|
||||
pub struct HealthStatisticsRepository {
|
||||
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 mut created = stat.clone();
|
||||
created.id = Some(result.inserted_id.as_object_id().unwrap());
|
||||
Ok(created)
|
||||
created.id = result.inserted_id.as_object_id();
|
||||
Ok(Some(created))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
pub async fn update_encrypted_data(
|
||||
&self,
|
||||
id: &ObjectId,
|
||||
stat: &HealthStatistic,
|
||||
encrypted_data: EncryptedFieldWire,
|
||||
) -> Result<Option<HealthStatistic>, MongoError> {
|
||||
let filter = doc! { "_id": id };
|
||||
self.collection.replace_one(filter, stat, None).await?;
|
||||
Ok(Some(stat.clone()))
|
||||
self.collection
|
||||
.find_one_and_update(
|
||||
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> {
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ pub struct MedicationResponse {
|
|||
pub user_id: String,
|
||||
pub profile_id: String,
|
||||
pub active: bool,
|
||||
/// Dose schedule (plaintext, for adherence calc).
|
||||
pub dose_schedule: Option<DoseSchedule>,
|
||||
/// Opaque client-encrypted blob (base64 ciphertext + iv). The server stores
|
||||
/// and returns this verbatim; only the client can decrypt it.
|
||||
pub encrypted_data: EncryptedFieldWire,
|
||||
|
|
@ -132,6 +134,7 @@ impl std::convert::From<Medication> for MedicationResponse {
|
|||
user_id: m.user_id,
|
||||
profile_id: m.profile_id,
|
||||
active: m.active,
|
||||
dose_schedule: m.dose_schedule,
|
||||
encrypted_data: EncryptedFieldWire {
|
||||
data: m.medication_data.data,
|
||||
iv: m.medication_data.iv,
|
||||
|
|
@ -154,6 +157,17 @@ fn system_time_to_rfc3339(t: std::time::SystemTime) -> String {
|
|||
.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)]
|
||||
pub struct Medication {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -176,6 +190,9 @@ pub struct Medication {
|
|||
/// documents that predate the field (see `default_true`).
|
||||
#[serde(rename = "active", default = "default_true")]
|
||||
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)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -226,6 +243,8 @@ pub struct CreateMedicationRequest {
|
|||
/// Whether the medication is active on creation (defaults to true).
|
||||
#[serde(default = "default_true")]
|
||||
pub active: bool,
|
||||
/// Dose schedule for adherence calculation (plaintext).
|
||||
pub dose_schedule: Option<DoseSchedule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -235,6 +254,8 @@ pub struct UpdateMedicationRequest {
|
|||
pub encrypted_data: Option<EncryptedFieldWire>,
|
||||
/// Set the medication active/inactive.
|
||||
pub active: Option<bool>,
|
||||
/// Update the dose schedule.
|
||||
pub dose_schedule: Option<Option<DoseSchedule>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -360,6 +381,19 @@ impl MedicationRepository {
|
|||
if let Some(active) = updates.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
|
||||
.collection
|
||||
|
|
@ -395,6 +429,19 @@ impl MedicationRepository {
|
|||
if let Some(active) = updates.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
|
||||
.collection
|
||||
|
|
@ -438,6 +485,7 @@ mod tests {
|
|||
created_at: DateTime::now(),
|
||||
updated_at: DateTime::now(),
|
||||
active: true,
|
||||
dose_schedule: None,
|
||||
pill_identification: None,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,10 @@ pub fn test_config(db_name: &str) -> Config {
|
|||
allowed_origins: vec!["http://localhost:3000".to_string()],
|
||||
},
|
||||
environment: Environment::Development,
|
||||
rate_limit: normogen_backend::config::RateLimitConfig {
|
||||
max_requests: 1000,
|
||||
window_secs: 60,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,16 @@ import {
|
|||
} from 'recharts';
|
||||
import { format } from 'date-fns';
|
||||
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);
|
||||
|
||||
|
|
@ -73,7 +82,7 @@ export const HealthStats: FC = () => {
|
|||
const [saving, setSaving] = useState(false);
|
||||
const [chartType, setChartType] = useState<HealthStatType>(HealthStatType.Weight);
|
||||
|
||||
const [form, setForm] = useState<CreateHealthStatRequest>({
|
||||
const [form, setForm] = useState<HealthFormData>({
|
||||
stat_type: HealthStatType.Weight,
|
||||
value: 0,
|
||||
unit: UNIT_DEFAULTS[HealthStatType.Weight],
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@ import {
|
|||
DrugInteraction,
|
||||
CheckInteractionRequest,
|
||||
CheckNewMedicationRequest,
|
||||
HealthStat,
|
||||
CreateHealthStatRequest,
|
||||
TrendData,
|
||||
ApiError,
|
||||
DoseLog,
|
||||
LogDoseRequest,
|
||||
|
|
@ -20,6 +18,8 @@ import {
|
|||
MedicationWireResponse,
|
||||
AppointmentWireResponse,
|
||||
ProfileWireResponse,
|
||||
HealthStatWireResponse,
|
||||
UpdateHealthStatRequest,
|
||||
EncryptedFieldWire,
|
||||
CreateAppointmentRequest,
|
||||
UpdateAppointmentRequest,
|
||||
|
|
@ -330,25 +330,25 @@ class ApiService {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
// ---- Health Statistics ----
|
||||
// ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ----
|
||||
|
||||
async getHealthStats(): Promise<HealthStat[]> {
|
||||
const response = await this.client.get<HealthStat[]>('/health-stats');
|
||||
async getHealthStats(): Promise<HealthStatWireResponse[]> {
|
||||
const response = await this.client.get<HealthStatWireResponse[]>('/health-stats');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getHealthStat(id: string): Promise<HealthStat> {
|
||||
const response = await this.client.get<HealthStat>(`/health-stats/${id}`);
|
||||
async getHealthStat(id: string): Promise<HealthStatWireResponse> {
|
||||
const response = await this.client.get<HealthStatWireResponse>(`/health-stats/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createHealthStat(data: CreateHealthStatRequest): Promise<HealthStat> {
|
||||
const response = await this.client.post<HealthStat>('/health-stats', data);
|
||||
async createHealthStat(data: CreateHealthStatRequest): Promise<HealthStatWireResponse> {
|
||||
const response = await this.client.post<HealthStatWireResponse>('/health-stats', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateHealthStat(id: string, data: Partial<CreateHealthStatRequest>): Promise<HealthStat> {
|
||||
const response = await this.client.put<HealthStat>(`/health-stats/${id}`, data);
|
||||
async updateHealthStat(id: string, data: UpdateHealthStatRequest): Promise<HealthStatWireResponse> {
|
||||
const response = await this.client.put<HealthStatWireResponse>(`/health-stats/${id}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
|
@ -356,11 +356,6 @@ class ApiService {
|
|||
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
|
||||
// /lab-results routes yet. The LabResult type stays in types/api.ts for when
|
||||
// those routes land.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
User,
|
||||
Medication,
|
||||
HealthStat,
|
||||
HealthStatType,
|
||||
DrugInteraction,
|
||||
AdherenceStats,
|
||||
Profile,
|
||||
|
|
@ -342,6 +343,7 @@ export const useMedicationStore = create<MedicationState>()(
|
|||
notes: data.notes as string | undefined,
|
||||
tags: data.tags as string[] | undefined,
|
||||
active: w.active,
|
||||
dose_schedule: (w as any).dose_schedule ?? undefined,
|
||||
created_at: w.created_at,
|
||||
updated_at: w.updated_at,
|
||||
} as Medication;
|
||||
|
|
@ -506,10 +508,31 @@ export const useHealthStore = create<HealthState>()(
|
|||
error: null,
|
||||
|
||||
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 });
|
||||
try {
|
||||
const stats = await apiService.getHealthStats();
|
||||
set({ stats, isLoading: false });
|
||||
const wireStats = await apiService.getHealthStats();
|
||||
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) {
|
||||
set({
|
||||
error: error.message || 'Failed to load health stats',
|
||||
|
|
@ -519,9 +542,33 @@ export const useHealthStore = create<HealthState>()(
|
|||
},
|
||||
|
||||
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 });
|
||||
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) => ({
|
||||
stats: [...state.stats, stat],
|
||||
isLoading: false,
|
||||
|
|
@ -536,58 +583,80 @@ export const useHealthStore = create<HealthState>()(
|
|||
},
|
||||
|
||||
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 {
|
||||
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) => ({
|
||||
stats: state.stats.map((stat) =>
|
||||
stat.stat_id === id ? updated : stat
|
||||
stats: state.stats.map((s) =>
|
||||
s.stat_id === id ? { ...s, ...d } : s,
|
||||
),
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to update stat',
|
||||
isLoading: false,
|
||||
});
|
||||
set({ error: error.message || 'Failed to update stat' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteStat: async (id: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await apiService.deleteHealthStat(id);
|
||||
set((state) => ({
|
||||
stats: state.stats.filter((stat) => stat.stat_id !== id),
|
||||
isLoading: false,
|
||||
stats: state.stats.filter((s) => s.stat_id !== id),
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to delete stat',
|
||||
isLoading: false,
|
||||
});
|
||||
set({ error: error.message || 'Failed to delete stat' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
loadTrends: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const trends = await apiService.getHealthTrends();
|
||||
set({ trends, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to load trends',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
// Trends are now computed client-side in loadStats.
|
||||
const computedTrends = computeTrends(get().stats);
|
||||
set({ trends: computedTrends });
|
||||
},
|
||||
|
||||
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)
|
||||
export const useInteractionStore = create<InteractionState>()(
|
||||
devtools((set, get) => ({
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ export interface Medication {
|
|||
notes?: string;
|
||||
tags?: string[];
|
||||
active: boolean;
|
||||
dose_schedule?: DoseSchedule;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
|
@ -150,6 +151,7 @@ export interface MedicationWireResponse {
|
|||
user_id: string;
|
||||
profile_id: string;
|
||||
active: boolean;
|
||||
dose_schedule?: DoseSchedule;
|
||||
encrypted_data: EncryptedFieldWire;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
|
@ -160,11 +162,13 @@ export interface CreateMedicationRequest {
|
|||
profile_id: string;
|
||||
encrypted_data: EncryptedFieldWire;
|
||||
active?: boolean;
|
||||
dose_schedule?: DoseSchedule;
|
||||
}
|
||||
|
||||
export interface UpdateMedicationRequest {
|
||||
encrypted_data?: EncryptedFieldWire;
|
||||
active?: boolean;
|
||||
dose_schedule?: DoseSchedule;
|
||||
}
|
||||
|
||||
// Drug Interaction Types (Phase 2.8)
|
||||
|
|
@ -204,24 +208,32 @@ export enum HealthStatType {
|
|||
Other = 'other'
|
||||
}
|
||||
|
||||
// Domain type (decrypted, used by UI)
|
||||
export interface HealthStat {
|
||||
stat_id?: string;
|
||||
user_id: string;
|
||||
user_id?: string;
|
||||
stat_type: HealthStatType;
|
||||
value: number;
|
||||
unit: string;
|
||||
measured_at: 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 {
|
||||
stat_type: HealthStatType;
|
||||
value: number;
|
||||
unit: string;
|
||||
measured_at: string;
|
||||
notes?: string;
|
||||
encrypted_data: EncryptedFieldWire;
|
||||
recorded_at?: string;
|
||||
}
|
||||
|
||||
export interface UpdateHealthStatRequest {
|
||||
encrypted_data: EncryptedFieldWire;
|
||||
}
|
||||
|
||||
export interface TrendData {
|
||||
|
|
@ -233,6 +245,12 @@ export interface TrendData {
|
|||
data_points: HealthStat[];
|
||||
}
|
||||
|
||||
// Dose schedule (plaintext metadata for adherence calc)
|
||||
export interface DoseSchedule {
|
||||
times_per_day: number;
|
||||
days_of_week?: string[];
|
||||
}
|
||||
|
||||
// Lab Results Types
|
||||
export interface LabResult {
|
||||
lab_id?: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue