Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
122 changes: 122 additions & 0 deletions app/admin/dashboard/page.tsx
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]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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>
);
}
81 changes: 81 additions & 0 deletions app/admin/login/page.tsx
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);
}
}
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>
);
}
21 changes: 21 additions & 0 deletions app/api/admin/appointments/route.ts
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 });
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This endpoint fetches all appointments without pagination. As the number of records grows, this will lead to performance degradation and increased memory usage. Consider implementing pagination (e.g., using .range() or .limit()) to handle large datasets efficiently.


return NextResponse.json({ appointments: appointments ?? [] });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
25 changes: 25 additions & 0 deletions app/api/admin/login/route.ts
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 });
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")
.eq("email", email)
.eq("password", password)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-critical critical

Comparing passwords in plaintext is a critical security vulnerability. Passwords must be hashed using a strong algorithm like bcrypt or Argon2 before storage. The login logic should verify the provided password against the stored hash using a secure comparison function.

.single();
if (!admin) return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const res = NextResponse.json({ success: true });
res.cookies.set("admin_session", admin.id, {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-medium medium

Using the database ID directly as a session identifier in a cookie is insecure, especially if the IDs are predictable (e.g., auto-incrementing integers). It is recommended to generate a cryptographically secure random session token or use a signed JWT to manage sessions securely.

httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 8,
});
return res;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
7 changes: 7 additions & 0 deletions app/api/admin/logout/route.ts
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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
56 changes: 54 additions & 2 deletions app/api/appointments/book/route.ts
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 ", "");
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 });

const { slotId, doctorId } = await req.json();
if (!slotId || !doctorId)
return NextResponse.json({ error: "slotId and doctorId are required" }, { status: 400 });
Comment thread
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 });
Comment thread
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

return NextResponse.json({ appointment });
}
53 changes: 51 additions & 2 deletions app/api/appointments/cancel/route.ts
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 });
Comment thread
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
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

🧩 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 -20

Repository: 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 -20

Repository: botcode-com/task

Length of output: 231


🏁 Script executed:

# Search for how appointment.slots is used elsewhere
rg 'appointment\.slots|\.slots' --type=ts -B2 -A2

Repository: 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 -40

Repository: 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 -20

Repository: 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 -50

Repository: 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 -20

Repository: 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 ts

Repository: 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 10

Repository: 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 ts

Repository: 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.ts

Repository: botcode-com/task

Length of output: 307


🏁 Script executed:

# Check the Supabase client configuration
cat lib/supabase.ts

Repository: botcode-com/task

Length of output: 307


🏁 Script executed:

# Look for package.json to see Supabase version
rg '"@supabase/supabase-js"' --type json

Repository: 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 2

Repository: botcode-com/task

Length of output: 719


Unsafe cast on appointment.slots allows undefined start_time to bypass the 1‑hour cancellation guard.

The code casts appointment.slots without checking existence: (appointment.slots as { start_time: string }).start_time. If slots is undefined or null, this evaluates to undefined, which when passed to validatePatientCancelTime produces new Date(undefined).getTime()NaN, and since NaN < 1 is false, the validation returns null and the patient-side 1‑hour guard is silently skipped.

Defensively validate that start_time exists before calling validatePatientCancelTime, and reject with an error if missing.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 });
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 (apptErr || !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 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);
if (timeError) return NextResponse.json({ error: timeError }, { status: 409 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/appointments/cancel/route.ts` around lines 25 - 44, The current code
unsafely casts appointment.slots to access start_time which can be undefined and
let validatePatientCancelTime receive undefined; update the route to first
verify appointment.slots exists and that (appointment.slots as { start_time?:
string }).start_time is a non-empty string (or use optional chaining like
appointment.slots?.start_time) and if missing return a 409/Bad Request JSON
error (e.g., "Missing slot start_time") before calling
validatePatientCancelTime; reference the appointment variable,
appointment.slots, start_time, and validatePatientCancelTime to locate and fix
the check.

Comment on lines +54 to +59
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

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
Verify each finding against the current code and only fix it if needed.

In `@app/api/appointments/cancel/route.ts` around lines 54 - 59, The current code
assumes appointment.slots is a single object; make it defensive for Supabase
relation shapes by checking whether appointment.slots is an array or object
before reading start_time: if Array.isArray(appointment.slots) pick the first
element (or handle empty array as an error), else if it's an object read its
start_time; also guard that the chosen slot has a start_time and return the same
NextResponse.json error (500) if missing; then pass the extracted start_time
into validatePatientCancelTime(startTime, isDoctor) as before.


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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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 error object returned by the Supabase client for either update, which could lead to returning a success response even if the database operation failed. These operations should be wrapped in a transaction with proper error checking.


return NextResponse.json({ success: true, status: newStatus });
Comment on lines +61 to +82
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 | 🔴 Critical

Unchecked writes + non-atomic state transition → can return success: true on failure and leave inconsistent state.

Two issues here:

  1. The result of both .update() calls is discarded. If either fails (RLS denial, network blip, constraint), the handler still returns { success: true } and the client believes the action persisted.
  2. The two writes are not atomic. If the appointment update succeeds but the slot update fails, the slot stays is_booked = true while the appointment is cancelled (orphan booking). Reverse order has the symmetric problem.
  3. There is also a TOCTOU window between validateAppointmentActive (line 39) and the unconditional update on line 47 — two concurrent cancel/done requests can both pass the check and both write. Make the status transition conditional so it’s idempotent and serializable at the DB level.

Recommended: gate the status update with .eq("status", "active"), only unbook the slot if that update actually changed a row, and surface DB errors.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/appointments/cancel/route.ts` around lines 46 - 53, The current
handler discards DB results and performs two non-atomic writes
(supabaseAdmin.from("appointments").update and
supabaseAdmin.from("slots").update) after validateAppointmentActive, which can
return success on failure and leave inconsistent state; change the appointment
update to be conditional by adding .eq("id", appointmentId).eq("status",
"active") so it only updates if still active, check the returned rowCount or
data from that update and only call supabaseAdmin.from("slots").update({
is_booked: false }).eq("id", appointment.slot_id) if the appointment update
actually affected a row, surface and propagate any DB errors to the HTTP
response instead of always returning NextResponse.json({ success: true }), and
consider moving both mutations into a Postgres RPC/transaction if you need true
atomicity and to eliminate the TOCTOU between validateAppointmentActive and the
unconditional update.

}
8 changes: 7 additions & 1 deletion app/doctor/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,15 @@ export default function DoctorDashboard() {
action: "done" | "cancel"
) {
setActionMsg("");
const {
data: { session },
} = await supabase.auth.getSession();
const res = await fetch("/api/appointments/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session?.access_token ?? ""}`,
},
body: JSON.stringify({ appointmentId, action }),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const data = await res.json();
Expand Down
Loading