-
Notifications
You must be signed in to change notification settings - Fork 11
feat: implement booking/cancel APIs, admin module, tests, and race condition handling #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,36 +3,52 @@ | |
| ## Setup | ||
|
|
||
| 1. Go to [supabase.com](https://supabase.com), sign in, and create a new project | ||
|
|
||
| 2. Once the project is ready, go to **SQL Editor** and run the contents of `schema.sql` | ||
|
|
||
| 3. Place the `.env` file you received into the root of this project | ||
|
|
||
| 4. Install dependencies: | ||
| ```bash | ||
| ```bash | ||
| npm install | ||
| ``` | ||
|
|
||
| ``` | ||
| 5. Seed the database (creates all users, slots, and a sample appointment): | ||
| ```bash | ||
| ```bash | ||
| npm run seed | ||
| ``` | ||
|
|
||
| ``` | ||
| 6. Start the dev server: | ||
| ```bash | ||
| ```bash | ||
| npm run dev | ||
| ``` | ||
| ``` | ||
|
|
||
| Open [http://localhost:3000](http://localhost:3000) | ||
|
|
||
| ## Test Credentials | ||
|
|
||
| | Role | Email | Password | | ||
| |----------|---------------------|------------| | ||
| | Doctor 1 | [email protected] | doctor123 | | ||
| | Doctor 2 | [email protected] | doctor123 | | ||
| | Doctor 3 | [email protected] | doctor123 | | ||
| | Patient 1| [email protected] | patient123 | | ||
| | Patient 2| [email protected] | patient123 | | ||
| | Patient 3| [email protected] | patient123 | | ||
| | Admin | [email protected] | admin123 | | ||
| | Role | Email | Password | | ||
| |-----------|---------------------|------------| | ||
| | Doctor 1 | [email protected] | doctor123 | | ||
| | Doctor 2 | [email protected] | doctor123 | | ||
| | Doctor 3 | [email protected] | doctor123 | | ||
| | Patient 1 | [email protected] | patient123 | | ||
| | Patient 2 | [email protected] | patient123 | | ||
| | Patient 3 | [email protected] | patient123 | | ||
| | Admin | [email protected] | admin123 | | ||
|
|
||
| ## Core Changes | ||
|
|
||
| ### API Routes (`/api/appointments/`) | ||
|
|
||
| - **`POST /api/appointments/book`** — Books a slot for a patient against a selected doctor. Enforces that a patient cannot book an already-taken slot, and cannot hold two active appointments with the same doctor simultaneously. | ||
| - **`POST /api/appointments/cancel`** — Handles cancellation with role-aware rules. Patients are blocked from cancelling within 1 hour of the appointment or if it is already done/cancelled. Doctors can bypass the time restriction and cancel the appointment any time they need. | ||
|
|
||
| ### Admin Module (`/admin/`) | ||
|
|
||
| - **Login page** — Separate login interface for the system admin using the seeded admin credentials. | ||
| - **Dashboard** — Displays a summary row (total appointments, doctors, patients, available slots) and four sections — **Appointments**, **Patients**, **Doctors**, and **Available Slots** — covering all records across the system. | ||
|
|
||
| ### Tests (`/tests/`) | ||
|
|
||
| - **4 Unit Tests** — Independently validate: duplicate slot booking, cancelling a done/cancelled appointment, the 1-hour patient cancellation window (with doctor exception), and duplicate active appointments with the same doctor. | ||
| - **2 Integration Tests** — End-to-end flows with auth and database: booking a slot (verifies appointment creation in DB) and cancelling an appointment (verifies status update in DB). | ||
|
|
||
| ### Race Condition Handling | ||
|
|
||
| Concurrent booking requests targeting the same slot are handled safely at the database level — only one succeeds and the other receives a clear conflict error. This was not part of the original spec but prevents double-booking under real-world load. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,324 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useState } from "react"; | ||
| import { useRouter } from "next/navigation"; | ||
| import { createClient } from "@supabase/supabase-js"; | ||
|
|
||
| type Appointment = { | ||
| id: string; | ||
| status: "active" | "done" | "cancelled"; | ||
| created_at: string; | ||
| patients: { name: string; email: string }; | ||
| doctors: { name: string; specialty: string }; | ||
| slots: { start_time: string; end_time: string }; | ||
| }; | ||
|
|
||
| type Doctor = { | ||
| id: string; | ||
| name: string; | ||
| email: string; | ||
| specialty: string; | ||
| }; | ||
|
|
||
| type Patient = { | ||
| id: string; | ||
| name: string; | ||
| email: string; | ||
| }; | ||
|
|
||
| type Slot = { | ||
| id: string; | ||
| start_time: string; | ||
| end_time: string; | ||
| is_booked: boolean; | ||
| doctors: { name: string }; | ||
| }; | ||
|
|
||
| function formatDateTime(iso: string) { | ||
| return new Date(iso).toLocaleString("en-IN", { | ||
| dateStyle: "medium", | ||
| timeStyle: "short", | ||
| }); | ||
| } | ||
|
|
||
| type Tab = "appointments" | "doctors" | "patients" | "slots"; | ||
|
|
||
| export default function AdminDashboard() { | ||
| const router = useRouter(); | ||
| const [activeTab, setActiveTab] = useState<Tab>("appointments"); | ||
| const [appointments, setAppointments] = useState<Appointment[]>([]); | ||
| const [doctors, setDoctors] = useState<Doctor[]>([]); | ||
| const [patients, setPatients] = useState<Patient[]>([]); | ||
| const [slots, setSlots] = useState<Slot[]>([]); | ||
| const [loading, setLoading] = useState(true); | ||
| const [adminEmail, setAdminEmail] = useState(""); | ||
| const [filter, setFilter] = useState("all"); | ||
|
|
||
| useEffect(() => { | ||
| const isLoggedIn = localStorage.getItem("admin_logged_in"); | ||
| if (!isLoggedIn) { | ||
| router.push("/admin/login"); | ||
| return; | ||
| } | ||
|
Comment on lines
+57
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Client-side localStorage check is trivially bypassable. Any user can open the browser console, run This is acceptable for demo purposes if labeled, but in production the dashboard should validate a signed session server-side. |
||
|
|
||
| setAdminEmail(localStorage.getItem("admin_email") ?? ""); | ||
|
|
||
| async function loadData() { | ||
| const supabase = createClient( | ||
| process.env.NEXT_PUBLIC_SUPABASE_URL!, | ||
| process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! | ||
| ); | ||
|
|
||
| const [apptRes, doctorRes, patientRes, slotRes] = await Promise.all([ | ||
| supabase | ||
| .from("appointments") | ||
| .select("id, status, created_at, patients(name, email), doctors(name, specialty), slots(start_time, end_time)") | ||
| .order("created_at", { ascending: false }), | ||
| supabase | ||
| .from("doctors") | ||
| .select("*") | ||
| .order("name"), | ||
| supabase | ||
| .from("patients") | ||
| .select("*") | ||
| .order("name"), | ||
| supabase | ||
| .from("slots") | ||
| .select("id, start_time, end_time, is_booked, doctors(name)") | ||
| .eq("is_booked",false) | ||
| .gt("start_time",new Date().toISOString()) | ||
| .order("start_time"), | ||
| ]); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| setAppointments((apptRes.data as unknown as Appointment[]) ?? []); | ||
| setDoctors((doctorRes.data as Doctor[]) ?? []); | ||
| setPatients((patientRes.data as Patient[]) ?? []); | ||
| setSlots((slotRes.data as unknown as Slot[]) ?? []); | ||
|
Comment on lines
+93
to
+96
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Unsafe type assertions with Double casting through Consider using Supabase's generated types or adding runtime validation. 🤖 Prompt for AI Agents |
||
| setLoading(false); | ||
| } | ||
|
|
||
| loadData(); | ||
| }, [router]); | ||
|
|
||
| function handleLogout() { | ||
| localStorage.removeItem("admin_logged_in"); | ||
| localStorage.removeItem("admin_email"); | ||
| router.push("/admin/login"); | ||
| } | ||
|
|
||
| if (loading) { | ||
| return ( | ||
| <main className="flex min-h-screen items-center justify-center"> | ||
| <p className="text-gray-500">Loading...</p> | ||
| </main> | ||
| ); | ||
| } | ||
|
|
||
| const tabs: { key: Tab; label: string; count: number }[] = [ | ||
| { key: "appointments", label: "Appointments", count: appointments.length }, | ||
| { key: "doctors", label: "Doctors", count: doctors.length }, | ||
| { key: "patients", label: "Patients", count: patients.length }, | ||
| { key: "slots", label: "Available Slots", count: slots.length}, | ||
| ]; | ||
|
|
||
| return ( | ||
| <main className="min-h-screen bg-gray-50 p-6"> | ||
| <div className="mx-auto max-w-6xl"> | ||
|
|
||
| {/* Header */} | ||
| <div className="mb-6 flex items-center justify-between"> | ||
| <div> | ||
| <h1 className="text-2xl font-bold">Admin Dashboard</h1> | ||
| <p className="text-sm text-gray-500">{adminEmail}</p> | ||
| </div> | ||
| <button | ||
| onClick={handleLogout} | ||
| className="rounded-lg border px-4 py-2 text-sm text-gray-600 hover:bg-gray-100" | ||
| > | ||
| Logout | ||
| </button> | ||
| </div> | ||
|
|
||
| {/* Stats */} | ||
| <div className="mb-6 grid grid-cols-4 gap-4"> | ||
| {tabs.map((tab) => ( | ||
| <div key={tab.key} className="rounded-xl border bg-white p-4"> | ||
| <p className="text-sm text-gray-500">{tab.label}</p> | ||
| <p className="text-2xl font-bold">{tab.count}</p> | ||
| </div> | ||
| ))} | ||
| </div> | ||
|
|
||
| {/* Tabs */} | ||
| <div className="mb-4 flex gap-2"> | ||
| {tabs.map((tab) => ( | ||
| <button | ||
| key={tab.key} | ||
| onClick={() => setActiveTab(tab.key)} | ||
| className={`rounded-lg px-4 py-2 text-sm font-medium ${ | ||
| activeTab === tab.key | ||
| ? "bg-blue-600 text-white" | ||
| : "bg-white border text-gray-600 hover:bg-gray-50" | ||
| }`} | ||
| > | ||
| {tab.label} | ||
| </button> | ||
| ))} | ||
| </div> | ||
|
|
||
| {/* Appointments Tab */} | ||
| {activeTab === "appointments" && ( | ||
| <div> | ||
| {/* Filter buttons */} | ||
| <div className="mb-3 flex gap-2"> | ||
| {["all", "active", "done", "cancelled"].map((f) => ( | ||
| <button | ||
| key={f} | ||
| onClick={() => setFilter(f)} | ||
| className={`rounded-lg px-3 py-1 text-xs font-medium capitalize ${ | ||
| filter === f | ||
| ? "bg-blue-600 text-white" | ||
| : "bg-white border text-gray-600 hover:bg-gray-50" | ||
| }`} | ||
| > | ||
| {f === "all" ? `All (${appointments.length})` : | ||
| f === "active" ? `Active (${appointments.filter(a => a.status === "active").length})` : | ||
| f === "done" ? `Done (${appointments.filter(a => a.status === "done").length})` : | ||
| `Cancelled (${appointments.filter(a => a.status === "cancelled").length})`} | ||
| </button> | ||
| ))} | ||
| </div> | ||
|
|
||
| <div className="overflow-hidden rounded-xl border bg-white"> | ||
| <table className="w-full text-sm"> | ||
| <thead className="bg-gray-50 text-left text-gray-600"> | ||
| <tr> | ||
| <th className="px-4 py-3 font-medium">Patient</th> | ||
| <th className="px-4 py-3 font-medium">Doctor</th> | ||
| <th className="px-4 py-3 font-medium">Specialty</th> | ||
| <th className="px-4 py-3 font-medium">Slot</th> | ||
| <th className="px-4 py-3 font-medium">Status</th> | ||
| <th className="px-4 py-3 font-medium">Booked On</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody className="divide-y"> | ||
| {appointments | ||
| .filter(a => filter === "all" || a.status === filter) | ||
| .length === 0 ? ( | ||
| <tr><td colSpan={6} className="px-4 py-6 text-center text-gray-500">No appointments found.</td></tr> | ||
| ) : appointments | ||
| .filter(a => filter === "all" || a.status === filter) | ||
| .map((appt) => ( | ||
| <tr key={appt.id}> | ||
| <td className="px-4 py-3"> | ||
| <div>{appt.patients?.name}</div> | ||
| <div className="text-xs text-gray-400">{appt.patients?.email}</div> | ||
| </td> | ||
| <td className="px-4 py-3">{appt.doctors?.name}</td> | ||
| <td className="px-4 py-3 text-gray-500">{appt.doctors?.specialty}</td> | ||
| <td className="px-4 py-3">{formatDateTime(appt.slots?.start_time)}</td> | ||
| <td className="px-4 py-3"> | ||
| <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${ | ||
| appt.status === "active" ? "bg-blue-100 text-blue-700" | ||
| : appt.status === "done" ? "bg-green-100 text-green-700" | ||
| : "bg-gray-100 text-gray-600" | ||
| }`}> | ||
| {appt.status} | ||
| </span> | ||
| </td> | ||
| <td className="px-4 py-3 text-gray-500">{formatDateTime(appt.created_at)}</td> | ||
| </tr> | ||
| ))} | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
| </div> | ||
| )} | ||
| {/* Doctors Tab */} | ||
| {activeTab === "doctors" && ( | ||
| <div className="overflow-hidden rounded-xl border bg-white"> | ||
| <table className="w-full text-sm"> | ||
| <thead className="bg-gray-50 text-left text-gray-600"> | ||
| <tr> | ||
| <th className="px-4 py-3 font-medium">Name</th> | ||
| <th className="px-4 py-3 font-medium">Email</th> | ||
| <th className="px-4 py-3 font-medium">Specialty</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody className="divide-y"> | ||
| {doctors.length === 0 ? ( | ||
| <tr><td colSpan={3} className="px-4 py-6 text-center text-gray-500">No doctors found.</td></tr> | ||
| ) : doctors.map((doc) => ( | ||
| <tr key={doc.id}> | ||
| <td className="px-4 py-3 font-medium">{doc.name}</td> | ||
| <td className="px-4 py-3 text-gray-500">{doc.email}</td> | ||
| <td className="px-4 py-3">{doc.specialty}</td> | ||
| </tr> | ||
| ))} | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
| )} | ||
|
|
||
| {/* Patients Tab */} | ||
| {activeTab === "patients" && ( | ||
| <div className="overflow-hidden rounded-xl border bg-white"> | ||
| <table className="w-full text-sm"> | ||
| <thead className="bg-gray-50 text-left text-gray-600"> | ||
| <tr> | ||
| <th className="px-4 py-3 font-medium">Name</th> | ||
| <th className="px-4 py-3 font-medium">Email</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody className="divide-y"> | ||
| {patients.length === 0 ? ( | ||
| <tr><td colSpan={2} className="px-4 py-6 text-center text-gray-500">No patients found.</td></tr> | ||
| ) : patients.map((pat) => ( | ||
| <tr key={pat.id}> | ||
| <td className="px-4 py-3 font-medium">{pat.name}</td> | ||
| <td className="px-4 py-3 text-gray-500">{pat.email}</td> | ||
| </tr> | ||
| ))} | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
| )} | ||
|
|
||
| {/* Slots Tab */} | ||
| {activeTab === "slots" && ( | ||
| <div className="overflow-hidden rounded-xl border bg-white"> | ||
| <table className="w-full text-sm"> | ||
| <thead className="bg-gray-50 text-left text-gray-600"> | ||
| <tr> | ||
| <th className="px-4 py-3 font-medium">Doctor</th> | ||
| <th className="px-4 py-3 font-medium">Start Time</th> | ||
| <th className="px-4 py-3 font-medium">End Time</th> | ||
| <th className="px-4 py-3 font-medium">Status</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody className="divide-y"> | ||
| {slots.length === 0 ? ( | ||
| <tr><td colSpan={4} className="px-4 py-6 text-center text-gray-500">No slots found.</td></tr> | ||
| ) : slots.map((slot) => ( | ||
| <tr key={slot.id}> | ||
| <td className="px-4 py-3">{slot.doctors?.name}</td> | ||
| <td className="px-4 py-3">{formatDateTime(slot.start_time)}</td> | ||
| <td className="px-4 py-3">{formatDateTime(slot.end_time)}</td> | ||
| <td className="px-4 py-3"> | ||
| <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${ | ||
| slot.is_booked ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700" | ||
| }`}> | ||
| {slot.is_booked ? "Booked" : "Available"} | ||
| </span> | ||
| </td> | ||
| </tr> | ||
| ))} | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
| )} | ||
|
|
||
| </div> | ||
| </main> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix markdown formatting issues flagged by linter.
The fenced code blocks should be surrounded by blank lines, and the ordered list should use consistent prefix style. The file is also missing a trailing newline.
📝 Proposed fix for markdown formatting
4. Install dependencies: + ```bash npm installVerify each finding against the current code and only fix it if needed.
In
@README.mdaround lines 9 - 19, Add blank lines before and after each fencedcode block and make the ordered list numbering style consistent (use "5." and
"6." as shown) so the fenced blocks for the commands "npm install", "npm run
seed", and "npm run dev" are each separated by an empty line from surrounding
text; also ensure each ```bash fenced block is correctly opened/closed and the
README.md ends with a single trailing newline character.