Merge fix/active-flag-and-appointments
Active-flag persistence for medications + full appointment feature (CRUD).
This commit is contained in:
commit
057303a8d0
12 changed files with 1075 additions and 14 deletions
|
|
@ -78,6 +78,12 @@ pub fn build_app(state: AppState) -> Router {
|
||||||
// Drug interactions (Phase 2.8)
|
// Drug interactions (Phase 2.8)
|
||||||
.route("/api/interactions/check", post(handlers::check_interactions))
|
.route("/api/interactions/check", post(handlers::check_interactions))
|
||||||
.route("/api/interactions/check-new", post(handlers::check_new_medication))
|
.route("/api/interactions/check-new", post(handlers::check_new_medication))
|
||||||
|
// Appointments
|
||||||
|
.route("/api/appointments", post(handlers::create_appointment))
|
||||||
|
.route("/api/appointments", get(handlers::list_appointments))
|
||||||
|
.route("/api/appointments/:id", get(handlers::get_appointment))
|
||||||
|
.route("/api/appointments/:id", post(handlers::update_appointment))
|
||||||
|
.route("/api/appointments/:id/delete", post(handlers::delete_appointment))
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
middleware::jwt_auth_middleware,
|
middleware::jwt_auth_middleware,
|
||||||
|
|
|
||||||
136
backend/src/handlers/appointments.rs
Normal file
136
backend/src/handlers/appointments.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Json, Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::jwt::Claims,
|
||||||
|
config::AppState,
|
||||||
|
models::appointment::{
|
||||||
|
AppointmentRepository, AppointmentResponse, CreateAppointmentRequest,
|
||||||
|
UpdateAppointmentRequest,
|
||||||
|
},
|
||||||
|
models::health_data::EncryptedField,
|
||||||
|
};
|
||||||
|
use mongodb::bson::DateTime;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AppointmentListQuery {
|
||||||
|
/// Filter by status (upcoming/completed/cancelled). Optional.
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_appointment(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(claims): Extension<Claims>,
|
||||||
|
Json(req): Json<CreateAppointmentRequest>,
|
||||||
|
) -> Result<Json<AppointmentResponse>, StatusCode> {
|
||||||
|
let database = state.db.get_database();
|
||||||
|
let repo = AppointmentRepository::new(database.collection("appointments"));
|
||||||
|
|
||||||
|
let now = SystemTime::now();
|
||||||
|
let appointment_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
// Pack the request fields into the data blob (camelCase keys).
|
||||||
|
let appointment_data = EncryptedField {
|
||||||
|
data: serde_json::json!({
|
||||||
|
"title": req.title,
|
||||||
|
"provider": req.provider,
|
||||||
|
"appointmentType": req.appointment_type,
|
||||||
|
"dateTime": req.date_time,
|
||||||
|
"location": req.location,
|
||||||
|
"durationMinutes": req.duration_minutes,
|
||||||
|
"reason": req.reason,
|
||||||
|
"notes": req.notes,
|
||||||
|
"status": req.status.unwrap_or_else(|| "upcoming".to_string()),
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
encrypted: false,
|
||||||
|
iv: String::new(),
|
||||||
|
auth_tag: String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let appointment = crate::models::appointment::Appointment {
|
||||||
|
id: None,
|
||||||
|
appointment_id,
|
||||||
|
user_id: claims.sub,
|
||||||
|
profile_id: req.profile_id.clone(),
|
||||||
|
appointment_data,
|
||||||
|
reminders: vec![],
|
||||||
|
created_at: DateTime::from_system_time(now),
|
||||||
|
updated_at: DateTime::from_system_time(now),
|
||||||
|
};
|
||||||
|
|
||||||
|
match repo.create(appointment).await {
|
||||||
|
Ok(appt) => Ok(Json(appt.into())),
|
||||||
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_appointments(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(claims): Extension<Claims>,
|
||||||
|
Query(query): Query<AppointmentListQuery>,
|
||||||
|
) -> Result<Json<Vec<AppointmentResponse>>, StatusCode> {
|
||||||
|
let database = state.db.get_database();
|
||||||
|
let repo = AppointmentRepository::new(database.collection("appointments"));
|
||||||
|
|
||||||
|
match repo
|
||||||
|
.find_by_user_filtered(&claims.sub, query.status.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(appointments) => {
|
||||||
|
let resp: Vec<AppointmentResponse> = appointments.into_iter().map(Into::into).collect();
|
||||||
|
Ok(Json(resp))
|
||||||
|
}
|
||||||
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_appointment(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(_claims): Extension<Claims>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<AppointmentResponse>, StatusCode> {
|
||||||
|
let database = state.db.get_database();
|
||||||
|
let repo = AppointmentRepository::new(database.collection("appointments"));
|
||||||
|
|
||||||
|
match repo.find_by_appointment_id(&id).await {
|
||||||
|
Ok(Some(appt)) => Ok(Json(appt.into())),
|
||||||
|
Ok(None) => Err(StatusCode::NOT_FOUND),
|
||||||
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_appointment(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(_claims): Extension<Claims>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(req): Json<UpdateAppointmentRequest>,
|
||||||
|
) -> Result<Json<AppointmentResponse>, StatusCode> {
|
||||||
|
let database = state.db.get_database();
|
||||||
|
let repo = AppointmentRepository::new(database.collection("appointments"));
|
||||||
|
|
||||||
|
match repo.update_by_appointment_id(&id, req).await {
|
||||||
|
Ok(Some(appt)) => Ok(Json(appt.into())),
|
||||||
|
Ok(None) => Err(StatusCode::NOT_FOUND),
|
||||||
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_appointment(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(_claims): Extension<Claims>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
let database = state.db.get_database();
|
||||||
|
let repo = AppointmentRepository::new(database.collection("appointments"));
|
||||||
|
|
||||||
|
match repo.delete_by_appointment_id(&id).await {
|
||||||
|
Ok(true) => Ok(StatusCode::NO_CONTENT),
|
||||||
|
Ok(false) => Err(StatusCode::NOT_FOUND),
|
||||||
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -72,6 +72,7 @@ pub async fn create_medication(
|
||||||
.collect(),
|
.collect(),
|
||||||
created_at: now.into(),
|
created_at: now.into(),
|
||||||
updated_at: now.into(),
|
updated_at: now.into(),
|
||||||
|
active: req.active,
|
||||||
pill_identification: req.pill_identification, // Phase 2.8
|
pill_identification: req.pill_identification, // Phase 2.8
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -89,9 +90,11 @@ pub async fn list_medications(
|
||||||
let database = state.db.get_database();
|
let database = state.db.get_database();
|
||||||
let repo = MedicationRepository::new(database.collection("medications"));
|
let repo = MedicationRepository::new(database.collection("medications"));
|
||||||
|
|
||||||
let _limit = query.limit.unwrap_or(100);
|
// Honor the active filter (and optional profile_id) when provided.
|
||||||
|
match repo
|
||||||
match repo.find_by_user(&claims.sub).await {
|
.find_by_user_filtered(&claims.sub, query.active, query.profile_id.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(medications) => {
|
Ok(medications) => {
|
||||||
let resp: Vec<MedicationResponse> = medications.into_iter().map(Into::into).collect();
|
let resp: Vec<MedicationResponse> = medications.into_iter().map(Into::into).collect();
|
||||||
Ok(Json(resp))
|
Ok(Json(resp))
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod appointments;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod health_stats;
|
pub mod health_stats;
|
||||||
|
|
@ -10,6 +11,9 @@ pub mod shares;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|
||||||
// Re-export commonly used handler functions
|
// Re-export commonly used handler functions
|
||||||
|
pub use appointments::{
|
||||||
|
create_appointment, delete_appointment, get_appointment, list_appointments, update_appointment,
|
||||||
|
};
|
||||||
pub use auth::{login, logout, recover_password, refresh, register};
|
pub use auth::{login, logout, recover_password, refresh, register};
|
||||||
pub use health::{health_check, ready_check};
|
pub use health::{health_check, ready_check};
|
||||||
pub use health_stats::{
|
pub use health_stats::{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,93 @@
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use mongodb::bson::{oid::ObjectId, DateTime};
|
|
||||||
use super::health_data::EncryptedField;
|
use super::health_data::EncryptedField;
|
||||||
|
use futures::StreamExt;
|
||||||
|
use mongodb::bson::{doc, oid::ObjectId, DateTime};
|
||||||
|
use mongodb::Collection;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// APPOINTMENT MODEL
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// The structured content stored (as a JSON string) inside
|
||||||
|
/// `appointment_data.data`. Deserializes from the camelCase keys the create
|
||||||
|
/// handler packs in. Mirrors the MedicationData pattern.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
pub struct AppointmentData {
|
||||||
|
pub title: String,
|
||||||
|
pub provider: String,
|
||||||
|
#[serde(rename = "appointmentType")]
|
||||||
|
pub appointment_type: String,
|
||||||
|
#[serde(rename = "dateTime")]
|
||||||
|
pub date_time: String,
|
||||||
|
pub location: Option<String>,
|
||||||
|
#[serde(rename = "durationMinutes")]
|
||||||
|
pub duration_minutes: Option<i64>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
#[serde(default = "default_status")]
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_status() -> String {
|
||||||
|
"upcoming".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The flat, snake_case appointment representation returned by the API. Built
|
||||||
|
/// from a stored `Appointment` by deserializing its `appointment_data.data` blob.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AppointmentResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub appointment_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub profile_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub provider: String,
|
||||||
|
pub appointment_type: String,
|
||||||
|
pub date_time: String,
|
||||||
|
pub location: Option<String>,
|
||||||
|
pub duration_minutes: Option<i64>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::convert::From<Appointment> for AppointmentResponse {
|
||||||
|
fn from(a: Appointment) -> Self {
|
||||||
|
let data: AppointmentData =
|
||||||
|
serde_json::from_str(&a.appointment_data.data).unwrap_or_default();
|
||||||
|
|
||||||
|
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,
|
||||||
|
title: data.title,
|
||||||
|
provider: data.provider,
|
||||||
|
appointment_type: data.appointment_type,
|
||||||
|
date_time: data.date_time,
|
||||||
|
location: data.location,
|
||||||
|
duration_minutes: data.duration_minutes,
|
||||||
|
reason: data.reason,
|
||||||
|
notes: data.notes,
|
||||||
|
status: data.status,
|
||||||
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Appointment {
|
pub struct Appointment {
|
||||||
|
|
@ -29,3 +116,230 @@ pub struct AppointmentReminder {
|
||||||
#[serde(rename = "scheduledTime")]
|
#[serde(rename = "scheduledTime")]
|
||||||
pub scheduled_time: String,
|
pub scheduled_time: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// REQUEST TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateAppointmentRequest {
|
||||||
|
pub title: String,
|
||||||
|
pub provider: String,
|
||||||
|
pub appointment_type: String,
|
||||||
|
pub date_time: String,
|
||||||
|
pub profile_id: String,
|
||||||
|
pub location: Option<String>,
|
||||||
|
pub duration_minutes: Option<i64>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
/// Defaults to "upcoming" when omitted.
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateAppointmentRequest {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub appointment_type: Option<String>,
|
||||||
|
pub date_time: Option<String>,
|
||||||
|
pub location: Option<String>,
|
||||||
|
pub duration_minutes: Option<i64>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// REPOSITORY
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppointmentRepository {
|
||||||
|
collection: Collection<Appointment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppointmentRepository {
|
||||||
|
pub fn new(collection: Collection<Appointment>) -> Self {
|
||||||
|
Self { collection }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
&self,
|
||||||
|
appointment: Appointment,
|
||||||
|
) -> Result<Appointment, Box<dyn std::error::Error>> {
|
||||||
|
self.collection
|
||||||
|
.insert_one(appointment.clone(), None)
|
||||||
|
.await?;
|
||||||
|
Ok(appointment)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List a user's appointments, optionally filtered by status (a field
|
||||||
|
/// inside the data blob — so filter post-query in Rust).
|
||||||
|
pub async fn find_by_user_filtered(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
status: Option<&str>,
|
||||||
|
) -> Result<Vec<Appointment>, Box<dyn std::error::Error>> {
|
||||||
|
let filter = doc! { "userId": user_id };
|
||||||
|
let mut cursor = self.collection.find(filter, None).await?;
|
||||||
|
let mut appointments = Vec::new();
|
||||||
|
while let Some(appt) = cursor.next().await {
|
||||||
|
appointments.push(appt?);
|
||||||
|
}
|
||||||
|
// status lives inside the data blob; filter here.
|
||||||
|
if let Some(wanted) = status {
|
||||||
|
appointments.retain(|a| {
|
||||||
|
serde_json::from_str::<AppointmentData>(&a.appointment_data.data)
|
||||||
|
.map(|d| d.status == wanted)
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(appointments)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_appointment_id(
|
||||||
|
&self,
|
||||||
|
appointment_id: &str,
|
||||||
|
) -> Result<Option<Appointment>, Box<dyn std::error::Error>> {
|
||||||
|
let filter = doc! { "appointmentId": appointment_id };
|
||||||
|
Ok(self.collection.find_one(filter, None).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_by_appointment_id(
|
||||||
|
&self,
|
||||||
|
appointment_id: &str,
|
||||||
|
updates: UpdateAppointmentRequest,
|
||||||
|
) -> Result<Option<Appointment>, Box<dyn std::error::Error>> {
|
||||||
|
let filter = doc! { "appointmentId": appointment_id };
|
||||||
|
let existing = self.collection.find_one(filter.clone(), None).await?;
|
||||||
|
let Some(existing) = existing else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut data: AppointmentData =
|
||||||
|
serde_json::from_str(&existing.appointment_data.data).unwrap_or_default();
|
||||||
|
|
||||||
|
if let Some(v) = updates.title {
|
||||||
|
data.title = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = updates.provider {
|
||||||
|
data.provider = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = updates.appointment_type {
|
||||||
|
data.appointment_type = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = updates.date_time {
|
||||||
|
data.date_time = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = updates.location {
|
||||||
|
data.location = Some(v);
|
||||||
|
}
|
||||||
|
if let Some(v) = updates.duration_minutes {
|
||||||
|
data.duration_minutes = Some(v);
|
||||||
|
}
|
||||||
|
if let Some(v) = updates.reason {
|
||||||
|
data.reason = Some(v);
|
||||||
|
}
|
||||||
|
if let Some(v) = updates.notes {
|
||||||
|
data.notes = Some(v);
|
||||||
|
}
|
||||||
|
if let Some(v) = updates.status {
|
||||||
|
data.status = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data_json = serde_json::json!({
|
||||||
|
"title": data.title,
|
||||||
|
"provider": data.provider,
|
||||||
|
"appointmentType": data.appointment_type,
|
||||||
|
"dateTime": data.date_time,
|
||||||
|
"location": data.location,
|
||||||
|
"durationMinutes": data.duration_minutes,
|
||||||
|
"reason": data.reason,
|
||||||
|
"notes": data.notes,
|
||||||
|
"status": data.status,
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let update_doc = doc! {
|
||||||
|
"appointmentData.data": data_json,
|
||||||
|
"updatedAt": DateTime::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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<bool, Box<dyn std::error::Error>> {
|
||||||
|
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_data_deserializes_camel_case_blob() {
|
||||||
|
let json = r#"{
|
||||||
|
"title": "Cardiology follow-up",
|
||||||
|
"provider": "Dr. House",
|
||||||
|
"appointmentType": "in-person",
|
||||||
|
"dateTime": "2026-07-01T10:00:00Z",
|
||||||
|
"location": "Main Clinic",
|
||||||
|
"durationMinutes": 30,
|
||||||
|
"reason": "check-up",
|
||||||
|
"notes": "bring results",
|
||||||
|
"status": "upcoming"
|
||||||
|
}"#;
|
||||||
|
let data: AppointmentData = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(data.title, "Cardiology follow-up");
|
||||||
|
assert_eq!(data.appointment_type, "in-person");
|
||||||
|
assert_eq!(data.duration_minutes, Some(30));
|
||||||
|
assert_eq!(data.status, "upcoming");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn appointment_data_defaults_status_when_missing() {
|
||||||
|
let json = r#"{"title":"X","provider":"Y","appointmentType":"lab","dateTime":"2026-07-01T00:00:00Z"}"#;
|
||||||
|
let data: AppointmentData = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(data.status, "upcoming");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn appointment_response_flattens() {
|
||||||
|
let blob = serde_json::json!({
|
||||||
|
"title": "Dentist", "provider": "Dr. Molar", "appointmentType": "in-person",
|
||||||
|
"dateTime": "2026-08-01T09:00:00Z", "location": null,
|
||||||
|
"durationMinutes": null, "reason": null, "notes": null, "status": "completed"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
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: blob,
|
||||||
|
encrypted: false,
|
||||||
|
iv: String::new(),
|
||||||
|
auth_tag: String::new(),
|
||||||
|
},
|
||||||
|
reminders: vec![],
|
||||||
|
created_at: DateTime::now(),
|
||||||
|
updated_at: DateTime::now(),
|
||||||
|
};
|
||||||
|
let resp: AppointmentResponse = appt.into();
|
||||||
|
assert_eq!(resp.appointment_id, "apt-1");
|
||||||
|
assert_eq!(resp.title, "Dentist");
|
||||||
|
assert_eq!(resp.appointment_type, "in-person");
|
||||||
|
assert_eq!(resp.status, "completed");
|
||||||
|
assert!(!resp.id.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,9 +124,7 @@ pub struct MedicationData {
|
||||||
|
|
||||||
/// The flat, snake_case medication representation returned by the API. Built from
|
/// The flat, snake_case medication representation returned by the API. Built from
|
||||||
/// a stored `Medication` by deserializing its `medication_data.data` blob.
|
/// a stored `Medication` by deserializing its `medication_data.data` blob.
|
||||||
///
|
/// Timestamps are ISO 8601 strings; `active` reflects the persisted flag.
|
||||||
/// 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)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct MedicationResponse {
|
pub struct MedicationResponse {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
@ -176,7 +174,7 @@ impl std::convert::From<Medication> for MedicationResponse {
|
||||||
end_date: data.end_date,
|
end_date: data.end_date,
|
||||||
notes: data.notes,
|
notes: data.notes,
|
||||||
tags: data.tags,
|
tags: data.tags,
|
||||||
active: true, // TODO: persist a real active flag.
|
active: m.active,
|
||||||
created_at: system_time_to_rfc3339(m.created_at.to_system_time()),
|
created_at: system_time_to_rfc3339(m.created_at.to_system_time()),
|
||||||
updated_at: system_time_to_rfc3339(m.updated_at.to_system_time()),
|
updated_at: system_time_to_rfc3339(m.updated_at.to_system_time()),
|
||||||
}
|
}
|
||||||
|
|
@ -212,12 +210,22 @@ pub struct Medication {
|
||||||
pub created_at: DateTime,
|
pub created_at: DateTime,
|
||||||
#[serde(rename = "updatedAt")]
|
#[serde(rename = "updatedAt")]
|
||||||
pub updated_at: DateTime,
|
pub updated_at: DateTime,
|
||||||
|
/// Whether this medication is currently active. Defaults to true for old
|
||||||
|
/// documents that predate the field (see `default_true`).
|
||||||
|
#[serde(rename = "active", default = "default_true")]
|
||||||
|
pub active: bool,
|
||||||
|
|
||||||
/// Physical pill identification (Phase 2.8 - optional)
|
/// Physical pill identification (Phase 2.8 - optional)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub pill_identification: Option<PillIdentification>,
|
pub pill_identification: Option<PillIdentification>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// serde default helper — old medication documents predate the `active` field
|
||||||
|
/// and should deserialize as active.
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MedicationReminder {
|
pub struct MedicationReminder {
|
||||||
#[serde(rename = "reminderId")]
|
#[serde(rename = "reminderId")]
|
||||||
|
|
@ -265,6 +273,9 @@ pub struct CreateMedicationRequest {
|
||||||
pub tags: Option<Vec<String>>,
|
pub tags: Option<Vec<String>>,
|
||||||
pub reminder_times: Option<Vec<String>>,
|
pub reminder_times: Option<Vec<String>>,
|
||||||
pub profile_id: String,
|
pub profile_id: String,
|
||||||
|
/// Whether the medication is active on creation (defaults to true).
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub active: bool,
|
||||||
|
|
||||||
/// Pill identification (Phase 2.8 - optional)
|
/// Pill identification (Phase 2.8 - optional)
|
||||||
#[serde(rename = "pill_identification")]
|
#[serde(rename = "pill_identification")]
|
||||||
|
|
@ -287,6 +298,8 @@ pub struct UpdateMedicationRequest {
|
||||||
pub notes: Option<String>,
|
pub notes: Option<String>,
|
||||||
pub tags: Option<Vec<String>>,
|
pub tags: Option<Vec<String>>,
|
||||||
pub reminder_times: Option<Vec<String>>,
|
pub reminder_times: Option<Vec<String>>,
|
||||||
|
/// Set the medication active/inactive.
|
||||||
|
pub active: Option<bool>,
|
||||||
|
|
||||||
/// Pill identification (Phase 2.8 - optional)
|
/// Pill identification (Phase 2.8 - optional)
|
||||||
#[serde(rename = "pill_identification")]
|
#[serde(rename = "pill_identification")]
|
||||||
|
|
@ -336,6 +349,29 @@ impl MedicationRepository {
|
||||||
Ok(medications)
|
Ok(medications)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like find_by_user, but optionally filtered by `active` and/or `profile_id`
|
||||||
|
/// at the Mongo level (these are real top-level document fields).
|
||||||
|
pub async fn find_by_user_filtered(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
active: Option<bool>,
|
||||||
|
profile_id: Option<&str>,
|
||||||
|
) -> Result<Vec<Medication>, Box<dyn std::error::Error>> {
|
||||||
|
let mut filter = doc! { "userId": user_id };
|
||||||
|
if let Some(active) = active {
|
||||||
|
filter.insert("active", active);
|
||||||
|
}
|
||||||
|
if let Some(pid) = profile_id {
|
||||||
|
filter.insert("profileId", pid);
|
||||||
|
}
|
||||||
|
let mut cursor = self.collection.find(filter, None).await?;
|
||||||
|
let mut medications = Vec::new();
|
||||||
|
while let Some(medication) = cursor.next().await {
|
||||||
|
medications.push(medication?);
|
||||||
|
}
|
||||||
|
Ok(medications)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn find_by_user_and_profile(
|
pub async fn find_by_user_and_profile(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
|
|
@ -428,7 +464,7 @@ impl MedicationRepository {
|
||||||
if let Some(v) = updates.tags {
|
if let Some(v) = updates.tags {
|
||||||
data.tags = v;
|
data.tags = v;
|
||||||
}
|
}
|
||||||
// NOTE: updates.active is accepted by the API but not yet persisted.
|
// `active` lives on the document, not in the data blob.
|
||||||
|
|
||||||
let data_json = serde_json::json!({
|
let data_json = serde_json::json!({
|
||||||
"name": data.name,
|
"name": data.name,
|
||||||
|
|
@ -451,6 +487,9 @@ impl MedicationRepository {
|
||||||
"medicationData.data": data_json,
|
"medicationData.data": data_json,
|
||||||
"updatedAt": DateTime::now(),
|
"updatedAt": DateTime::now(),
|
||||||
};
|
};
|
||||||
|
if let Some(active) = updates.active {
|
||||||
|
update_doc.insert("active", active);
|
||||||
|
}
|
||||||
if let Some(pill_identification) = updates.pill_identification {
|
if let Some(pill_identification) = updates.pill_identification {
|
||||||
if let Ok(pill_doc) = mongodb::bson::to_document(&pill_identification) {
|
if let Ok(pill_doc) = mongodb::bson::to_document(&pill_identification) {
|
||||||
update_doc.insert("pill_identification", pill_doc);
|
update_doc.insert("pill_identification", pill_doc);
|
||||||
|
|
@ -532,8 +571,7 @@ impl MedicationRepository {
|
||||||
if let Some(v) = updates.tags {
|
if let Some(v) = updates.tags {
|
||||||
data.tags = v;
|
data.tags = v;
|
||||||
}
|
}
|
||||||
// NOTE: updates.active is accepted by the API but not yet persisted —
|
// `active` lives on the document, not in the data blob.
|
||||||
// the storage model has no active column (TODO).
|
|
||||||
|
|
||||||
// Re-serialize with the SAME camelCase keys the create handler uses, so
|
// Re-serialize with the SAME camelCase keys the create handler uses, so
|
||||||
// reads stay consistent.
|
// reads stay consistent.
|
||||||
|
|
@ -558,6 +596,9 @@ impl MedicationRepository {
|
||||||
"medicationData.data": data_json,
|
"medicationData.data": data_json,
|
||||||
"updatedAt": DateTime::now(),
|
"updatedAt": DateTime::now(),
|
||||||
};
|
};
|
||||||
|
if let Some(active) = updates.active {
|
||||||
|
update_doc.insert("active", active);
|
||||||
|
}
|
||||||
if let Some(pill_identification) = updates.pill_identification {
|
if let Some(pill_identification) = updates.pill_identification {
|
||||||
if let Ok(pill_doc) = mongodb::bson::to_document(&pill_identification) {
|
if let Ok(pill_doc) = mongodb::bson::to_document(&pill_identification) {
|
||||||
update_doc.insert("pill_identification", pill_doc);
|
update_doc.insert("pill_identification", pill_doc);
|
||||||
|
|
@ -646,6 +687,7 @@ mod tests {
|
||||||
reminders: vec![],
|
reminders: vec![],
|
||||||
created_at: DateTime::now(),
|
created_at: DateTime::now(),
|
||||||
updated_at: DateTime::now(),
|
updated_at: DateTime::now(),
|
||||||
|
active: true,
|
||||||
pill_identification: None,
|
pill_identification: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -674,6 +716,7 @@ mod tests {
|
||||||
reminders: vec![],
|
reminders: vec![],
|
||||||
created_at: DateTime::now(),
|
created_at: DateTime::now(),
|
||||||
updated_at: DateTime::now(),
|
updated_at: DateTime::now(),
|
||||||
|
active: true,
|
||||||
pill_identification: None,
|
pill_identification: None,
|
||||||
};
|
};
|
||||||
// Should NOT panic — falls back to defaults.
|
// Should NOT panic — falls back to defaults.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod appointment;
|
||||||
pub mod audit_log;
|
pub mod audit_log;
|
||||||
pub mod family;
|
pub mod family;
|
||||||
pub mod health_data;
|
pub mod health_data;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,395 @@
|
||||||
|
import { useEffect, useState, type FC } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardActions,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
MenuItem,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { useAppointmentStore, useAuthStore } from '../../store/useStore';
|
||||||
|
import type {
|
||||||
|
Appointment,
|
||||||
|
CreateAppointmentRequest,
|
||||||
|
UpdateAppointmentRequest,
|
||||||
|
} from '../../types/api';
|
||||||
|
|
||||||
|
const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other'];
|
||||||
|
const STATUSES = ['upcoming', 'completed', 'cancelled'];
|
||||||
|
|
||||||
|
const statusColor = (status: string): 'success' | 'default' | 'error' => {
|
||||||
|
if (status === 'upcoming') return 'success';
|
||||||
|
if (status === 'cancelled') return 'error';
|
||||||
|
return 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyCreate: CreateAppointmentRequest = {
|
||||||
|
title: '',
|
||||||
|
provider: '',
|
||||||
|
appointment_type: 'in-person',
|
||||||
|
date_time: new Date().toISOString(),
|
||||||
|
profile_id: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
const toLocalInput = (d: Date) => {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppointmentsManager: FC = () => {
|
||||||
|
const {
|
||||||
|
appointments,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
loadAppointments,
|
||||||
|
createAppointment,
|
||||||
|
updateAppointment,
|
||||||
|
deleteAppointment,
|
||||||
|
clearError,
|
||||||
|
} = useAppointmentStore();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createForm, setCreateForm] = useState<CreateAppointmentRequest>(emptyCreate);
|
||||||
|
const [editTarget, setEditTarget] = useState<Appointment | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState<UpdateAppointmentRequest>({});
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Appointment | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAppointments();
|
||||||
|
}, [loadAppointments]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setCreateForm({
|
||||||
|
...emptyCreate,
|
||||||
|
profile_id: `profile_${user?.user_id ?? 'default'}`,
|
||||||
|
});
|
||||||
|
setCreateOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitCreate = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await createAppointment(createForm);
|
||||||
|
setCreateOpen(false);
|
||||||
|
} catch {
|
||||||
|
/* error surfaced via store */
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (appt: Appointment) => {
|
||||||
|
setEditTarget(appt);
|
||||||
|
setEditForm({
|
||||||
|
title: appt.title,
|
||||||
|
provider: appt.provider,
|
||||||
|
appointment_type: appt.appointment_type,
|
||||||
|
date_time: appt.date_time,
|
||||||
|
location: appt.location,
|
||||||
|
duration_minutes: appt.duration_minutes,
|
||||||
|
reason: appt.reason,
|
||||||
|
notes: appt.notes,
|
||||||
|
status: appt.status,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitEdit = async () => {
|
||||||
|
if (!editTarget?.appointment_id) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateAppointment(editTarget.appointment_id, editForm);
|
||||||
|
setEditTarget(null);
|
||||||
|
} catch {
|
||||||
|
/* error surfaced via store */
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteTarget?.appointment_id) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await deleteAppointment(deleteTarget.appointment_id);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
} catch {
|
||||||
|
/* error surfaced via store */
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="h6">Appointments</Typography>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }} onClose={clearError}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Box display="flex" justifyContent="center" py={4}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : appointments.length === 0 ? (
|
||||||
|
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||||
|
No appointments yet — add one.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<List disablePadding>
|
||||||
|
{appointments.map((appt) => (
|
||||||
|
<ListItem key={appt.appointment_id} disableGutters sx={{ mb: 1.5 }}>
|
||||||
|
<Card sx={{ width: '100%' }}>
|
||||||
|
<CardContent sx={{ pb: 1 }}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" component="span" fontWeight={600}>
|
||||||
|
{appt.title}
|
||||||
|
</Typography>{' '}
|
||||||
|
<Typography component="span" color="text.secondary">
|
||||||
|
· {appt.provider}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 0.5 }}>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={appt.appointment_type}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ mr: 0.5 }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={appt.status}
|
||||||
|
color={statusColor(appt.status)}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
{format(new Date(appt.date_time), 'MMM d, yyyy HH:mm')}
|
||||||
|
{appt.location ? ` · ${appt.location}` : ''}
|
||||||
|
</Typography>
|
||||||
|
{appt.notes && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||||
|
{appt.notes}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions sx={{ justifyContent: 'flex-end' }}>
|
||||||
|
<IconButton size="small" onClick={() => openEdit(appt)} aria-label="edit">
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setDeleteTarget(appt)}
|
||||||
|
aria-label="delete"
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create dialog */}
|
||||||
|
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle>Add appointment</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Title"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
value={createForm.title}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Provider"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
value={createForm.provider}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, provider: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Type"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
value={createForm.appointment_type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({ ...createForm, appointment_type: e.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{APPT_TYPES.map((t) => (
|
||||||
|
<MenuItem key={t} value={t}>{t}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="When"
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
value={toLocalInput(new Date(createForm.date_time))}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({
|
||||||
|
...createForm,
|
||||||
|
date_time: new Date(e.target.value).toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<TextField
|
||||||
|
label="Location"
|
||||||
|
fullWidth
|
||||||
|
value={createForm.location ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({ ...createForm, location: e.target.value || undefined })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Notes"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={createForm.notes ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({ ...createForm, notes: e.target.value || undefined })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={submitCreate}
|
||||||
|
disabled={saving || !createForm.title || !createForm.provider}
|
||||||
|
>
|
||||||
|
{saving ? <CircularProgress size={24} /> : 'Add'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit dialog */}
|
||||||
|
<Dialog open={!!editTarget} onClose={() => setEditTarget(null)} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle>Edit appointment</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Title"
|
||||||
|
fullWidth
|
||||||
|
value={editForm.title ?? ''}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Provider"
|
||||||
|
fullWidth
|
||||||
|
value={editForm.provider ?? ''}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, provider: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Type"
|
||||||
|
fullWidth
|
||||||
|
value={editForm.appointment_type ?? 'in-person'}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, appointment_type: e.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{APPT_TYPES.map((t) => (
|
||||||
|
<MenuItem key={t} value={t}>{t}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Status"
|
||||||
|
fullWidth
|
||||||
|
value={editForm.status ?? 'upcoming'}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, status: e.target.value })}
|
||||||
|
>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<MenuItem key={s} value={s}>{s}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Stack>
|
||||||
|
<TextField
|
||||||
|
label="When"
|
||||||
|
type="datetime-local"
|
||||||
|
fullWidth
|
||||||
|
value={editForm.date_time ? toLocalInput(new Date(editForm.date_time)) : ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
date_time: e.target.value
|
||||||
|
? new Date(e.target.value).toISOString()
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Notes"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={editForm.notes ?? ''}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setEditTarget(null)}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={submitEdit} disabled={saving}>
|
||||||
|
{saving ? <CircularProgress size={24} /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete confirm */}
|
||||||
|
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
|
||||||
|
<DialogTitle>Delete appointment?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>Remove {deleteTarget?.title}? This cannot be undone.</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
|
||||||
|
<Button variant="contained" color="error" onClick={confirmDelete} disabled={saving}>
|
||||||
|
{saving ? <CircularProgress size={24} /> : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppointmentsManager;
|
||||||
|
|
@ -6,8 +6,9 @@ import { MedicationManager } from '../components/medication/MedicationManager';
|
||||||
import { HealthStats } from '../components/health/HealthStats';
|
import { HealthStats } from '../components/health/HealthStats';
|
||||||
import { InteractionsChecker } from '../components/interactions/InteractionsChecker';
|
import { InteractionsChecker } from '../components/interactions/InteractionsChecker';
|
||||||
import { ProfileEditor } from '../components/profile/ProfileEditor';
|
import { ProfileEditor } from '../components/profile/ProfileEditor';
|
||||||
|
import { AppointmentsManager } from '../components/appointments/AppointmentsManager';
|
||||||
|
|
||||||
type TabIndex = 0 | 1 | 2 | 3;
|
type TabIndex = 0 | 1 | 2 | 3 | 4;
|
||||||
|
|
||||||
export const Dashboard: FC = () => {
|
export const Dashboard: FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -50,6 +51,7 @@ export const Dashboard: FC = () => {
|
||||||
<Tab label="Medications" />
|
<Tab label="Medications" />
|
||||||
<Tab label="Health" />
|
<Tab label="Health" />
|
||||||
<Tab label="Interactions" />
|
<Tab label="Interactions" />
|
||||||
|
<Tab label="Appointments" />
|
||||||
<Tab label="Profile" />
|
<Tab label="Profile" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|
@ -57,7 +59,8 @@ export const Dashboard: FC = () => {
|
||||||
{tab === 0 && <MedicationManager />}
|
{tab === 0 && <MedicationManager />}
|
||||||
{tab === 1 && <HealthStats />}
|
{tab === 1 && <HealthStats />}
|
||||||
{tab === 2 && <InteractionsChecker />}
|
{tab === 2 && <InteractionsChecker />}
|
||||||
{tab === 3 && <ProfileEditor />}
|
{tab === 3 && <AppointmentsManager />}
|
||||||
|
{tab === 4 && <ProfileEditor />}
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ import {
|
||||||
LogDoseRequest,
|
LogDoseRequest,
|
||||||
AdherenceStats,
|
AdherenceStats,
|
||||||
Profile,
|
Profile,
|
||||||
|
Appointment,
|
||||||
|
CreateAppointmentRequest,
|
||||||
|
UpdateAppointmentRequest,
|
||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
|
|
||||||
// API base URL. In dev this is "/api", proxied by the Vite dev server to the
|
// API base URL. In dev this is "/api", proxied by the Vite dev server to the
|
||||||
|
|
@ -262,6 +265,34 @@ class ApiService {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Appointments ----
|
||||||
|
|
||||||
|
async getAppointments(status?: string): Promise<Appointment[]> {
|
||||||
|
const response = await this.client.get<Appointment[]>('/appointments', {
|
||||||
|
params: status ? { status } : undefined,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAppointment(id: string): Promise<Appointment> {
|
||||||
|
const response = await this.client.get<Appointment>(`/appointments/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAppointment(data: CreateAppointmentRequest): Promise<Appointment> {
|
||||||
|
const response = await this.client.post<Appointment>('/appointments', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise<Appointment> {
|
||||||
|
const response = await this.client.post<Appointment>(`/appointments/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAppointment(id: string): Promise<void> {
|
||||||
|
await this.client.post(`/appointments/${id}/delete`);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Drug Interactions (Phase 2.8) ----
|
// ---- Drug Interactions (Phase 2.8) ----
|
||||||
|
|
||||||
async checkInteractions(medications: string[]): Promise<DrugInteraction[]> {
|
async checkInteractions(medications: string[]): Promise<DrugInteraction[]> {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import {
|
||||||
DrugInteraction,
|
DrugInteraction,
|
||||||
AdherenceStats,
|
AdherenceStats,
|
||||||
Profile,
|
Profile,
|
||||||
|
Appointment,
|
||||||
|
CreateAppointmentRequest,
|
||||||
|
UpdateAppointmentRequest,
|
||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
import apiService from '../services/api';
|
import apiService from '../services/api';
|
||||||
|
|
||||||
|
|
@ -80,6 +83,17 @@ interface ProfileState {
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AppointmentState {
|
||||||
|
appointments: Appointment[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
loadAppointments: (status?: string) => Promise<void>;
|
||||||
|
createAppointment: (data: CreateAppointmentRequest) => Promise<void>;
|
||||||
|
updateAppointment: (id: string, data: UpdateAppointmentRequest) => Promise<void>;
|
||||||
|
deleteAppointment: (id: string) => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
// Auth Store
|
// Auth Store
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
devtools(
|
devtools(
|
||||||
|
|
@ -466,3 +480,70 @@ export const useProfileStore = create<ProfileState>()(
|
||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Appointment Store
|
||||||
|
export const useAppointmentStore = create<AppointmentState>()(
|
||||||
|
devtools((set) => ({
|
||||||
|
appointments: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
loadAppointments: async (status) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const appointments = await apiService.getAppointments(status);
|
||||||
|
set({ appointments, isLoading: false });
|
||||||
|
} catch (error: any) {
|
||||||
|
set({
|
||||||
|
error: error.message || 'Failed to load appointments',
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createAppointment: async (data) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const appt = await apiService.createAppointment(data);
|
||||||
|
set((state) => ({
|
||||||
|
appointments: [...state.appointments, appt],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
} catch (error: any) {
|
||||||
|
set({
|
||||||
|
error: error.message || 'Failed to create appointment',
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAppointment: async (id, data) => {
|
||||||
|
try {
|
||||||
|
const updated = await apiService.updateAppointment(id, data);
|
||||||
|
set((state) => ({
|
||||||
|
appointments: state.appointments.map((a) =>
|
||||||
|
a.appointment_id === id ? updated : a,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (error: any) {
|
||||||
|
set({ error: error.message || 'Failed to update appointment' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAppointment: async (id) => {
|
||||||
|
try {
|
||||||
|
await apiService.deleteAppointment(id);
|
||||||
|
set((state) => ({
|
||||||
|
appointments: state.appointments.filter((a) => a.appointment_id !== id),
|
||||||
|
}));
|
||||||
|
} catch (error: any) {
|
||||||
|
set({ error: error.message || 'Failed to delete appointment' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,50 @@ export interface LabResult {
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Appointment Types — match backend AppointmentResponse (snake_case).
|
||||||
|
export interface Appointment {
|
||||||
|
id?: string;
|
||||||
|
appointment_id: string;
|
||||||
|
user_id?: string;
|
||||||
|
profile_id?: string;
|
||||||
|
title: string;
|
||||||
|
provider: string;
|
||||||
|
appointment_type: string;
|
||||||
|
date_time: string;
|
||||||
|
location?: string;
|
||||||
|
duration_minutes?: number;
|
||||||
|
reason?: string;
|
||||||
|
notes?: string;
|
||||||
|
status: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAppointmentRequest {
|
||||||
|
title: string;
|
||||||
|
provider: string;
|
||||||
|
appointment_type: string;
|
||||||
|
date_time: string;
|
||||||
|
profile_id: string;
|
||||||
|
location?: string;
|
||||||
|
duration_minutes?: number;
|
||||||
|
reason?: string;
|
||||||
|
notes?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAppointmentRequest {
|
||||||
|
title?: string;
|
||||||
|
provider?: string;
|
||||||
|
appointment_type?: string;
|
||||||
|
date_time?: string;
|
||||||
|
location?: string;
|
||||||
|
duration_minutes?: number;
|
||||||
|
reason?: string;
|
||||||
|
notes?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Dose Log Types — match backend MedicationDose (camelCase serialization).
|
// Dose Log Types — match backend MedicationDose (camelCase serialization).
|
||||||
export interface DoseLog {
|
export interface DoseLog {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue