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
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),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue