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:
parent
89eec1e9c4
commit
63cf46c230
5 changed files with 679 additions and 291 deletions
|
|
@ -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,162 @@ 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 you", color: "#dc3545" };
|
||||
return { text: "Cancelled by you", 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: "800px",
|
||||
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",
|
||||
},
|
||||
section: {
|
||||
marginBottom: "2rem",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "1.1rem",
|
||||
fontWeight: 500,
|
||||
color: "#fff",
|
||||
marginBottom: "1rem",
|
||||
},
|
||||
sectionTitleMuted: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "1.1rem",
|
||||
fontWeight: 500,
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
marginBottom: "1rem",
|
||||
},
|
||||
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,
|
||||
background: "rgba(255, 255, 255, 0.01)",
|
||||
},
|
||||
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",
|
||||
},
|
||||
appointmentNote: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.875rem",
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
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",
|
||||
},
|
||||
emptyStateLink: {
|
||||
color: "#a78bfa",
|
||||
textDecoration: "none",
|
||||
},
|
||||
};
|
||||
|
||||
export default function AppointmentsPage() {
|
||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||
requiredPermission: Permission.VIEW_OWN_APPOINTMENTS,
|
||||
|
|
@ -88,12 +231,9 @@ export default function AppointmentsPage() {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={sharedStyles.pageContainer}>
|
||||
<Header currentPage="appointments" />
|
||||
<main style={sharedStyles.mainContent}>
|
||||
<p>Loading...</p>
|
||||
</main>
|
||||
</div>
|
||||
<main style={styles.main}>
|
||||
<div style={styles.loader}>Loading...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -109,123 +249,79 @@ export default function AppointmentsPage() {
|
|||
);
|
||||
|
||||
return (
|
||||
<div style={sharedStyles.pageContainer}>
|
||||
<main style={styles.main}>
|
||||
<Header currentPage="appointments" />
|
||||
<main style={sharedStyles.mainContent}>
|
||||
<h1 style={{ marginBottom: "0.5rem" }}>My Appointments</h1>
|
||||
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
|
||||
<div style={styles.content}>
|
||||
<h1 style={styles.pageTitle}>My Appointments</h1>
|
||||
<p style={styles.pageSubtitle}>
|
||||
View and manage your booked 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>
|
||||
)}
|
||||
|
||||
{isLoadingAppointments ? (
|
||||
<p>Loading appointments...</p>
|
||||
<div style={styles.emptyState}>Loading appointments...</div>
|
||||
) : appointments.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: "center",
|
||||
padding: "2rem",
|
||||
color: "#666",
|
||||
}}>
|
||||
<div style={styles.emptyState}>
|
||||
<p>You don't have any appointments yet.</p>
|
||||
<a href="/booking" style={{ color: "#0070f3" }}>Book an appointment</a>
|
||||
<a href="/booking" style={styles.emptyStateLink}>Book an appointment</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Upcoming Appointments */}
|
||||
{upcomingAppointments.length > 0 && (
|
||||
<div style={{ marginBottom: "2rem" }}>
|
||||
<h2 style={{ fontSize: "1.1rem", marginBottom: "1rem" }}>
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>
|
||||
Upcoming ({upcomingAppointments.length})
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<div style={styles.appointmentList}>
|
||||
{upcomingAppointments.map((apt) => {
|
||||
const status = getStatusDisplay(apt.status);
|
||||
return (
|
||||
<div
|
||||
key={apt.id}
|
||||
style={{
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "8px",
|
||||
padding: "1rem",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div key={apt.id} style={styles.appointmentCard}>
|
||||
<div style={styles.appointmentHeader}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: "0.25rem" }}>
|
||||
<div style={styles.appointmentTime}>
|
||||
{formatDateTime(apt.slot_start)}
|
||||
</div>
|
||||
{apt.note && (
|
||||
<div style={{ color: "#666", fontSize: "0.875rem", marginBottom: "0.5rem" }}>
|
||||
<div style={styles.appointmentNote}>
|
||||
{apt.note}
|
||||
</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>
|
||||
|
|
@ -242,36 +338,27 @@ export default function AppointmentsPage() {
|
|||
|
||||
{/* Past/Cancelled Appointments */}
|
||||
{pastOrCancelledAppointments.length > 0 && (
|
||||
<div>
|
||||
<h2 style={{ fontSize: "1.1rem", marginBottom: "1rem", color: "#666" }}>
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitleMuted}>
|
||||
Past & Cancelled ({pastOrCancelledAppointments.length})
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<div style={styles.appointmentList}>
|
||||
{pastOrCancelledAppointments.map((apt) => {
|
||||
const status = getStatusDisplay(apt.status);
|
||||
return (
|
||||
<div
|
||||
key={apt.id}
|
||||
style={{
|
||||
border: "1px solid #eee",
|
||||
borderRadius: "8px",
|
||||
padding: "1rem",
|
||||
background: "#fafafa",
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 500, marginBottom: "0.25rem" }}>
|
||||
<div key={apt.id} style={{...styles.appointmentCard, ...styles.appointmentCardPast}}>
|
||||
<div style={styles.appointmentTime}>
|
||||
{formatDateTime(apt.slot_start)}
|
||||
</div>
|
||||
{apt.note && (
|
||||
<div style={{ color: "#888", fontSize: "0.875rem", marginBottom: "0.5rem" }}>
|
||||
<div style={styles.appointmentNote}>
|
||||
{apt.note}
|
||||
</div>
|
||||
)}
|
||||
<span style={{
|
||||
color: status.color,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
...styles.statusBadge,
|
||||
background: status.bgColor,
|
||||
color: status.textColor,
|
||||
}}>
|
||||
{status.text}
|
||||
</span>
|
||||
|
|
@ -283,8 +370,8 @@ export default function AppointmentsPage() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue