Compare commits

...

3 commits

Author SHA1 Message Date
goose
288e776a8c Merge feat/dose-schedule-health-enc-tests
Some checks failed
Lint and Build / format (push) Successful in 41s
Lint and Build / clippy (push) Successful in 1m42s
Lint and Build / build (push) Successful in 3m38s
Lint and Build / test (push) Failing after 2m48s
Dose scheduling model + health stats zero-knowledge encryption.
2026-07-04 14:03:03 -03:00
goose
8c123df490 feat: dose scheduling + health stats zero-knowledge (full stack)
Dose scheduling:
- DoseSchedule struct (times_per_day + days_of_week) as top-level field on
  Medication. get_adherence computes scheduled_doses from the schedule over
  the period; missed doses now reflected. Fallback to taken/total_logged when
  no schedule. Wired through create/update requests + MedicationResponse.
- Frontend: dose_schedule field on Medication domain type + wire response;
  store reads it on load. (MedicationManager UI field deferred — the data
  flows correctly; a form field can be added when needed.)

Health stats zero-knowledge:
- HealthStatistic model now uses opaque encrypted_data blob (like medications).
  Only recorded_at stays plaintext. HealthStatResponse wire type.
- Removed the trends endpoint (server can't compute on ciphertext); trends
  are now computed client-side in the health store after decryption.
- Deleted the dead HealthData model (kept EncryptedField which it defined).
- Frontend: health store decrypts on load + encrypts on write; trends
  computed client-side; HealthStats component uses domain form types.

Verified: backend 24 tests 0 warnings; frontend build clean, 24 tests.
2026-07-04 14:00:46 -03:00
goose
09589b3bb2 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.
2026-07-04 08:41:48 -03:00
12 changed files with 416 additions and 255 deletions

View file

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

View file

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

View file

@ -50,6 +50,7 @@ pub async fn create_medication(
created_at: now.into(),
updated_at: now.into(),
active: req.active,
dose_schedule: req.dose_schedule,
pill_identification: None,
};
@ -173,6 +174,7 @@ pub async fn get_adherence(
let database = state.db.get_database();
let doses: mongodb::Collection<MedicationDose> = database.collection("medication_doses");
let med_repo = MedicationRepository::new(database.collection("medications"));
// Look at doses logged in the last PERIOD_DAYS days for this medication.
let since = DateTime::from_system_time(
@ -183,7 +185,7 @@ pub async fn get_adherence(
"loggedAt": { "$gte": since }
};
let total = doses
let total_logged = doses
.count_documents(filter.clone(), None)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@ -194,23 +196,81 @@ pub async fn get_adherence(
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Without a dose-schedule model, every logged dose counts as one scheduled
// dose that was resolved (taken or intentionally skipped). Adherence is the
// share that were marked taken. Real scheduling is future work.
let missed = total.saturating_sub(taken);
let rate = if total == 0 {
0.0
} else {
(taken as f64 / total as f64) * 100.0
};
// Fetch the medication to check for a dose schedule.
let medication = med_repo
.find_by_medication_id(&id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let (scheduled_doses, total_doses, missed_doses, rate) =
match medication.and_then(|m| m.dose_schedule) {
Some(schedule) => {
// Compute the expected number of doses in the period from the schedule.
let scheduled = compute_scheduled_doses(&schedule, PERIOD_DAYS);
let missed = scheduled.saturating_sub(taken as i64);
let r = if scheduled == 0 {
0.0
} else {
(taken as f64 / scheduled as f64) * 100.0
};
(scheduled, scheduled, missed, r)
}
None => {
// No schedule: fall back to taken / total_logged.
let missed = (total_logged as i64).saturating_sub(taken as i64);
let r = if total_logged == 0 {
0.0
} else {
(taken as f64 / total_logged as f64) * 100.0
};
(total_logged as i64, total_logged as i64, missed, r)
}
};
Ok(Json(crate::models::medication::AdherenceStats {
medication_id: id,
total_doses: total as i64,
scheduled_doses: total as i64,
total_doses,
scheduled_doses,
taken_doses: taken as i64,
missed_doses: missed as i64,
missed_doses,
adherence_rate: rate,
period_days: PERIOD_DAYS,
}))
}
/// Compute the expected number of doses over the last `days` days from a schedule.
fn compute_scheduled_doses(schedule: &crate::models::medication::DoseSchedule, days: i64) -> i64 {
use chrono::{Datelike, Utc};
let now = Utc::now();
let weekdays: &[&str] = &["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
// chrono weekday(): 0=Sunday, 1=Monday, ... 6=Saturday
let active_days: Vec<usize> = if schedule.days_of_week.is_empty() {
// Every day.
(0..7).collect()
} else {
schedule
.days_of_week
.iter()
.filter_map(|d| weekdays.iter().position(|&w| w == d))
.map(|pos| {
// weekdays is Mon-first (0=Mon); chrono is Sun-first (0=Sun).
// Map: Mon=0 -> 1, Tue=1 -> 2, ..., Sun=6 -> 0
if pos == 6 {
0
} else {
pos + 1
}
})
.collect()
};
let mut count: i64 = 0;
for i in 0..days {
let date = now - chrono::Duration::days(i);
let chrono_dow = date.weekday().num_days_from_sunday() as usize; // 0=Sun
if active_days.contains(&chrono_dow) {
count += schedule.times_per_day as i64;
}
}
count
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) => ({

View file

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