Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
152 changes: 152 additions & 0 deletions app/admin/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"use client";

import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";

type AdminAppointment = {
id: string;
status: "active" | "done" | "cancelled";
created_at: string;
patients: { name: string };
doctors: { name: string; specialty: string };
slots: { start_time: string; end_time: string };
};

function formatDateTime(iso: string) {
return new Date(iso).toLocaleString("en-IN", {
dateStyle: "medium",
timeStyle: "short",
});
}

export default function AdminDashboard() {
const router = useRouter();
const [appointments, setAppointments] = useState<AdminAppointment[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(false);

useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
try {
const res = await fetch(`/api/admin/appointments?page=${page}`);
if (!res.ok) {
router.push("/admin/login");
return;
}
const data = await res.json();
if (!cancelled) {
setAppointments(data.appointments);
setHasMore(data.hasMore);
}
} catch {
if (!cancelled) router.push("/admin/login");
} finally {
if (!cancelled) setLoading(false);
}
}

load();
return () => { cancelled = true; };
}, [page, router]);

async function handleLogout() {
await fetch("/api/admin/logout", { method: "POST" });
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>
);
}

return (
<main className="min-h-screen bg-gray-50 p-6">
<div className="mx-auto max-w-5xl">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
<button
onClick={handleLogout}
className="rounded-lg border px-4 py-2 text-sm text-gray-600 hover:bg-gray-100"
>
Logout
</button>
</div>

<section>
<h2 className="mb-3 text-lg font-semibold">All Appointments</h2>
{appointments.length === 0 ? (
<p className="text-sm text-gray-500">No appointments found.</p>
) : (
<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">Created</th>
</tr>
</thead>
<tbody className="divide-y">
{appointments.map((appt) => (
<tr key={appt.id}>
<td className="px-4 py-3">{appt.patients?.name}</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 className="mt-4 flex items-center justify-between">
<button
onClick={() => setPage((p) => p - 1)}
disabled={page === 0}
className="rounded-lg border px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40"
>
Previous
</button>
<span className="text-sm text-gray-500">Page {page + 1}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={!hasMore}
className="rounded-lg border px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40"
>
Next
</button>
</div>
</section>
</div>
</main>
);
}
86 changes: 86 additions & 0 deletions app/admin/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";

export default function AdminLogin() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);

try {
const res = await fetch("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});

if (res.ok) {
router.push("/admin/dashboard");
} else {
const data = await res.json();
setError(data.error ?? "Login failed.");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<main className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md rounded-xl border bg-white p-8 shadow-sm">
<h1 className="mb-6 text-2xl font-bold">Admin Login</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="admin@test.com"
className="w-full rounded-lg border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded-lg border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="rounded-lg bg-purple-600 py-2 font-medium text-white hover:bg-purple-700 disabled:opacity-50"
>
{loading ? "Logging in..." : "Login"}
</button>
</form>
<p className="mt-4 text-center text-sm text-gray-500">
<Link href="/" className="text-purple-600 hover:underline">
← Back to home
</Link>
</p>
</div>
</main>
);
}
30 changes: 30 additions & 0 deletions app/api/admin/appointments/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { supabaseAdmin } from "@/lib/supabase-server";
import { verifyAdminSession, SESSION_COOKIE } from "@/lib/session";

const PAGE_SIZE = 50;

export async function GET(req: NextRequest) {
const token = req.cookies.get(SESSION_COOKIE)?.value;
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

const adminId = verifyAdminSession(token);
if (!adminId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

const pageParam = req.nextUrl.searchParams.get("page");
const page = Math.max(0, parseInt(pageParam ?? "0", 10) || 0);
const from = page * PAGE_SIZE;

const { data: appointments, error } = await supabaseAdmin
.from("appointments")
.select("id, status, created_at, patients(name), doctors(name, specialty), slots(start_time, end_time)")
.order("created_at", { ascending: false })
.range(from, from + PAGE_SIZE); // fetch PAGE_SIZE + 1 to determine hasMore

if (error) return NextResponse.json({ error: error.message }, { status: 500 });

const hasMore = (appointments?.length ?? 0) > PAGE_SIZE;
const pageData = hasMore ? appointments!.slice(0, PAGE_SIZE) : (appointments ?? []);

return NextResponse.json({ appointments: pageData, page, hasMore });
}
31 changes: 31 additions & 0 deletions app/api/admin/login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { supabaseAdmin } from "@/lib/supabase-server";
import { createAdminSession, SESSION_COOKIE, COOKIE_OPTIONS } from "@/lib/session";

export async function POST(req: NextRequest) {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid or missing JSON body" }, { status: 400 });
}

const { email, password } = body as Record<string, unknown>;
if (!email || !password)
return NextResponse.json({ error: "Email and password are required" }, { status: 400 });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const { data: admin } = await supabaseAdmin
.from("system_admins")
.select("id, email, password")
.eq("email", email)
.single();

const valid = admin && (await bcrypt.compare(password as string, admin.password));
if (!valid) return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const token = createAdminSession(admin.id);
const res = NextResponse.json({ success: true });
res.cookies.set(SESSION_COOKIE, token, { ...COOKIE_OPTIONS, maxAge: 60 * 60 * 8 });
return res;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
8 changes: 8 additions & 0 deletions app/api/admin/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { SESSION_COOKIE, COOKIE_OPTIONS } from "@/lib/session";

export async function POST() {
const res = NextResponse.json({ success: true });
res.cookies.set(SESSION_COOKIE, "", { ...COOKIE_OPTIONS, maxAge: 0 });
return res;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
69 changes: 67 additions & 2 deletions app/api/appointments/book/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import { supabaseAdmin } from "@/lib/supabase-server";
import { validateNoActiveAppointmentWithDoctor } from "@/lib/appointments";

export async function POST(_req: NextRequest) {
return NextResponse.json({ error: "Not implemented" }, { status: 501 });
export async function POST(req: NextRequest) {
const token = req.headers.get("authorization")?.replace("Bearer ", "");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

const {
data: { user },
error: authError,
} = await supabaseAdmin.auth.getUser(token);
if (authError || !user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

const { data: patient } = await supabaseAdmin
.from("patients")
.select("id")
.eq("id", user.id)
.single();
if (!patient) return NextResponse.json({ error: "Only patients can book appointments" }, { status: 403 });

let slotId: string;
try {
const body = await req.json();
slotId = body.slotId;
} catch {
return NextResponse.json({ error: "Invalid or missing JSON body" }, { status: 400 });
}
if (!slotId) return NextResponse.json({ error: "slotId is required" }, { status: 400 });

// Atomically claim the slot — only succeeds if is_booked is currently false
const { data: slot } = await supabaseAdmin
.from("slots")
.update({ is_booked: true })
.eq("id", slotId)
.eq("is_booked", false)
.select("id, doctor_id")
.maybeSingle();

if (!slot) return NextResponse.json({ error: "Slot is already booked" }, { status: 409 });

// Check for duplicate active appointment with the same doctor (doctor_id comes from slot, not client)
const { data: existing } = await supabaseAdmin
.from("appointments")
.select("id")
.eq("patient_id", user.id)
.eq("doctor_id", slot.doctor_id)
.eq("status", "active")
.maybeSingle();

const duplicateError = validateNoActiveAppointmentWithDoctor(!!existing);
if (duplicateError) {
// Release the slot we just claimed
await supabaseAdmin.from("slots").update({ is_booked: false }).eq("id", slotId);
return NextResponse.json({ error: duplicateError }, { status: 409 });
}
Comment on lines +47 to +61
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

Residual race: two concurrent bookings can still create duplicate active appointments with the same doctor.

The slot-claim is now atomic, but the duplicate-doctor check at lines 43-49 + insert at 58-62 is still a non-atomic check-then-write. A patient firing two requests in parallel for two different free slots of the same doctor will pass validateNoActiveAppointmentWithDoctor on both before either insert lands, ending up with two active appointments for the same (patient_id, doctor_id).

A reliable fix is a partial unique index in the DB (e.g., CREATE UNIQUE INDEX one_active_per_doctor ON appointments (patient_id, doctor_id) WHERE status = 'active';) and treating the resulting unique-violation on insert as a 409, then releasing the claimed slot.

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

In `@app/api/appointments/book/route.ts` around lines 42 - 56, The current
check-then-insert flow around validateNoActiveAppointmentWithDoctor (in
route.ts) is racy; add a DB-level partial unique index (e.g., UNIQUE on
(patient_id, doctor_id) WHERE status='active') and change the insert path to
handle unique-violation errors from supabaseAdmin when creating the appointment:
on a unique-violation, catch the DB error, release the claimed slot
(supabaseAdmin.from("slots").update({ is_booked: false }).eq("id", slotId)), and
return a 409 JSON error (same behavior as the existing duplicate handling). Keep
existing optimistic check if desired but rely on the DB constraint + error
handling as the authoritative prevention of duplicate active appointments.


const { data: appointment, error: insertError } = await supabaseAdmin
.from("appointments")
.insert({ patient_id: user.id, doctor_id: slot.doctor_id, slot_id: slotId })
.select()
.single();

if (insertError) {
await supabaseAdmin.from("slots").update({ is_booked: false }).eq("id", slotId);
return NextResponse.json({ error: insertError.message }, { status: 500 });
}

return NextResponse.json({ appointment });
}
Loading