-
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 1 commit
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,122 @@ | ||
| "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); | ||
|
|
||
| useEffect(() => { | ||
| async function load() { | ||
| const res = await fetch("/api/admin/appointments"); | ||
| if (!res.ok) { | ||
| router.push("/admin/login"); | ||
| return; | ||
| } | ||
| const data = await res.json(); | ||
| setAppointments(data.appointments); | ||
| setLoading(false); | ||
| } | ||
|
|
||
| load(); | ||
| }, [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> | ||
| )} | ||
| </section> | ||
| </div> | ||
| </main> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| "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); | ||
|
|
||
| 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."); | ||
| setLoading(false); | ||
| } | ||
| } | ||
|
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"> | ||
| </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,21 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { supabaseAdmin } from "@/lib/supabase-server"; | ||
|
|
||
| export async function GET(req: NextRequest) { | ||
| const adminId = req.cookies.get("admin_session")?.value; | ||
| if (!adminId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||
|
|
||
| const { data: admin } = await supabaseAdmin | ||
| .from("system_admins") | ||
| .select("id") | ||
| .eq("id", adminId) | ||
| .single(); | ||
| if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||
|
|
||
| const { data: appointments } = await supabaseAdmin | ||
| .from("appointments") | ||
| .select("id, status, created_at, patients(name), doctors(name, specialty), slots(start_time, end_time)") | ||
| .order("created_at", { ascending: false }); | ||
|
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. |
||
|
|
||
| return NextResponse.json({ appointments: appointments ?? [] }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { supabaseAdmin } from "@/lib/supabase-server"; | ||
|
|
||
| export async function POST(req: NextRequest) { | ||
| const { email, password } = await req.json(); | ||
| 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") | ||
| .eq("email", email) | ||
| .eq("password", password) | ||
|
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. |
||
| .single(); | ||
| if (!admin) return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| const res = NextResponse.json({ success: true }); | ||
| res.cookies.set("admin_session", admin.id, { | ||
|
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. |
||
| httpOnly: true, | ||
| sameSite: "lax", | ||
| path: "/", | ||
| 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,7 @@ | ||
| import { NextResponse } from "next/server"; | ||
|
|
||
| export async function POST() { | ||
| const res = NextResponse.json({ success: true }); | ||
| res.cookies.delete("admin_session"); | ||
| return res; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,57 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { supabaseAdmin } from "@/lib/supabase-server"; | ||
| import { validateSlotAvailable, 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 }); | ||
|
|
||
| const { slotId, doctorId } = await req.json(); | ||
| if (!slotId || !doctorId) | ||
| return NextResponse.json({ error: "slotId and doctorId are required" }, { status: 400 }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| const { data: slot } = await supabaseAdmin | ||
| .from("slots") | ||
| .select("id, is_booked") | ||
| .eq("id", slotId) | ||
| .single(); | ||
| if (!slot) return NextResponse.json({ error: "Slot not found" }, { status: 404 }); | ||
|
|
||
| const slotError = validateSlotAvailable(slot.is_booked); | ||
| if (slotError) return NextResponse.json({ error: slotError }, { status: 409 }); | ||
|
|
||
| const { data: existing } = await supabaseAdmin | ||
| .from("appointments") | ||
| .select("id") | ||
| .eq("patient_id", user.id) | ||
| .eq("doctor_id", doctorId) | ||
| .eq("status", "active") | ||
| .maybeSingle(); | ||
|
|
||
| const duplicateError = validateNoActiveAppointmentWithDoctor(!!existing); | ||
| if (duplicateError) return NextResponse.json({ error: duplicateError }, { status: 409 }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| const { data: appointment, error: insertError } = await supabaseAdmin | ||
| .from("appointments") | ||
| .insert({ patient_id: user.id, doctor_id: doctorId, slot_id: slotId }) | ||
| .select() | ||
| .single(); | ||
| if (insertError) return NextResponse.json({ error: insertError.message }, { status: 500 }); | ||
|
|
||
| await supabaseAdmin.from("slots").update({ is_booked: true }).eq("id", slotId); | ||
|
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. This booking logic is susceptible to race conditions and lacks atomicity. Multiple concurrent requests could pass the availability check (lines 26-34) before any of them update the slot status. Additionally, if the slot update (line 54) fails after the appointment is inserted (line 47), the database will be in an inconsistent state. These operations should be performed within a single database transaction or handled via a stored procedure (RPC) to ensure atomicity and prevent overbooking.
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| return NextResponse.json({ appointment }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,54 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { NextRequest, NextResponse } from "next/server"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { supabaseAdmin } from "@/lib/supabase-server"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| validateAppointmentActive, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| validatePatientCancelTime, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| AppointmentStatus, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } 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 ", ""); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 { appointmentId, action } = await req.json(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!appointmentId || !action) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: "appointmentId and action are required" }, { status: 400 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!["cancel", "done"].includes(action)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: "action must be cancel or done" }, { status: 400 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { data: appointment } = await supabaseAdmin | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .from("appointments") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .select("id, status, patient_id, doctor_id, slot_id, slots(start_time)") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .eq("id", appointmentId) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .single(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!appointment) return NextResponse.json({ error: "Appointment not found" }, { status: 404 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isDoctor = appointment.doctor_id === user.id; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isPatient = appointment.patient_id === user.id; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!isDoctor && !isPatient) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (action === "done" && !isDoctor) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: "Only doctors can mark appointments as done" }, { status: 403 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const activeError = validateAppointmentActive(appointment.status as AppointmentStatus); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (activeError) return NextResponse.json({ error: activeError }, { status: 409 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const startTime = (appointment.slots as { start_time: string }).start_time; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const timeError = validatePatientCancelTime(startTime, isDoctor); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (timeError) return NextResponse.json({ error: timeError }, { status: 409 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+37
to
+59
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. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Inspect the FK relationship between appointments.slot_id and slots, and how the
# Supabase generated types model the relation, to confirm whether slots(...) returns
# an object or an array in TS at runtime.
fd -t f 'database.types.ts'
fd -t f 'supabase' -e ts -e sql
rg -nP -C3 '\bslots\s*\(' --type=ts
rg -nP -C5 'CREATE TABLE\s+(public\.)?appointments\b|REFERENCES\s+(public\.)?slots\b'Repository: botcode-com/task Length of output: 2927 🏁 Script executed: # Find and examine Supabase type definitions
fd -type f -name '*.ts' -o -name '*.d.ts' | head -20Repository: botcode-com/task Length of output: 231 🏁 Script executed: # Check if there's a database types file
fd -type f 'types' | grep -E '(database|supabase)' | head -20Repository: botcode-com/task Length of output: 231 🏁 Script executed: # Search for how appointment.slots is used elsewhere
rg 'appointment\.slots|\.slots' --type=ts -B2 -A2Repository: botcode-com/task Length of output: 1723 🏁 Script executed: # Look for any existing type annotations on appointment variable
rg 'appointment.*:' --type=ts -B2 -A2 | head -40Repository: botcode-com/task Length of output: 2217 🏁 Script executed: # Search for TypeScript definition files in the project
find . -name "*.d.ts" -o -name "types.ts" | head -20Repository: botcode-com/task Length of output: 76 🏁 Script executed: # Check the imports and type annotations in the cancel route
cat app/api/appointments/cancel/route.ts | head -50Repository: botcode-com/task Length of output: 2368 🏁 Script executed: # Look for any supabase types files
find . -path "*/supabase/*" -name "*.ts" -o -path "*/.types/*" -name "*.ts" | head -20Repository: botcode-com/task Length of output: 42 🏁 Script executed: # Check the validation function to see what it does with undefined/NaN
rg -B5 -A10 'validatePatientCancelTime' --type tsRepository: botcode-com/task Length of output: 5501 🏁 Script executed: # Search for type definitions and check how slots is typed
rg -g '*.d.ts' -g '*.ts' 'export.*type.*appointment|interface.*appointment' --type ts -A 10Repository: botcode-com/task Length of output: 42 🏁 Script executed: # Check for any supabase schema or database type files
find . -type f \( -name "*database*" -o -name "*supabase*" \) | grep -E '\.(ts|d\.ts)$'Repository: botcode-com/task Length of output: 103 🏁 Script executed: # Look for type guards or any runtime checks on slots
rg 'Array\.isArray.*slots|slots.*\[\]|slots.*:.*array' --type tsRepository: botcode-com/task Length of output: 234 🏁 Script executed: # Test if the vulnerability exists: NaN < 1 behavior
node -e "console.log('NaN < 1:', NaN < 1); console.log('new Date(undefined).getTime():', new Date(undefined).getTime());"Repository: botcode-com/task Length of output: 110 🏁 Script executed: # Check the Supabase server configuration
cat lib/supabase-server.tsRepository: botcode-com/task Length of output: 307 🏁 Script executed: # Check the Supabase client configuration
cat lib/supabase.tsRepository: botcode-com/task Length of output: 307 🏁 Script executed: # Look for package.json to see Supabase version
rg '"@supabase/supabase-js"' --type jsonRepository: botcode-com/task Length of output: 175 🏁 Script executed: # Search for any issues or TODOs related to slots
rg 'TODO|FIXME|BUG|slots' app/api/appointments/cancel/route.ts -A 2 -B 2Repository: botcode-com/task Length of output: 719 Unsafe cast on The code casts Defensively validate that Proposed fix- const { data: appointment } = await supabaseAdmin
+ const { data: appointment, error: apptErr } = await supabaseAdmin
.from("appointments")
.select("id, status, patient_id, doctor_id, slot_id, slots(start_time)")
.eq("id", appointmentId)
.single();
- if (!appointment) return NextResponse.json({ error: "Appointment not found" }, { status: 404 });
+ if (apptErr || !appointment) return NextResponse.json({ error: "Appointment not found" }, { status: 404 });
@@
- const startTime = (appointment.slots as { start_time: string }).start_time;
- const timeError = validatePatientCancelTime(startTime, isDoctor);
+ const slotsRel = appointment.slots as { start_time: string } | { start_time: string }[] | null;
+ const startTime = Array.isArray(slotsRel) ? slotsRel[0]?.start_time : slotsRel?.start_time;
+ if (!startTime) return NextResponse.json({ error: "Appointment slot not found" }, { status: 404 });
+ const timeError = validatePatientCancelTime(startTime, isDoctor);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Comment on lines
+54
to
+59
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 Slots cast is now guarded but could be more defensive. The null check on line 54 handles the missing slots case. However, Supabase's joined relations can return either an object or array depending on query configuration. Adding explicit handling would be more robust. ♻️ Defensive handling for relation shape if (!appointment.slots)
return NextResponse.json({ error: "Slot data missing for this appointment" }, { status: 500 });
- const startTime = (appointment.slots as { start_time: string }).start_time;
+ const slotsData = appointment.slots as { start_time: string } | { start_time: string }[];
+ const startTime = Array.isArray(slotsData) ? slotsData[0]?.start_time : slotsData?.start_time;
+ if (!startTime)
+ return NextResponse.json({ error: "Slot start time missing" }, { status: 500 });
const timeError = validatePatientCancelTime(startTime, isDoctor);🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const newStatus = action === "done" ? "done" : "cancelled"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await supabaseAdmin.from("appointments").update({ status: newStatus }).eq("id", appointmentId); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (newStatus === "cancelled") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await supabaseAdmin.from("slots").update({ is_booked: false }).eq("id", appointment.slot_id); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
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. The updates to the appointment and slot statuses are not atomic and lack error handling. If the second update fails, the system enters an inconsistent state. Additionally, the code does not check the |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ success: true, status: newStatus }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+61
to
+82
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. Unchecked writes + non-atomic state transition → can return Two issues here:
Recommended: gate the status update with Proposed fix- const newStatus = action === "done" ? "done" : "cancelled";
- await supabaseAdmin.from("appointments").update({ status: newStatus }).eq("id", appointmentId);
-
- if (newStatus === "cancelled") {
- await supabaseAdmin.from("slots").update({ is_booked: false }).eq("id", appointment.slot_id);
- }
-
- return NextResponse.json({ success: true, status: newStatus });
+ const newStatus = action === "done" ? "done" : "cancelled";
+ const { data: updated, error: updateErr } = await supabaseAdmin
+ .from("appointments")
+ .update({ status: newStatus })
+ .eq("id", appointmentId)
+ .eq("status", "active") // guards against concurrent transitions
+ .select("id")
+ .maybeSingle();
+ if (updateErr) {
+ return NextResponse.json({ error: "Failed to update appointment" }, { status: 500 });
+ }
+ if (!updated) {
+ return NextResponse.json({ error: "Appointment is no longer active" }, { status: 409 });
+ }
+
+ if (newStatus === "cancelled") {
+ const { error: slotErr } = await supabaseAdmin
+ .from("slots")
+ .update({ is_booked: false })
+ .eq("id", appointment.slot_id);
+ if (slotErr) {
+ // Best-effort compensation; consider moving this whole flow into a Postgres
+ // function (RPC) to make the two writes truly atomic.
+ return NextResponse.json({ error: "Appointment cancelled but failed to release slot" }, { status: 500 });
+ }
+ }
+
+ return NextResponse.json({ success: true, status: newStatus });For real atomicity, consider wrapping these two mutations in a Postgres function (Supabase RPC) so the appointment status flip and slot release commit in a single transaction. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.