use super::health_data::EncryptedField; use futures::StreamExt; use mongodb::bson::{doc, oid::ObjectId, DateTime}; use mongodb::Collection; use serde::{Deserialize, Serialize}; fn default_status() -> String { "upcoming".to_string() } /// Zero-knowledge wire response. The server returns ONLY metadata + the opaque /// encrypted data blob + the top-level `status` (filterable without decrypting). #[derive(Debug, Serialize)] pub struct AppointmentResponse { pub id: String, pub appointment_id: String, pub user_id: String, pub profile_id: String, pub status: String, pub encrypted_data: crate::models::medication::EncryptedFieldWire, pub created_at: String, pub updated_at: String, } impl std::convert::From for AppointmentResponse { fn from(a: Appointment) -> Self { AppointmentResponse { id: a.id.map(|o| o.to_hex()).unwrap_or_default(), appointment_id: a.appointment_id, user_id: a.user_id, profile_id: a.profile_id, status: a.status, encrypted_data: crate::models::medication::EncryptedFieldWire { data: a.appointment_data.data, iv: a.appointment_data.iv, auth_tag: a.appointment_data.auth_tag, }, created_at: system_time_to_rfc3339(a.created_at.to_system_time()), updated_at: system_time_to_rfc3339(a.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 Appointment { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] pub id: Option, #[serde(rename = "appointmentId")] pub appointment_id: String, #[serde(rename = "userId")] pub user_id: String, #[serde(rename = "profileId")] pub profile_id: String, #[serde(rename = "appointmentData")] pub appointment_data: EncryptedField, #[serde(rename = "reminders")] pub reminders: Vec, /// Top-level status so it can be filtered without decrypting the blob. #[serde(rename = "status", default = "default_status")] pub status: String, #[serde(rename = "createdAt")] pub created_at: DateTime, #[serde(rename = "updatedAt")] pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppointmentReminder { #[serde(rename = "reminderId")] pub reminder_id: String, #[serde(rename = "scheduledTime")] pub scheduled_time: String, } // ============================================================================ // REQUEST TYPES — opaque blobs; the client encrypts, the server stores verbatim. // ============================================================================ #[derive(Debug, Deserialize)] pub struct CreateAppointmentRequest { pub profile_id: String, pub encrypted_data: crate::models::medication::EncryptedFieldWire, /// Defaults to "upcoming". #[serde(default = "default_status")] pub status: String, } #[derive(Debug, Deserialize)] pub struct UpdateAppointmentRequest { pub encrypted_data: Option, pub status: Option, } // ============================================================================ // REPOSITORY // ============================================================================ #[derive(Clone)] pub struct AppointmentRepository { collection: Collection, } impl AppointmentRepository { pub fn new(collection: Collection) -> Self { Self { collection } } pub async fn create( &self, appointment: Appointment, ) -> Result> { self.collection .insert_one(appointment.clone(), None) .await?; Ok(appointment) } /// List a user's appointments, optionally filtered by top-level `status` /// and/or `profileId`. pub async fn find_by_user_filtered( &self, user_id: &str, status: Option<&str>, profile_id: Option<&str>, ) -> Result, Box> { let mut filter = doc! { "userId": user_id }; if let Some(s) = status { filter.insert("status", s); } if let Some(p) = profile_id { filter.insert("profileId", p); } let mut cursor = self.collection.find(filter, None).await?; let mut appointments = Vec::new(); while let Some(appt) = cursor.next().await { appointments.push(appt?); } Ok(appointments) } pub async fn find_by_appointment_id( &self, appointment_id: &str, ) -> Result, Box> { let filter = doc! { "appointmentId": appointment_id }; Ok(self.collection.find_one(filter, None).await?) } /// Whole-blob replace — the client re-encrypts and sends the new blob. pub async fn update_by_appointment_id( &self, appointment_id: &str, updates: UpdateAppointmentRequest, ) -> Result, Box> { let filter = doc! { "appointmentId": appointment_id }; let mut update_doc = doc! { "updatedAt": DateTime::now() }; if let Some(blob) = updates.encrypted_data { update_doc.insert("appointmentData.data", blob.data); update_doc.insert("appointmentData.iv", blob.iv); update_doc.insert("appointmentData.authTag", blob.auth_tag); } if let Some(status) = updates.status { update_doc.insert("status", status); } Ok(self .collection .find_one_and_update(filter, doc! { "$set": update_doc }, None) .await?) } pub async fn delete_by_appointment_id( &self, appointment_id: &str, ) -> Result> { let filter = doc! { "appointmentId": appointment_id }; let result = self.collection.delete_one(filter, None).await?; Ok(result.deleted_count > 0) } } #[cfg(test)] mod tests { use super::*; #[test] fn appointment_response_echoes_opaque_blob_and_status() { let appt = Appointment { id: Some(ObjectId::new()), appointment_id: "apt-1".to_string(), user_id: "user-1".to_string(), profile_id: "profile_user-1".to_string(), appointment_data: EncryptedField { data: "opaque-ciphertext".to_string(), encrypted: true, iv: "base64-iv".to_string(), auth_tag: String::new(), }, reminders: vec![], status: "completed".to_string(), created_at: DateTime::now(), updated_at: DateTime::now(), }; let resp: AppointmentResponse = appt.into(); assert_eq!(resp.appointment_id, "apt-1"); assert_eq!(resp.status, "completed"); assert_eq!(resp.encrypted_data.data, "opaque-ciphertext"); assert!(!resp.id.is_empty()); } }