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,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<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",
},
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 (
<div style={sharedStyles.pageContainer}>
<Header currentPage="admin-appointments" />
<main style={sharedStyles.mainContent}>
<p>Loading...</p>
</main>
</div>
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
</main>
);
}
@ -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 (
<div style={sharedStyles.pageContainer}>
<main style={styles.main}>
<Header currentPage="admin-appointments" />
<main style={sharedStyles.mainContent}>
<h1 style={{ marginBottom: "0.5rem" }}>All Appointments</h1>
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
<div style={styles.content}>
<h1 style={styles.pageTitle}>All Appointments</h1>
<p style={styles.pageSubtitle}>
View and manage all user appointments
</p>
{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>
)}
{/* Status Filter */}
<div style={{ marginBottom: "1rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
<label style={{ fontWeight: 500 }}>Filter:</label>
<div style={styles.filterRow}>
<span style={styles.filterLabel}>Filter:</span>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={{
padding: "0.5rem",
border: "1px solid #ddd",
borderRadius: "4px",
}}
style={styles.filterSelect}
>
<option value="all">All ({appointments.length})</option>
<option value="booked">Booked ({bookedCount})</option>
@ -152,13 +283,13 @@ export default function AdminAppointmentsPage() {
</div>
{isLoadingAppointments ? (
<p>Loading appointments...</p>
<div style={styles.emptyState}>Loading appointments...</div>
) : appointments.length === 0 ? (
<p style={{ color: "#666" }}>No appointments yet.</p>
<div style={styles.emptyState}>No appointments yet.</div>
) : filteredAppointments.length === 0 ? (
<p style={{ color: "#666" }}>No appointments match the filter.</p>
<div style={styles.emptyState}>No appointments match the filter.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div style={styles.appointmentList}>
{filteredAppointments.map((apt) => {
const status = getStatusDisplay(apt.status);
const isPast = new Date(apt.slot_start) <= new Date();
@ -166,80 +297,54 @@ export default function AdminAppointmentsPage() {
<div
key={apt.id}
style={{
border: "1px solid #ddd",
borderRadius: "8px",
padding: "1rem",
background: isPast ? "#fafafa" : "#fff",
opacity: isPast && apt.status !== "booked" ? 0.7 : 1,
...styles.appointmentCard,
...(isPast ? styles.appointmentCardPast : {}),
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div style={styles.appointmentHeader}>
<div>
<div style={{ fontWeight: 500, marginBottom: "0.25rem" }}>
<div style={styles.appointmentTime}>
{formatDateTime(apt.slot_start)}
</div>
<div style={{ color: "#666", fontSize: "0.875rem", marginBottom: "0.25rem" }}>
User: {apt.user_email}
<div style={styles.appointmentUser}>
{apt.user_email}
</div>
{apt.note && (
<div style={{ color: "#888", fontSize: "0.875rem", marginBottom: "0.5rem", fontStyle: "italic" }}>
<div style={styles.appointmentNote}>
&quot;{apt.note}&quot;
</div>
)}
<span style={{
color: status.color,
fontSize: "0.75rem",
fontWeight: 500,
...styles.statusBadge,
background: status.bgColor,
color: status.textColor,
}}>
{status.text}
</span>
</div>
{apt.status === "booked" && (
<div>
<div style={styles.buttonGroup}>
{confirmCancelId === apt.id ? (
<div style={{ display: "flex", gap: "0.5rem" }}>
<>
<button
onClick={() => handleCancel(apt.id)}
disabled={cancellingId === apt.id}
style={{
padding: "0.25rem 0.75rem",
background: "#dc3545",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: cancellingId === apt.id ? "not-allowed" : "pointer",
fontSize: "0.75rem",
}}
style={styles.confirmButton}
>
{cancellingId === apt.id ? "..." : "Confirm"}
</button>
<button
onClick={() => setConfirmCancelId(null)}
style={{
padding: "0.25rem 0.75rem",
background: "#fff",
border: "1px solid #ddd",
borderRadius: "4px",
cursor: "pointer",
fontSize: "0.75rem",
}}
style={styles.cancelButton}
>
No
</button>
</div>
</>
) : (
<button
onClick={() => setConfirmCancelId(apt.id)}
style={{
padding: "0.25rem 0.75rem",
background: "#fff",
border: "1px solid #ddd",
borderRadius: "4px",
cursor: "pointer",
fontSize: "0.75rem",
color: "#666",
}}
style={styles.cancelButton}
>
Cancel
</button>
@ -252,8 +357,8 @@ export default function AdminAppointmentsPage() {
})}
</div>
)}
</main>
</div>
</div>
</main>
);
}