From 4c4c29f72ef7e05787377dd4ac90f017ae88f060 Mon Sep 17 00:00:00 2001 From: goose Date: Sun, 28 Jun 2026 16:35:50 -0300 Subject: [PATCH] feat: full appointment feature (CRUD + Appointments tab) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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), 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. --- backend/src/app.rs | 6 + backend/src/handlers/appointments.rs | 136 ++++++ backend/src/handlers/mod.rs | 4 + backend/src/models/appointment.rs | 318 +++++++++++++- backend/src/models/mod.rs | 1 + .../appointments/AppointmentsManager.tsx | 395 ++++++++++++++++++ web/normogen-web/src/pages/Dashboard.tsx | 7 +- web/normogen-web/src/services/api.ts | 31 ++ web/normogen-web/src/store/useStore.ts | 81 ++++ web/normogen-web/src/types/api.ts | 44 ++ 10 files changed, 1019 insertions(+), 4 deletions(-) create mode 100644 backend/src/handlers/appointments.rs create mode 100644 web/normogen-web/src/components/appointments/AppointmentsManager.tsx diff --git a/backend/src/app.rs b/backend/src/app.rs index de21413..7a6ca1f 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -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, diff --git a/backend/src/handlers/appointments.rs b/backend/src/handlers/appointments.rs new file mode 100644 index 0000000..9e7ab8b --- /dev/null +++ b/backend/src/handlers/appointments.rs @@ -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, +} + +pub async fn create_appointment( + State(state): State, + Extension(claims): Extension, + Json(req): Json, +) -> Result, 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, + Extension(claims): Extension, + Query(query): Query, +) -> Result>, 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 = appointments.into_iter().map(Into::into).collect(); + Ok(Json(resp)) + } + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +pub async fn get_appointment( + State(state): State, + Extension(_claims): Extension, + Path(id): Path, +) -> Result, 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, + Extension(_claims): Extension, + Path(id): Path, + Json(req): Json, +) -> Result, 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, + Extension(_claims): Extension, + Path(id): Path, +) -> Result { + 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), + } +} diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index a6930c2..417a445 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -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::{ diff --git a/backend/src/models/appointment.rs b/backend/src/models/appointment.rs index 0e0b144..0f19b4f 100644 --- a/backend/src/models/appointment.rs +++ b/backend/src/models/appointment.rs @@ -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, + #[serde(rename = "durationMinutes")] + pub duration_minutes: Option, + pub reason: Option, + pub notes: Option, + #[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, + pub duration_minutes: Option, + pub reason: Option, + pub notes: Option, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + +impl std::convert::From 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, + pub duration_minutes: Option, + pub reason: Option, + pub notes: Option, + /// Defaults to "upcoming" when omitted. + pub status: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateAppointmentRequest { + pub title: Option, + pub provider: Option, + pub appointment_type: Option, + pub date_time: Option, + pub location: Option, + pub duration_minutes: Option, + pub reason: Option, + pub notes: 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 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, Box> { + 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::(&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, Box> { + 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, Box> { + 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> { + 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()); + } +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index c5dba69..bc326e8 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod appointment; pub mod audit_log; pub mod family; pub mod health_data; diff --git a/web/normogen-web/src/components/appointments/AppointmentsManager.tsx b/web/normogen-web/src/components/appointments/AppointmentsManager.tsx new file mode 100644 index 0000000..7391943 --- /dev/null +++ b/web/normogen-web/src/components/appointments/AppointmentsManager.tsx @@ -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(emptyCreate); + const [editTarget, setEditTarget] = useState(null); + const [editForm, setEditForm] = useState({}); + const [deleteTarget, setDeleteTarget] = useState(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 ( + + + Appointments + + + + {error && ( + + {error} + + )} + + {isLoading ? ( + + + + ) : appointments.length === 0 ? ( + + No appointments yet — add one. + + ) : ( + + {appointments.map((appt) => ( + + + + + + + {appt.title} + {' '} + + · {appt.provider} + + + + + + + {format(new Date(appt.date_time), 'MMM d, yyyy HH:mm')} + {appt.location ? ` · ${appt.location}` : ''} + + {appt.notes && ( + + {appt.notes} + + )} + + + + + openEdit(appt)} aria-label="edit"> + + + setDeleteTarget(appt)} + aria-label="delete" + color="error" + > + + + + + + ))} + + )} + + {/* Create dialog */} + setCreateOpen(false)} fullWidth maxWidth="sm"> + Add appointment + + + setCreateForm({ ...createForm, title: e.target.value })} + /> + setCreateForm({ ...createForm, provider: e.target.value })} + /> + + + setCreateForm({ ...createForm, appointment_type: e.target.value }) + } + > + {APPT_TYPES.map((t) => ( + {t} + ))} + + + setCreateForm({ + ...createForm, + date_time: new Date(e.target.value).toISOString(), + }) + } + /> + + + setCreateForm({ ...createForm, location: e.target.value || undefined }) + } + /> + + setCreateForm({ ...createForm, notes: e.target.value || undefined }) + } + /> + + + + + + + + + {/* Edit dialog */} + setEditTarget(null)} fullWidth maxWidth="sm"> + Edit appointment + + + setEditForm({ ...editForm, title: e.target.value })} + /> + setEditForm({ ...editForm, provider: e.target.value })} + /> + + + setEditForm({ ...editForm, appointment_type: e.target.value }) + } + > + {APPT_TYPES.map((t) => ( + {t} + ))} + + setEditForm({ ...editForm, status: e.target.value })} + > + {STATUSES.map((s) => ( + {s} + ))} + + + + setEditForm({ + ...editForm, + date_time: e.target.value + ? new Date(e.target.value).toISOString() + : undefined, + }) + } + /> + setEditForm({ ...editForm, notes: e.target.value })} + /> + + + + + + + + + {/* Delete confirm */} + setDeleteTarget(null)}> + Delete appointment? + + Remove {deleteTarget?.title}? This cannot be undone. + + + + + + + + ); +}; + +export default AppointmentsManager; diff --git a/web/normogen-web/src/pages/Dashboard.tsx b/web/normogen-web/src/pages/Dashboard.tsx index 94921f3..dfabf52 100644 --- a/web/normogen-web/src/pages/Dashboard.tsx +++ b/web/normogen-web/src/pages/Dashboard.tsx @@ -6,8 +6,9 @@ import { MedicationManager } from '../components/medication/MedicationManager'; import { HealthStats } from '../components/health/HealthStats'; import { InteractionsChecker } from '../components/interactions/InteractionsChecker'; 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 = () => { const navigate = useNavigate(); @@ -50,6 +51,7 @@ export const Dashboard: FC = () => { + @@ -57,7 +59,8 @@ export const Dashboard: FC = () => { {tab === 0 && } {tab === 1 && } {tab === 2 && } - {tab === 3 && } + {tab === 3 && } + {tab === 4 && } diff --git a/web/normogen-web/src/services/api.ts b/web/normogen-web/src/services/api.ts index 79b31bd..3cf213b 100644 --- a/web/normogen-web/src/services/api.ts +++ b/web/normogen-web/src/services/api.ts @@ -18,6 +18,9 @@ import { LogDoseRequest, AdherenceStats, Profile, + Appointment, + CreateAppointmentRequest, + UpdateAppointmentRequest, } from '../types/api'; // 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; } + // ---- Appointments ---- + + async getAppointments(status?: string): Promise { + const response = await this.client.get('/appointments', { + params: status ? { status } : undefined, + }); + return response.data; + } + + async getAppointment(id: string): Promise { + const response = await this.client.get(`/appointments/${id}`); + return response.data; + } + + async createAppointment(data: CreateAppointmentRequest): Promise { + const response = await this.client.post('/appointments', data); + return response.data; + } + + async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise { + const response = await this.client.post(`/appointments/${id}`, data); + return response.data; + } + + async deleteAppointment(id: string): Promise { + await this.client.post(`/appointments/${id}/delete`); + } + // ---- Drug Interactions (Phase 2.8) ---- async checkInteractions(medications: string[]): Promise { diff --git a/web/normogen-web/src/store/useStore.ts b/web/normogen-web/src/store/useStore.ts index 36fcda8..d471874 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -7,6 +7,9 @@ import { DrugInteraction, AdherenceStats, Profile, + Appointment, + CreateAppointmentRequest, + UpdateAppointmentRequest, } from '../types/api'; import apiService from '../services/api'; @@ -80,6 +83,17 @@ interface ProfileState { clearError: () => void; } +interface AppointmentState { + appointments: Appointment[]; + isLoading: boolean; + error: string | null; + loadAppointments: (status?: string) => Promise; + createAppointment: (data: CreateAppointmentRequest) => Promise; + updateAppointment: (id: string, data: UpdateAppointmentRequest) => Promise; + deleteAppointment: (id: string) => Promise; + clearError: () => void; +} + // Auth Store export const useAuthStore = create()( devtools( @@ -466,3 +480,70 @@ export const useProfileStore = create()( clearError: () => set({ error: null }), })), ); + +// Appointment Store +export const useAppointmentStore = create()( + 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 }), + })), +); diff --git a/web/normogen-web/src/types/api.ts b/web/normogen-web/src/types/api.ts index 1b5711d..6ba868e 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -244,6 +244,50 @@ export interface LabResult { 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). export interface DoseLog { id?: string;