diff --git a/backend/src/handlers/medications.rs b/backend/src/handlers/medications.rs index 1aed58b..54bbbcb 100644 --- a/backend/src/handlers/medications.rs +++ b/backend/src/handlers/medications.rs @@ -2,6 +2,7 @@ use axum::{ extract::{Extension, Json, Path, Query, State}, http::StatusCode, }; +use mongodb::bson::oid::ObjectId; use std::time::SystemTime; use crate::{ @@ -9,7 +10,7 @@ use crate::{ config::AppState, models::medication::{ CreateMedicationRequest, LogDoseRequest, Medication, MedicationDose, MedicationRepository, - MedicationResponse, UpdateMedicationRequest, + UpdateMedicationRequest, }, }; @@ -24,7 +25,7 @@ pub async fn create_medication( State(state): State, Extension(claims): Extension, Json(req): Json, -) -> Result, StatusCode> { +) -> Result, StatusCode> { let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); @@ -76,7 +77,7 @@ pub async fn create_medication( }; match repo.create(medication).await { - Ok(med) => Ok(Json(med.into())), + Ok(med) => Ok(Json(med)), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } @@ -85,17 +86,14 @@ pub async fn list_medications( State(state): State, Extension(claims): Extension, Query(query): Query, -) -> Result>, StatusCode> { +) -> Result>, StatusCode> { let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); let _limit = query.limit.unwrap_or(100); match repo.find_by_user(&claims.sub).await { - Ok(medications) => { - let resp: Vec = medications.into_iter().map(Into::into).collect(); - Ok(Json(resp)) - } + Ok(medications) => Ok(Json(medications)), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } @@ -104,16 +102,17 @@ pub async fn get_medication( State(state): State, Extension(_claims): Extension, Path(id): Path, -) -> Result, StatusCode> { +) -> Result, StatusCode> { let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); - // The path param is the application-level medication_id (a UUID), not the - // Mongo _id, so look it up directly instead of parsing as ObjectId. - match repo.find_by_medication_id(&id).await { - Ok(Some(medication)) => Ok(Json(medication.into())), - Ok(None) => Err(StatusCode::NOT_FOUND), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + match ObjectId::parse_str(&id) { + Ok(oid) => match repo.find_by_id(&oid).await { + Ok(Some(medication)) => Ok(Json(medication)), + Ok(None) => Err(StatusCode::NOT_FOUND), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + }, + Err(_) => Err(StatusCode::BAD_REQUEST), } } @@ -122,15 +121,17 @@ pub async fn update_medication( Extension(_claims): Extension, Path(id): Path, Json(req): Json, -) -> Result, StatusCode> { +) -> Result, StatusCode> { let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); - // Look up by medication_id (UUID) — the path param is not a Mongo ObjectId. - match repo.update_by_medication_id(&id, req).await { - Ok(Some(medication)) => Ok(Json(medication.into())), - Ok(None) => Err(StatusCode::NOT_FOUND), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + match ObjectId::parse_str(&id) { + Ok(oid) => match repo.update(&oid, req).await { + Ok(Some(medication)) => Ok(Json(medication)), + Ok(None) => Err(StatusCode::NOT_FOUND), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + }, + Err(_) => Err(StatusCode::BAD_REQUEST), } } @@ -142,11 +143,13 @@ pub async fn delete_medication( let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); - // Look up by medication_id (UUID), not Mongo _id. - match repo.delete_by_medication_id(&id).await { - Ok(true) => Ok(StatusCode::NO_CONTENT), - Ok(false) => Err(StatusCode::NOT_FOUND), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + match ObjectId::parse_str(&id) { + Ok(oid) => match repo.delete(&oid).await { + Ok(true) => Ok(StatusCode::NO_CONTENT), + Ok(false) => Err(StatusCode::NOT_FOUND), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + }, + Err(_) => Err(StatusCode::BAD_REQUEST), } } diff --git a/backend/src/models/medication.rs b/backend/src/models/medication.rs index b39f642..977d66c 100644 --- a/backend/src/models/medication.rs +++ b/backend/src/models/medication.rs @@ -96,104 +96,6 @@ pub struct AdherenceStats { // MEDICATION MODEL (Existing + Phase 2.8 updates) // ============================================================================ -/// The structured content stored (as a JSON string) inside `medication_data.data`. -/// Deserializes from the camelCase keys the create handler packs in. This is the -/// intermediate type used to flatten a `Medication` into a `MedicationResponse`. -#[derive(Debug, Clone, Deserialize, Default)] -pub struct MedicationData { - pub name: String, - pub dosage: String, - pub frequency: String, - pub route: String, - pub reason: Option, - pub instructions: Option, - #[serde(rename = "sideEffects", default)] - pub side_effects: Vec, - #[serde(rename = "prescribedBy")] - pub prescribed_by: Option, - #[serde(rename = "prescribedDate")] - pub prescribed_date: Option, - #[serde(rename = "startDate")] - pub start_date: Option, - #[serde(rename = "endDate")] - pub end_date: Option, - pub notes: Option, - #[serde(default)] - pub tags: Vec, -} - -/// The flat, snake_case medication representation returned by the API. Built from -/// a stored `Medication` by deserializing its `medication_data.data` blob. -/// -/// NOTE: `active` is synthesized as `true` for now — the storage model does not -/// track active/inactive (TODO). Timestamps are ISO 8601 strings. -#[derive(Debug, Serialize)] -pub struct MedicationResponse { - pub id: String, - pub medication_id: String, - pub user_id: String, - pub profile_id: String, - pub name: String, - pub dosage: String, - pub frequency: String, - pub route: String, - pub reason: Option, - pub instructions: Option, - pub side_effects: Vec, - pub prescribed_by: Option, - pub prescribed_date: Option, - pub start_date: Option, - pub end_date: Option, - pub notes: Option, - pub tags: Vec, - pub active: bool, - pub created_at: String, - pub updated_at: String, -} - -impl std::convert::From for MedicationResponse { - fn from(m: Medication) -> Self { - // The stored data is a JSON string; tolerate old/malformed rows by - // falling back to empty defaults rather than failing the whole response. - let data: MedicationData = - serde_json::from_str(&m.medication_data.data).unwrap_or_default(); - - MedicationResponse { - id: m.id.map(|o| o.to_hex()).unwrap_or_default(), - medication_id: m.medication_id, - user_id: m.user_id, - profile_id: m.profile_id, - name: data.name, - dosage: data.dosage, - frequency: data.frequency, - route: data.route, - reason: data.reason, - instructions: data.instructions, - side_effects: data.side_effects, - prescribed_by: data.prescribed_by, - prescribed_date: data.prescribed_date, - start_date: data.start_date, - end_date: data.end_date, - notes: data.notes, - tags: data.tags, - active: true, // TODO: persist a real active flag. - created_at: system_time_to_rfc3339(m.created_at.to_system_time()), - updated_at: system_time_to_rfc3339(m.updated_at.to_system_time()), - } - } -} - -/// Format a SystemTime as an ISO 8601 (RFC 3339) string. -fn system_time_to_rfc3339(t: std::time::SystemTime) -> String { - let secs = t - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - chrono::DateTime::from_timestamp(secs, 0) - .map(|dt| dt.to_rfc3339()) - .unwrap_or_default() -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Medication { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] @@ -362,208 +264,64 @@ impl MedicationRepository { Ok(medication) } - /// Look up by the application-level medication_id (a UUID string), which is - /// what the API exposes in URLs. (find_by_id filters on the Mongo _id.) - pub async fn find_by_medication_id( - &self, - medication_id: &str, - ) -> Result, Box> { - let filter = doc! { "medicationId": medication_id }; - let medication = self.collection.find_one(filter, None).await?; - Ok(medication) - } - - /// Update by the application-level medication_id (UUID). The API exposes - /// medication_id in URLs, not the Mongo _id. - pub async fn update_by_medication_id( - &self, - medication_id: &str, - updates: UpdateMedicationRequest, - ) -> Result, Box> { - let filter = doc! { "medicationId": medication_id }; - let existing = self.collection.find_one(filter.clone(), None).await?; - let Some(existing) = existing else { - return Ok(None); - }; - - let mut data: MedicationData = - serde_json::from_str(&existing.medication_data.data).unwrap_or_default(); - - if let Some(v) = updates.name { - data.name = v; - } - if let Some(v) = updates.dosage { - data.dosage = v; - } - if let Some(v) = updates.frequency { - data.frequency = v; - } - if let Some(v) = updates.route { - data.route = v; - } - if let Some(v) = updates.reason { - data.reason = Some(v); - } - if let Some(v) = updates.instructions { - data.instructions = Some(v); - } - if let Some(v) = updates.side_effects { - data.side_effects = v; - } - if let Some(v) = updates.prescribed_by { - data.prescribed_by = Some(v); - } - if let Some(v) = updates.prescribed_date { - data.prescribed_date = Some(v); - } - if let Some(v) = updates.start_date { - data.start_date = Some(v); - } - if let Some(v) = updates.end_date { - data.end_date = Some(v); - } - if let Some(v) = updates.notes { - data.notes = Some(v); - } - if let Some(v) = updates.tags { - data.tags = v; - } - // NOTE: updates.active is accepted by the API but not yet persisted. - - let data_json = serde_json::json!({ - "name": data.name, - "dosage": data.dosage, - "frequency": data.frequency, - "route": data.route, - "reason": data.reason, - "instructions": data.instructions, - "sideEffects": data.side_effects, - "prescribedBy": data.prescribed_by, - "prescribedDate": data.prescribed_date, - "startDate": data.start_date, - "endDate": data.end_date, - "notes": data.notes, - "tags": data.tags, - }) - .to_string(); - - let mut update_doc = doc! { - "medicationData.data": data_json, - "updatedAt": DateTime::now(), - }; - if let Some(pill_identification) = updates.pill_identification { - if let Ok(pill_doc) = mongodb::bson::to_document(&pill_identification) { - update_doc.insert("pill_identification", pill_doc); - } - } - - let medication = self - .collection - .find_one_and_update(filter, doc! { "$set": update_doc }, None) - .await?; - Ok(medication) - } - - /// Delete by the application-level medication_id (UUID). - pub async fn delete_by_medication_id( - &self, - medication_id: &str, - ) -> Result> { - let filter = doc! { "medicationId": medication_id }; - let result = self.collection.delete_one(filter, None).await?; - Ok(result.deleted_count > 0) - } - pub async fn update( &self, id: &ObjectId, updates: UpdateMedicationRequest, ) -> Result, Box> { - // The stored shape is `medicationData: { data: "", ... }`. - // The previous implementation used dot-notation ("medicationData.name") - // which wrote into phantom paths. Instead: load the existing document, - // deserialize its data blob, apply the overrides, re-serialize, and - // $set the whole `medicationData.data` string. - let filter = doc! { "_id": id }; - let existing = self.collection.find_one(filter.clone(), None).await?; - let Some(existing) = existing else { - return Ok(None); - }; + let mut update_doc = doc! {}; - let mut data: MedicationData = - serde_json::from_str(&existing.medication_data.data).unwrap_or_default(); - - if let Some(v) = updates.name { - data.name = v; + if let Some(name) = updates.name { + update_doc.insert("medicationData.name", name); } - if let Some(v) = updates.dosage { - data.dosage = v; + if let Some(dosage) = updates.dosage { + update_doc.insert("medicationData.dosage", dosage); } - if let Some(v) = updates.frequency { - data.frequency = v; + if let Some(frequency) = updates.frequency { + update_doc.insert("medicationData.frequency", frequency); } - if let Some(v) = updates.route { - data.route = v; + if let Some(route) = updates.route { + update_doc.insert("medicationData.route", route); } - if let Some(v) = updates.reason { - data.reason = Some(v); + if let Some(reason) = updates.reason { + update_doc.insert("medicationData.reason", reason); } - if let Some(v) = updates.instructions { - data.instructions = Some(v); + if let Some(instructions) = updates.instructions { + update_doc.insert("medicationData.instructions", instructions); } - if let Some(v) = updates.side_effects { - data.side_effects = v; + if let Some(side_effects) = updates.side_effects { + update_doc.insert("medicationData.sideEffects", side_effects); } - if let Some(v) = updates.prescribed_by { - data.prescribed_by = Some(v); + if let Some(prescribed_by) = updates.prescribed_by { + update_doc.insert("medicationData.prescribedBy", prescribed_by); } - if let Some(v) = updates.prescribed_date { - data.prescribed_date = Some(v); + if let Some(prescribed_date) = updates.prescribed_date { + update_doc.insert("medicationData.prescribedDate", prescribed_date); } - if let Some(v) = updates.start_date { - data.start_date = Some(v); + if let Some(start_date) = updates.start_date { + update_doc.insert("medicationData.startDate", start_date); } - if let Some(v) = updates.end_date { - data.end_date = Some(v); + if let Some(end_date) = updates.end_date { + update_doc.insert("medicationData.endDate", end_date); } - if let Some(v) = updates.notes { - data.notes = Some(v); + if let Some(notes) = updates.notes { + update_doc.insert("medicationData.notes", notes); } - if let Some(v) = updates.tags { - data.tags = v; + if let Some(tags) = updates.tags { + update_doc.insert("medicationData.tags", tags); + } + if let Some(reminder_times) = updates.reminder_times { + update_doc.insert("reminderTimes", reminder_times); } - // NOTE: updates.active is accepted by the API but not yet persisted — - // the storage model has no active column (TODO). - - // Re-serialize with the SAME camelCase keys the create handler uses, so - // reads stay consistent. - let data_json = serde_json::json!({ - "name": data.name, - "dosage": data.dosage, - "frequency": data.frequency, - "route": data.route, - "reason": data.reason, - "instructions": data.instructions, - "sideEffects": data.side_effects, - "prescribedBy": data.prescribed_by, - "prescribedDate": data.prescribed_date, - "startDate": data.start_date, - "endDate": data.end_date, - "notes": data.notes, - "tags": data.tags, - }) - .to_string(); - - let mut update_doc = doc! { - "medicationData.data": data_json, - "updatedAt": DateTime::now(), - }; if let Some(pill_identification) = updates.pill_identification { if let Ok(pill_doc) = mongodb::bson::to_document(&pill_identification) { - update_doc.insert("pill_identification", pill_doc); + update_doc.insert("pillIdentification", pill_doc); } } + update_doc.insert("updatedAt", mongodb::bson::DateTime::now()); + + let filter = doc! { "_id": id }; let medication = self .collection .find_one_and_update(filter, doc! { "$set": update_doc }, None) @@ -581,104 +339,3 @@ impl MedicationRepository { // querying the medication_doses collection directly (it needs a different // collection than the medications this repository wraps). } - -#[cfg(test)] -mod tests { - use super::*; - use crate::models::health_data::EncryptedField; - - #[test] - fn medication_data_deserializes_camel_case_blob() { - // This is the exact shape the create handler packs into the data string. - let json = r#"{ - "name": "Ibuprofen", - "dosage": "200mg", - "frequency": "as needed", - "route": "oral", - "reason": null, - "instructions": "with food", - "sideEffects": ["nausea"], - "prescribedBy": "Dr. House", - "prescribedDate": "2026-01-01", - "startDate": "2026-01-02", - "endDate": null, - "notes": "take care", - "tags": ["pain"] - }"#; - let data: MedicationData = serde_json::from_str(json).unwrap(); - assert_eq!(data.name, "Ibuprofen"); - assert_eq!(data.side_effects, vec!["nausea".to_string()]); - assert_eq!(data.prescribed_by.as_deref(), Some("Dr. House")); - assert_eq!(data.tags, vec!["pain".to_string()]); - assert_eq!(data.route, "oral"); - } - - #[test] - fn medication_data_defaults_empty_arrays_for_missing_keys() { - // Old rows may lack sideEffects/tags; the #[default] should yield empty vecs. - let json = r#"{"name":"X","dosage":"1","frequency":"daily","route":"oral"}"#; - let data: MedicationData = serde_json::from_str(json).unwrap(); - assert!(data.side_effects.is_empty()); - assert!(data.tags.is_empty()); - } - - #[test] - fn medication_response_flattens_and_uses_snake_case_fields() { - let blob = serde_json::json!({ - "name": "Aspirin", "dosage": "100mg", "frequency": "daily", "route": "oral", - "reason": null, "instructions": null, - "sideEffects": ["bleeding"], "prescribedBy": null, "prescribedDate": null, - "startDate": null, "endDate": null, "notes": null, "tags": [] - }) - .to_string(); - - let med = Medication { - id: Some(ObjectId::new()), - medication_id: "med-123".to_string(), - user_id: "user-1".to_string(), - profile_id: "profile_user-1".to_string(), - medication_data: EncryptedField { - data: blob, - encrypted: false, - iv: String::new(), - auth_tag: String::new(), - }, - reminders: vec![], - created_at: DateTime::now(), - updated_at: DateTime::now(), - pill_identification: None, - }; - - let resp: MedicationResponse = med.into(); - assert_eq!(resp.medication_id, "med-123"); - assert_eq!(resp.name, "Aspirin"); - assert_eq!(resp.side_effects, vec!["bleeding".to_string()]); - assert!(resp.active); - assert!(!resp.id.is_empty()); - assert!(!resp.created_at.is_empty()); // ISO string - } - - #[test] - fn medication_response_tolerates_malformed_data_blob() { - let med = Medication { - id: None, - medication_id: "m".to_string(), - user_id: "u".to_string(), - profile_id: "p".to_string(), - medication_data: EncryptedField { - data: "not valid json".to_string(), - encrypted: false, - iv: String::new(), - auth_tag: String::new(), - }, - reminders: vec![], - created_at: DateTime::now(), - updated_at: DateTime::now(), - pill_identification: None, - }; - // Should NOT panic — falls back to defaults. - let resp: MedicationResponse = med.into(); - assert_eq!(resp.name, ""); - assert!(resp.active); - } -} diff --git a/web/normogen-web/src/types/api.ts b/web/normogen-web/src/types/api.ts index 1b5711d..25debf2 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -111,23 +111,14 @@ export interface PillIdentification { } export interface Medication { - id?: string; medication_id?: string; - user_id?: string; - profile_id?: string; + user_id: string; name: string; dosage: string; frequency: string; - route?: string; - reason?: string; - instructions?: string; - side_effects?: string[]; - prescribed_by?: string; - prescribed_date?: string; start_date?: string; end_date?: string; - notes?: string; - tags?: string[]; + instructions?: string; active: boolean; pill_identification?: PillIdentification; created_at?: string;