-
Notifications
You must be signed in to change notification settings - Fork 12
Task2 completed. #1
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 2 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 |
|---|---|---|
| @@ -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> | ||
| ); | ||
| } |
| 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); | ||
| } | ||
| } | ||
|
|
||
| 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"> | ||
| </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> | ||
| ); | ||
| } | ||
| 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 }); | ||
| } |
| 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 }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
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 }); | ||
|
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; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| 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; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| 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 ", ""); | ||
|
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
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. 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 A reliable fix is a partial unique index in the DB (e.g., 🤖 Prompt for AI Agents |
||
|
|
||
| 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 }); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.