feat: full appointment feature (CRUD + Appointments tab)
Builds the complete appointment feature end-to-end, mirroring the medication feature structure. The model existed but was orphaned dead code (no handlers, routes, repo, or frontend) — this builds the whole stack. Backend: - models/appointment.rs: AppointmentData (deserializes the data blob, camelCase keys), AppointmentResponse (flat snake_case + From<Appointment>), Create/Update request structs, AppointmentRepository (create, find_by_user_filtered with optional status filter, find/update/delete by appointment_id). Mirrors the medication serialization pattern. Module added to models/mod.rs. - handlers/appointments.rs: full CRUD (create/list/get/update/delete) returning AppointmentResponse. Routes wired in app.rs (POST/GET /api/appointments, GET/POST /:id, POST /:id/delete). - 3 unit tests (AppointmentData deser, default status, response flatten). Frontend: - Appointment/Create/Update types; getAppointments/getAppointment/create/ update/deleteAppointment in api.ts; useAppointmentStore. - AppointmentsManager component (list with type+status chips, create/edit dialogs, delete confirm, empty state) as a 5th Dashboard tab. Verified: backend fmt/clippy 0 warnings, 26 tests pass; frontend build clean, 20 tests pass.
This commit is contained in:
parent
0921d73a6d
commit
4c4c29f72e
10 changed files with 1019 additions and 4 deletions
|
|
@ -78,6 +78,12 @@ pub fn build_app(state: AppState) -> Router {
|
|||
// Drug interactions (Phase 2.8)
|
||||
.route("/api/interactions/check", post(handlers::check_interactions))
|
||||
.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(
|
||||
state.clone(),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod appointments;
|
||||
pub mod auth;
|
||||
pub mod health;
|
||||
pub mod health_stats;
|
||||
|
|
@ -10,6 +11,9 @@ pub mod shares;
|
|||
pub mod users;
|
||||
|
||||
// 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 health::{health_check, ready_check};
|
||||
pub use health_stats::{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,93 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use mongodb::bson::{oid::ObjectId, DateTime};
|
||||
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)]
|
||||
pub struct Appointment {
|
||||
|
|
@ -29,3 +116,230 @@ pub struct AppointmentReminder {
|
|||
#[serde(rename = "scheduledTime")]
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod appointment;
|
||||
pub mod audit_log;
|
||||
pub mod family;
|
||||
pub mod health_data;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue