Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 36 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
```
Comment on lines +9 to +19
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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 install
  1. Seed the database (creates all users, slots, and a sample appointment):
   npm run seed
  1. Start the dev server:
   npm run dev

Also ensure the file ends with a single newline character after line 54.
</details>

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.22.1)</summary>

[warning] 9-9: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

---

[warning] 11-11: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

---

[warning] 12-12: Ordered list item prefix
Expected: 1; Actual: 5; Style: 1/1/1

(MD029, ol-prefix)

---

[warning] 13-13: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

---

[warning] 15-15: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

---

[warning] 16-16: Ordered list item prefix
Expected: 1; Actual: 6; Style: 1/1/1

(MD029, ol-prefix)

---

[warning] 17-17: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @README.md around lines 9 - 19, Add blank lines before and after each fenced
code 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.


</details>

<!-- fingerprinting:phantom:medusa:ocelot:81eaa604-4d5a-472f-8385-1e1c74f27b0e -->

<!-- d98c2f50 -->

<!-- This is an auto-generated comment by CodeRabbit -->


Open [http://localhost:3000](http://localhost:3000)

## Test Credentials

| Role | Email | Password |
|----------|---------------------|------------|
| Doctor 1 | doctor1@test.com | doctor123 |
| Doctor 2 | doctor2@test.com | doctor123 |
| Doctor 3 | doctor3@test.com | doctor123 |
| Patient 1| patient1@test.com | patient123 |
| Patient 2| patient2@test.com | patient123 |
| Patient 3| patient3@test.com | patient123 |
| Admin | admin@test.com | admin123 |
| Role | Email | Password |
|-----------|---------------------|------------|
| Doctor 1 | doctor1@test.com | doctor123 |
| Doctor 2 | doctor2@test.com | doctor123 |
| Doctor 3 | doctor3@test.com | doctor123 |
| Patient 1 | patient1@test.com | patient123 |
| Patient 2 | patient2@test.com | patient123 |
| Patient 3 | patient3@test.com | patient123 |
| Admin | admin@test.com | 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.
324 changes: 324 additions & 0 deletions app/admin/dashboard/page.tsx
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Client-side localStorage check is trivially bypassable.

Any user can open the browser console, run localStorage.setItem("admin_logged_in", "true"), and access the dashboard. Combined with the anon-key Supabase client, this provides read access to appointments, patients, and doctors data (via the overly-permissive USING (true) RLS policies).

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"),
]);
Comment thread
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Unsafe type assertions with as unknown as hide potential type mismatches.

Double casting through unknown bypasses TypeScript's type checking entirely. If the Supabase response shape changes (e.g., nested relations like patients, doctors, slots not matching the Appointment type), this will cause silent runtime failures.

Consider using Supabase's generated types or adding runtime validation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/admin/dashboard/page.tsx` around lines 93 - 96, Replace the unsafe
double-casts (e.g., the use of "as unknown as Appointment[]") by using Supabase
generic typing and/or runtime validation before calling
setAppointments/setDoctors/setPatients/setSlots: call the Supabase query with
the proper generic type (e.g.,
supabase.from<Appointment>('appointments').select(...)) or validate
apptRes.data/doctorRes.data/patientRes.data/slotRes.data with a schema validator
(Zod or similar) and only set state when validation passes; also check
response.error and handle null/undefined data instead of forcing a cast so you
won't silently accept mismatched shapes.

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>
);
}
Loading