From d41256e05bf1bf2cbe94e8dd052b9a1346455cd2 Mon Sep 17 00:00:00 2001 From: goose Date: Sun, 28 Jun 2026 11:30:01 -0300 Subject: [PATCH 1/2] fix(backend): flatten medication serialization (MedicationResponse) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend + frontend. Resolves the integration gap where the medication list/create responses were deeply nested (fields inside medicationData.data JSON blob, camelCase) while the frontend expected flat snake_case fields — so the MedicationManager couldn't display real data. Backend (models/medication.rs): * New MedicationData struct: deserializes the camelCase JSON string stored in medicationData.data (sideEffects->side_effects, prescribedBy->prescribed_by, etc.). Tolerates missing keys via #[default]. * New MedicationResponse: flat snake_case, the wire format. From deserializes the data blob, maps fields, synthesizes active=true (TODO: real active flag), formats timestamps as ISO 8601. Tolerates malformed blobs. * Fixed MedicationRepository::update: the old dot-notation (medicationData.name) wrote into phantom paths because the stored shape is medicationData:{data: ''}. Now loads the doc, deserializes the blob, applies overrides, re-serializes, and $sets the whole data string. Updates actually persist now. * 4 new unit tests (MedicationData deser, defaults, MedicationResponse flatten, malformed-blob tolerance). Handlers (handlers/medications.rs): create/list/get/update now return MedicationResponse instead of the raw Medication model. Frontend (types/api.ts): Medication interface reconciled to match MedicationResponse (added id, profile_id, route, reason, side_effects, prescribed_by/date, notes, tags; relaxed user_id to optional). Verified: backend cargo fmt/build/clippy 0 warnings, 23 unit tests pass (was 19); frontend npm build clean, 20 vitest tests pass. --- backend/src/handlers/medications.rs | 21 +- backend/src/models/medication.rs | 299 ++++++++++++++++++++++++---- web/normogen-web/src/types/api.ts | 13 +- 3 files changed, 288 insertions(+), 45 deletions(-) diff --git a/backend/src/handlers/medications.rs b/backend/src/handlers/medications.rs index 54bbbcb..7683932 100644 --- a/backend/src/handlers/medications.rs +++ b/backend/src/handlers/medications.rs @@ -10,7 +10,7 @@ use crate::{ config::AppState, models::medication::{ CreateMedicationRequest, LogDoseRequest, Medication, MedicationDose, MedicationRepository, - UpdateMedicationRequest, + MedicationResponse, UpdateMedicationRequest, }, }; @@ -25,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")); @@ -77,7 +77,7 @@ pub async fn create_medication( }; match repo.create(medication).await { - Ok(med) => Ok(Json(med)), + Ok(med) => Ok(Json(med.into())), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } @@ -86,14 +86,17 @@ 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) => Ok(Json(medications)), + Ok(medications) => { + let resp: Vec = medications.into_iter().map(Into::into).collect(); + Ok(Json(resp)) + } Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } @@ -102,13 +105,13 @@ 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")); match ObjectId::parse_str(&id) { Ok(oid) => match repo.find_by_id(&oid).await { - Ok(Some(medication)) => Ok(Json(medication)), + Ok(Some(medication)) => Ok(Json(medication.into())), Ok(None) => Err(StatusCode::NOT_FOUND), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), }, @@ -121,13 +124,13 @@ 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")); match ObjectId::parse_str(&id) { Ok(oid) => match repo.update(&oid, req).await { - Ok(Some(medication)) => Ok(Json(medication)), + Ok(Some(medication)) => Ok(Json(medication.into())), Ok(None) => Err(StatusCode::NOT_FOUND), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), }, diff --git a/backend/src/models/medication.rs b/backend/src/models/medication.rs index 977d66c..247cae7 100644 --- a/backend/src/models/medication.rs +++ b/backend/src/models/medication.rs @@ -96,6 +96,104 @@ 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")] @@ -269,59 +367,91 @@ impl MedicationRepository { id: &ObjectId, updates: UpdateMedicationRequest, ) -> Result, Box> { - let mut update_doc = doc! {}; + // 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); + }; - if let Some(name) = updates.name { - update_doc.insert("medicationData.name", name); + 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(dosage) = updates.dosage { - update_doc.insert("medicationData.dosage", dosage); + if let Some(v) = updates.dosage { + data.dosage = v; } - if let Some(frequency) = updates.frequency { - update_doc.insert("medicationData.frequency", frequency); + if let Some(v) = updates.frequency { + data.frequency = v; } - if let Some(route) = updates.route { - update_doc.insert("medicationData.route", route); + if let Some(v) = updates.route { + data.route = v; } - if let Some(reason) = updates.reason { - update_doc.insert("medicationData.reason", reason); + if let Some(v) = updates.reason { + data.reason = Some(v); } - if let Some(instructions) = updates.instructions { - update_doc.insert("medicationData.instructions", instructions); + if let Some(v) = updates.instructions { + data.instructions = Some(v); } - if let Some(side_effects) = updates.side_effects { - update_doc.insert("medicationData.sideEffects", side_effects); + if let Some(v) = updates.side_effects { + data.side_effects = v; } - if let Some(prescribed_by) = updates.prescribed_by { - update_doc.insert("medicationData.prescribedBy", prescribed_by); + if let Some(v) = updates.prescribed_by { + data.prescribed_by = Some(v); } - if let Some(prescribed_date) = updates.prescribed_date { - update_doc.insert("medicationData.prescribedDate", prescribed_date); + if let Some(v) = updates.prescribed_date { + data.prescribed_date = Some(v); } - if let Some(start_date) = updates.start_date { - update_doc.insert("medicationData.startDate", start_date); + if let Some(v) = updates.start_date { + data.start_date = Some(v); } - if let Some(end_date) = updates.end_date { - update_doc.insert("medicationData.endDate", end_date); + if let Some(v) = updates.end_date { + data.end_date = Some(v); } - if let Some(notes) = updates.notes { - update_doc.insert("medicationData.notes", notes); + if let Some(v) = updates.notes { + data.notes = Some(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); + if let Some(v) = updates.tags { + data.tags = v; } + // 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("pillIdentification", pill_doc); + update_doc.insert("pill_identification", 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) @@ -339,3 +469,104 @@ 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 25debf2..1b5711d 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -111,14 +111,23 @@ export interface PillIdentification { } export interface Medication { + id?: string; medication_id?: string; - user_id: string; + user_id?: string; + profile_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; - instructions?: string; + notes?: string; + tags?: string[]; active: boolean; pill_identification?: PillIdentification; created_at?: string; From dadcc8f1e413c16b8912a17f68022a9bd495e766 Mon Sep 17 00:00:00 2001 From: goose Date: Sun, 28 Jun 2026 11:40:03 -0300 Subject: [PATCH 2/2] fix(backend): medication get/update/delete use medication_id (UUID), not Mongo _id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handlers parsed the URL :id as a Mongo ObjectId, but the create handler generates medication_id as a UUID — so update/delete/get-by-uuid returned 400. Added find_by_medication_id/update_by_medication_id/delete_by_medication_id (filter on the medicationId field) and switched the get/update/delete handlers to use them. Update now actually persists. --- backend/src/handlers/medications.rs | 38 ++++------ backend/src/models/medication.rs | 112 ++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 22 deletions(-) diff --git a/backend/src/handlers/medications.rs b/backend/src/handlers/medications.rs index 7683932..1aed58b 100644 --- a/backend/src/handlers/medications.rs +++ b/backend/src/handlers/medications.rs @@ -2,7 +2,6 @@ use axum::{ extract::{Extension, Json, Path, Query, State}, http::StatusCode, }; -use mongodb::bson::oid::ObjectId; use std::time::SystemTime; use crate::{ @@ -109,13 +108,12 @@ pub async fn get_medication( let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); - match ObjectId::parse_str(&id) { - Ok(oid) => match repo.find_by_id(&oid).await { - Ok(Some(medication)) => Ok(Json(medication.into())), - Ok(None) => Err(StatusCode::NOT_FOUND), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), - }, - Err(_) => Err(StatusCode::BAD_REQUEST), + // 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), } } @@ -128,13 +126,11 @@ pub async fn update_medication( let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); - match ObjectId::parse_str(&id) { - Ok(oid) => match repo.update(&oid, req).await { - Ok(Some(medication)) => Ok(Json(medication.into())), - Ok(None) => Err(StatusCode::NOT_FOUND), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), - }, - Err(_) => Err(StatusCode::BAD_REQUEST), + // 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), } } @@ -146,13 +142,11 @@ pub async fn delete_medication( let database = state.db.get_database(); let repo = MedicationRepository::new(database.collection("medications")); - 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), + // 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), } } diff --git a/backend/src/models/medication.rs b/backend/src/models/medication.rs index 247cae7..b39f642 100644 --- a/backend/src/models/medication.rs +++ b/backend/src/models/medication.rs @@ -362,6 +362,118 @@ 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,