From 63cf46c2300da4927554a38bf962c034b0054150 Mon Sep 17 00:00:00 2001 From: counterweight Date: Sun, 21 Dec 2025 17:27:23 +0100 Subject: [PATCH] Fix: Prevent cancellation of past appointments Add check to both user and admin cancel endpoints to reject cancellation of appointments whose slot_start is in the past. This matches the spec requirement that cancellations can only happen 'before the appointment'. Added tests for both user and admin cancel endpoints. Also includes frontend styling updates. --- backend/routes/booking.py | 14 + backend/tests/test_booking.py | 52 +++- frontend/app/admin/appointments/page.tsx | 269 ++++++++++++------ frontend/app/appointments/page.tsx | 287 ++++++++++++------- frontend/app/booking/page.tsx | 348 ++++++++++++++++------- 5 files changed, 679 insertions(+), 291 deletions(-) diff --git a/backend/routes/booking.py b/backend/routes/booking.py index 2902a17..2981502 100644 --- a/backend/routes/booking.py +++ b/backend/routes/booking.py @@ -266,6 +266,13 @@ async def cancel_my_appointment( detail=f"Cannot cancel appointment with status '{appointment.status.value}'" ) + # Check if appointment is in the past + if appointment.slot_start <= datetime.now(timezone.utc): + raise HTTPException( + status_code=400, + detail="Cannot cancel a past appointment" + ) + # Cancel the appointment appointment.status = AppointmentStatus.CANCELLED_BY_USER appointment.cancelled_at = datetime.now(timezone.utc) @@ -346,6 +353,13 @@ async def admin_cancel_appointment( detail=f"Cannot cancel appointment with status '{appointment.status.value}'" ) + # Check if appointment is in the past + if appointment.slot_start <= datetime.now(timezone.utc): + raise HTTPException( + status_code=400, + detail="Cannot cancel a past appointment" + ) + # Cancel the appointment appointment.status = AppointmentStatus.CANCELLED_BY_ADMIN appointment.cancelled_at = datetime.now(timezone.utc) diff --git a/backend/tests/test_booking.py b/backend/tests/test_booking.py index 1e5a65f..86e6cc7 100644 --- a/backend/tests/test_booking.py +++ b/backend/tests/test_booking.py @@ -3,9 +3,11 @@ Booking API Tests Tests for the user booking endpoints. """ -from datetime import date, timedelta +from datetime import date, datetime, timedelta, timezone import pytest +from models import Appointment, AppointmentStatus + def tomorrow() -> date: return date.today() + timedelta(days=1) @@ -656,6 +658,30 @@ class TestCancelAppointment: ) assert len(slots_response.json()["slots"]) == 2 + @pytest.mark.asyncio + async def test_cannot_cancel_past_appointment(self, client_factory, regular_user): + """User cannot cancel a past appointment.""" + # Create a past appointment directly in DB + async with client_factory.get_db_session() as db: + past_time = datetime.now(timezone.utc) - timedelta(hours=1) + appointment = Appointment( + user_id=regular_user["user"]["id"], + slot_start=past_time, + slot_end=past_time + timedelta(minutes=15), + status=AppointmentStatus.BOOKED, + ) + db.add(appointment) + await db.commit() + await db.refresh(appointment) + apt_id = appointment.id + + # Try to cancel + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.post(f"/api/appointments/{apt_id}/cancel") + + assert response.status_code == 400 + assert "past" in response.json()["detail"].lower() + # ============================================================================= # Admin Appointments Tests @@ -806,3 +832,27 @@ class TestAdminCancelAppointment: assert response.status_code == 400 assert "cancelled_by_user" in response.json()["detail"] + @pytest.mark.asyncio + async def test_admin_cannot_cancel_past_appointment(self, client_factory, regular_user, admin_user): + """Admin cannot cancel a past appointment.""" + # Create a past appointment directly in DB + async with client_factory.get_db_session() as db: + past_time = datetime.now(timezone.utc) - timedelta(hours=1) + appointment = Appointment( + user_id=regular_user["user"]["id"], + slot_start=past_time, + slot_end=past_time + timedelta(minutes=15), + status=AppointmentStatus.BOOKED, + ) + db.add(appointment) + await db.commit() + await db.refresh(appointment) + apt_id = appointment.id + + # Admin tries to cancel + async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: + response = await admin_client.post(f"/api/admin/appointments/{apt_id}/cancel") + + assert response.status_code == 400 + assert "past" in response.json()["detail"].lower() + diff --git a/frontend/app/admin/appointments/page.tsx b/frontend/app/admin/appointments/page.tsx index 3dcebed..11b4b93 100644 --- a/frontend/app/admin/appointments/page.tsx +++ b/frontend/app/admin/appointments/page.tsx @@ -1,9 +1,9 @@ "use client"; +import React from "react"; import { useEffect, useState, useCallback } from "react"; import { Permission } from "../../auth-context"; import { api } from "../../api"; -import { sharedStyles } from "../../styles/shared"; import { Header } from "../../components/Header"; import { useRequireAuth } from "../../hooks/useRequireAuth"; import { components } from "../../generated/api"; @@ -24,19 +24,167 @@ function formatDateTime(isoString: string): string { } // Helper to get status display -function getStatusDisplay(status: string): { text: string; color: string } { +function getStatusDisplay(status: string): { text: string; bgColor: string; textColor: string } { switch (status) { case "booked": - return { text: "Booked", color: "#28a745" }; + return { text: "Booked", bgColor: "rgba(34, 197, 94, 0.2)", textColor: "#4ade80" }; case "cancelled_by_user": - return { text: "Cancelled by user", color: "#dc3545" }; + return { text: "Cancelled by user", bgColor: "rgba(239, 68, 68, 0.2)", textColor: "#f87171" }; case "cancelled_by_admin": - return { text: "Cancelled by admin", color: "#dc3545" }; + return { text: "Cancelled by admin", bgColor: "rgba(239, 68, 68, 0.2)", textColor: "#f87171" }; default: - return { text: status, color: "#666" }; + return { text: status, bgColor: "rgba(255,255,255,0.1)", textColor: "rgba(255,255,255,0.6)" }; } } +const styles: Record = { + main: { + minHeight: "100vh", + background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)", + display: "flex", + flexDirection: "column", + }, + loader: { + flex: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.5)", + }, + content: { + flex: 1, + padding: "2rem", + maxWidth: "900px", + margin: "0 auto", + width: "100%", + }, + pageTitle: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "1.75rem", + fontWeight: 600, + color: "#fff", + marginBottom: "0.5rem", + }, + pageSubtitle: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.5)", + fontSize: "0.9rem", + marginBottom: "1.5rem", + }, + errorBanner: { + background: "rgba(239, 68, 68, 0.15)", + border: "1px solid rgba(239, 68, 68, 0.3)", + color: "#f87171", + padding: "1rem", + borderRadius: "8px", + marginBottom: "1rem", + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.875rem", + }, + filterRow: { + display: "flex", + alignItems: "center", + gap: "0.75rem", + marginBottom: "1.5rem", + }, + filterLabel: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.6)", + fontSize: "0.875rem", + }, + filterSelect: { + fontFamily: "'DM Sans', system-ui, sans-serif", + padding: "0.5rem 1rem", + background: "rgba(255, 255, 255, 0.05)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "6px", + color: "#fff", + fontSize: "0.875rem", + }, + appointmentList: { + display: "flex", + flexDirection: "column", + gap: "0.75rem", + }, + appointmentCard: { + background: "rgba(255, 255, 255, 0.03)", + border: "1px solid rgba(255, 255, 255, 0.08)", + borderRadius: "12px", + padding: "1.25rem", + transition: "all 0.2s", + }, + appointmentCardPast: { + opacity: 0.6, + }, + appointmentHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "flex-start", + gap: "1rem", + }, + appointmentTime: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "1rem", + fontWeight: 500, + color: "#fff", + marginBottom: "0.25rem", + }, + appointmentUser: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.875rem", + color: "rgba(255, 255, 255, 0.5)", + marginBottom: "0.25rem", + }, + appointmentNote: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.875rem", + color: "rgba(255, 255, 255, 0.4)", + fontStyle: "italic", + marginBottom: "0.5rem", + }, + statusBadge: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.75rem", + fontWeight: 500, + padding: "0.25rem 0.75rem", + borderRadius: "9999px", + display: "inline-block", + }, + buttonGroup: { + display: "flex", + gap: "0.5rem", + }, + cancelButton: { + fontFamily: "'DM Sans', system-ui, sans-serif", + padding: "0.35rem 0.75rem", + fontSize: "0.75rem", + background: "rgba(255, 255, 255, 0.05)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "6px", + color: "rgba(255, 255, 255, 0.7)", + cursor: "pointer", + transition: "all 0.2s", + }, + confirmButton: { + fontFamily: "'DM Sans', system-ui, sans-serif", + padding: "0.35rem 0.75rem", + fontSize: "0.75rem", + background: "rgba(239, 68, 68, 0.2)", + border: "1px solid rgba(239, 68, 68, 0.3)", + borderRadius: "6px", + color: "#f87171", + cursor: "pointer", + transition: "all 0.2s", + }, + emptyState: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.4)", + textAlign: "center", + padding: "3rem", + }, +}; + export default function AdminAppointmentsPage() { const { user, isLoading, isAuthorized } = useRequireAuth({ requiredPermission: Permission.VIEW_ALL_APPOINTMENTS, @@ -89,12 +237,9 @@ export default function AdminAppointmentsPage() { if (isLoading) { return ( -
-
-
-

Loading...

-
-
+
+
Loading...
+
); } @@ -108,41 +253,27 @@ export default function AdminAppointmentsPage() { }); const bookedCount = appointments.filter((a) => a.status === "booked").length; - const cancelledCount = appointments.filter((a) => a.status !== "booked").length; return ( -
+
-
-

All Appointments

-

+

+

All Appointments

+

View and manage all user appointments

{error && ( -
- {error} -
+
{error}
)} {/* Status Filter */} -
- +
+ Filter: