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.
This commit is contained in:
counterweight 2025-12-21 17:27:23 +01:00
parent 89eec1e9c4
commit 63cf46c230
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
5 changed files with 679 additions and 291 deletions

View file

@ -1,10 +1,9 @@
"use client";
import React from "react";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
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";
@ -46,8 +45,208 @@ function getBookableDates(): Date[] {
return dates;
}
const styles: Record<string, React.CSSProperties> = {
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",
},
successBanner: {
background: "rgba(34, 197, 94, 0.15)",
border: "1px solid rgba(34, 197, 94, 0.3)",
color: "#4ade80",
padding: "1rem",
borderRadius: "8px",
marginBottom: "1rem",
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
},
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",
},
section: {
marginBottom: "2rem",
},
sectionTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "1rem",
},
dateGrid: {
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
},
dateButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.75rem 1rem",
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "10px",
cursor: "pointer",
minWidth: "90px",
textAlign: "center" as const,
transition: "all 0.2s",
},
dateButtonSelected: {
background: "rgba(167, 139, 250, 0.15)",
borderColor: "#a78bfa",
},
dateWeekday: {
color: "#fff",
fontWeight: 500,
fontSize: "0.875rem",
marginBottom: "0.25rem",
},
dateDay: {
color: "rgba(255, 255, 255, 0.5)",
fontSize: "0.8rem",
},
slotGrid: {
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
},
slotButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.6rem 1.25rem",
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "8px",
color: "#fff",
cursor: "pointer",
fontSize: "0.9rem",
transition: "all 0.2s",
},
slotButtonSelected: {
background: "rgba(167, 139, 250, 0.15)",
borderColor: "#a78bfa",
},
emptyState: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.4)",
padding: "1rem 0",
},
confirmCard: {
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "12px",
padding: "1.5rem",
maxWidth: "400px",
},
confirmTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "1rem",
},
confirmTime: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.7)",
marginBottom: "1rem",
},
inputLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
display: "block",
color: "rgba(255, 255, 255, 0.7)",
fontSize: "0.875rem",
marginBottom: "0.5rem",
},
textarea: {
fontFamily: "'DM Sans', system-ui, sans-serif",
width: "100%",
padding: "0.75rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "#fff",
fontSize: "0.875rem",
minHeight: "80px",
resize: "vertical" as const,
},
charCount: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
color: "rgba(255, 255, 255, 0.4)",
textAlign: "right" as const,
marginTop: "0.25rem",
},
charCountWarning: {
color: "#f87171",
},
buttonRow: {
display: "flex",
gap: "0.75rem",
marginTop: "1.5rem",
},
bookButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
flex: 1,
padding: "0.75rem",
background: "linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%)",
border: "none",
borderRadius: "8px",
color: "#fff",
fontWeight: 500,
cursor: "pointer",
transition: "all 0.2s",
},
bookButtonDisabled: {
opacity: 0.5,
cursor: "not-allowed",
},
cancelButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.75rem 1.25rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "rgba(255, 255, 255, 0.7)",
cursor: "pointer",
transition: "all 0.2s",
},
};
export default function BookingPage() {
const router = useRouter();
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.BOOK_APPOINTMENT,
fallbackRedirect: "/",
@ -138,12 +337,9 @@ export default function BookingPage() {
if (isLoading) {
return (
<div style={sharedStyles.pageContainer}>
<Header currentPage="booking" />
<main style={sharedStyles.mainContent}>
<p>Loading...</p>
</main>
</div>
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
</main>
);
}
@ -152,48 +348,26 @@ export default function BookingPage() {
}
return (
<div style={sharedStyles.pageContainer}>
<main style={styles.main}>
<Header currentPage="booking" />
<main style={sharedStyles.mainContent}>
<h1 style={{ marginBottom: "0.5rem" }}>Book an Appointment</h1>
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
<div style={styles.content}>
<h1 style={styles.pageTitle}>Book an Appointment</h1>
<p style={styles.pageSubtitle}>
Select a date to see available {slotDurationMinutes}-minute slots
</p>
{successMessage && (
<div style={{
background: "#d4edda",
border: "1px solid #c3e6cb",
color: "#155724",
padding: "1rem",
borderRadius: "8px",
marginBottom: "1rem",
}}>
{successMessage}
</div>
<div style={styles.successBanner}>{successMessage}</div>
)}
{error && (
<div style={{
background: "#f8d7da",
border: "1px solid #f5c6cb",
color: "#721c24",
padding: "1rem",
borderRadius: "8px",
marginBottom: "1rem",
}}>
{error}
</div>
<div style={styles.errorBanner}>{error}</div>
)}
{/* Date Selection */}
<div style={{ marginBottom: "1.5rem" }}>
<h2 style={{ fontSize: "1.1rem", marginBottom: "0.75rem" }}>Select a Date</h2>
<div style={{
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
}}>
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Select a Date</h2>
<div style={styles.dateGrid}>
{dates.map((date) => {
const isSelected = selectedDate && formatDate(selectedDate) === formatDate(date);
return (
@ -201,19 +375,14 @@ export default function BookingPage() {
key={formatDate(date)}
onClick={() => handleDateSelect(date)}
style={{
padding: "0.5rem 1rem",
border: isSelected ? "2px solid #0070f3" : "1px solid #ddd",
borderRadius: "8px",
background: isSelected ? "#e7f3ff" : "#fff",
cursor: "pointer",
fontSize: "0.875rem",
minWidth: "100px",
...styles.dateButton,
...(isSelected ? styles.dateButtonSelected : {}),
}}
>
<div style={{ fontWeight: 500 }}>
<div style={styles.dateWeekday}>
{date.toLocaleDateString("en-US", { weekday: "short" })}
</div>
<div style={{ color: "#666" }}>
<div style={styles.dateDay}>
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</div>
</button>
@ -224,8 +393,8 @@ export default function BookingPage() {
{/* Available Slots */}
{selectedDate && (
<div style={{ marginBottom: "1.5rem" }}>
<h2 style={{ fontSize: "1.1rem", marginBottom: "0.75rem" }}>
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Available Slots for {selectedDate.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
@ -234,15 +403,11 @@ export default function BookingPage() {
</h2>
{isLoadingSlots ? (
<p>Loading slots...</p>
<div style={styles.emptyState}>Loading slots...</div>
) : availableSlots.length === 0 ? (
<p style={{ color: "#666" }}>No available slots for this date</p>
<div style={styles.emptyState}>No available slots for this date</div>
) : (
<div style={{
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
}}>
<div style={styles.slotGrid}>
{availableSlots.map((slot) => {
const isSelected = selectedSlot?.start_time === slot.start_time;
return (
@ -250,12 +415,8 @@ export default function BookingPage() {
key={slot.start_time}
onClick={() => handleSlotSelect(slot)}
style={{
padding: "0.5rem 1rem",
border: isSelected ? "2px solid #0070f3" : "1px solid #ddd",
borderRadius: "8px",
background: isSelected ? "#e7f3ff" : "#fff",
cursor: "pointer",
fontSize: "0.875rem",
...styles.slotButton,
...(isSelected ? styles.slotButtonSelected : {}),
}}
>
{formatTime(slot.start_time)}
@ -269,60 +430,37 @@ export default function BookingPage() {
{/* Booking Form */}
{selectedSlot && (
<div style={{
background: "#f9f9f9",
border: "1px solid #ddd",
borderRadius: "8px",
padding: "1.5rem",
maxWidth: "400px",
}}>
<h3 style={{ marginBottom: "1rem" }}>
Confirm Booking
</h3>
<p style={{ marginBottom: "1rem" }}>
<div style={styles.confirmCard}>
<h3 style={styles.confirmTitle}>Confirm Booking</h3>
<p style={styles.confirmTime}>
<strong>Time:</strong> {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
</p>
<div style={{ marginBottom: "1rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: 500 }}>
<div>
<label style={styles.inputLabel}>
Note (optional, max {noteMaxLength} chars)
</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value.slice(0, noteMaxLength))}
placeholder="Add a note about your appointment..."
style={{
width: "100%",
padding: "0.5rem",
border: "1px solid #ddd",
borderRadius: "4px",
minHeight: "80px",
resize: "vertical",
fontFamily: "inherit",
}}
style={styles.textarea}
/>
<div style={{
fontSize: "0.75rem",
color: note.length >= noteMaxLength ? "#dc3545" : "#666",
textAlign: "right",
<div style={{
...styles.charCount,
...(note.length >= noteMaxLength ? styles.charCountWarning : {}),
}}>
{note.length}/{noteMaxLength}
</div>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<div style={styles.buttonRow}>
<button
onClick={handleBook}
disabled={isBooking}
style={{
flex: 1,
padding: "0.75rem",
background: isBooking ? "#ccc" : "#0070f3",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: isBooking ? "not-allowed" : "pointer",
fontWeight: 500,
...styles.bookButton,
...(isBooking ? styles.bookButtonDisabled : {}),
}}
>
{isBooking ? "Booking..." : "Book Appointment"}
@ -330,21 +468,15 @@ export default function BookingPage() {
<button
onClick={cancelSlotSelection}
disabled={isBooking}
style={{
padding: "0.75rem 1rem",
background: "#fff",
border: "1px solid #ddd",
borderRadius: "4px",
cursor: "pointer",
}}
style={styles.cancelButton}
>
Cancel
</button>
</div>
</div>
)}
</main>
</div>
</div>
</main>
);
}