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
Next Next commit
phase 1 of patient book and doctor cancel
  • Loading branch information
Akshaya-SK committed Apr 29, 2026
commit e82f56ecd4256861c604e83d0a00b2877f0bb2ba
54 changes: 51 additions & 3 deletions app/api/appointments/book/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";

export async function POST(_req: NextRequest) {
return NextResponse.json({ error: "Not implemented" }, { status: 501 });
}
export async function POST(req: NextRequest) {
try {
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);

// Get user from cookie
const token = req.headers.get("authorization")?.replace("Bearer ", "");
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

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

const { slotId, doctorId } = await req.json();
if (!slotId || !doctorId) return NextResponse.json({ error: "Missing fields" }, { status: 400 });

// Check slot exists and is not already booked
const { data: existingAppt } = await supabaseAdmin
.from("appointments")
.select("id")
.eq("slot_id", slotId)
.eq("status", ["active", "done"])
.maybeSingle();

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 .eq() filter in the Supabase client does not support arrays for 'IN' logic. This query will fail to find existing appointments correctly because it will look for a status that literally matches the string representation of the array. Use the .in() method instead.

Suggested change
.maybeSingle();
.in("status", ["active", "done"])


if (existingAppt) return NextResponse.json({ error: "Slot is already booked" }, { status: 409 });
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Check patient doesn't already have active appointment with this doctor
const { data: duplicate } = await supabaseAdmin
.from("appointments")
.select("id")
.eq("patient_id", user.id)
.eq("doctor_id", doctorId)
.eq("status", "active")
.maybeSingle();
Comment on lines +31 to +38

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
sed -n '20,70p' app/api/appointments/book/route.ts

Repository: botcode-com/task

Length of output: 1825


Reorder the slot lookup before the duplicate check, and fix the property-name mismatch in the insert.

Line 36 references slot.doctor_id before slot is declared on Line 44 — this violates JavaScript's temporal dead zone and would cause a runtime error. Additionally, Line 61 inserts slot.doctorId (camelCase), but the database returns doctor_id (snake_case), resulting in doctor_id: undefined being written to the appointments table.

Move the slot fetch (lines 44–48) before the duplicate check (lines 31–38) and use slot.doctor_id instead of slot.doctorId in the insert statement.

Fix
+    const { data: slot } = await supabaseAdmin
+      .from("slots")
+      .select("start_time, doctor_id")
+      .eq("id", slotId)
+      .single();
+
+    if (!slot) {
+      return NextResponse.json({ error: "Slot not found" }, { status: 404 });
+    }
+
     // Check slot exists and is not already booked
     const { data: existingAppt } = await supabaseAdmin
       .from("appointments")
       .select("id")
       .eq("slot_id", slotId)
       .in("status", ["active", "done"])
       .maybeSingle();

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

     // Check patient doesn't already have active appointment with this doctor
     const { data: duplicate } = await supabaseAdmin
       .from("appointments")
       .select("id")
       .eq("patient_id", user.id)
       .eq("doctor_id", slot.doctor_id)
       .eq("status", "active")
       .maybeSingle();

     if (duplicate) return NextResponse.json({ error: "You already have an active appointment with this doctor" }, { status: 409 });

     const now = new Date().toISOString();

-    const { data: slot } = await supabaseAdmin
-      .from("slots")
-      .select("start_time, doctor_id")
-      .eq("id", slotId)
-      .single();
-
-    if (!slot) {
-      return NextResponse.json({ error: "Slot not found" }, { status: 404 });
-    }
-
     if (new Date(slot.start_time) < new Date()) {
       return NextResponse.json({ error: "Cannot book past slots" }, { status: 400 });
     }

     // Book it
     const { error: insertError } = await supabaseAdmin
       .from("appointments")
-      .insert({ patient_id: user.id, doctor_id: slot.doctorId, slot_id: slotId, status: "active" });
+      .insert({ patient_id: user.id, doctor_id: slot.doctor_id, slot_id: slotId, status: "active" });
🤖 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 31 - 38, The duplicate-check
queries the appointments table using slot.doctor_id before slot is fetched,
causing a TDZ/runtime error and the subsequent insert uses the wrong property
name; move the slot fetch (the code that assigns slot from supabaseAdmin /
.from("slots") / .select(...)) to run before the duplicate check, then update
the insert into "appointments" to use slot.doctor_id (snake_case) instead of
slot.doctorId so the correct doctor_id value is stored; ensure any references to
slot in the duplicate-check and insert use the fetched slot variable and its
snake_case fields.


if (duplicate) return NextResponse.json({ error: "You already have an active appointment with this doctor" }, { status: 409 });
Comment on lines +31 to +40

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

Consider using the existing canBookWithDoctor validation helper.

The duplicate appointment check duplicates logic already implemented in lib/validation.ts:canBookWithDoctor(). Reusing that helper would improve maintainability and ensure consistent validation across the codebase.

🤖 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 31 - 40, The
duplicate-active-appointment check in route.ts duplicates existing logic;
replace the manual supabaseAdmin query and local duplicate handling with the
shared validation helper canBookWithDoctor from lib/validation.ts: call
canBookWithDoctor(user.id, doctorId) (or the helper's exact signature) and use
its result/throw to decide failure, removing the manual .from("appointments")
.select(...).maybeSingle() block and the duplicate variable, and return the same
409 NextResponse when the helper indicates booking is not allowed.


// Book it
const { error: insertError } = await supabaseAdmin
.from("appointments")
.insert({ patient_id: user.id, doctor_id: doctorId, slot_id: slotId, status: "active" });

if (insertError) return NextResponse.json({ error: "Failed to book" }, { status: 500 });

return NextResponse.json({ message: "Appointment booked successfully" });
} catch {
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
54 changes: 51 additions & 3 deletions app/api/appointments/cancel/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";

export async function POST(_req: NextRequest) {
return NextResponse.json({ error: "Not implemented" }, { status: 501 });
}
export async function POST(req: NextRequest) {
try {
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);

const token = req.headers.get("authorization")?.replace("Bearer ", "");
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

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

const { appointmentId, action } = await req.json();
if (!appointmentId || !action) return NextResponse.json({ error: "Missing fields" }, { status: 400 });
if (!["done", "cancel"].includes(action)) return NextResponse.json({ error: "Invalid action" }, { status: 400 });

Comment on lines +26 to +31

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'action: "cancel"|const newStatus|includes\(action\)' app/patient/dashboard/page.tsx app/doctor/dashboard/page.tsx app/api/appointments/cancel/route.ts

Repository: botcode-com/task

Length of output: 495


🏁 Script executed:

rg -n 'action: "' app/doctor/dashboard/page.tsx

Repository: botcode-com/task

Length of output: 145


🏁 Script executed:

rg -n 'handleAction\(' app/doctor/dashboard/page.tsx -A 2

Repository: botcode-com/task

Length of output: 729


Accept "cancel" in the action allowlist and normalize it once.

Both dashboards send action: "cancel", but Line 28 only accepts "done" and "cancelled". This causes every cancel request to fail with 400 and leaves the patient-specific checks on Lines 60-67 dead code. Additionally, newStatus is declared twice in the same scope (Lines 30 and 77), causing a compile error.

Fix
     const { appointmentId, action } = await req.json();
     if (!appointmentId || !action) return NextResponse.json({ error: "Missing fields" }, { status: 400 });
-    if (!["done", "cancelled"].includes(action)) return NextResponse.json({ error: "Invalid action" }, { status: 400 });
-
-    const newStatus = action;
+    if (!["done", "cancel"].includes(action)) return NextResponse.json({ error: "Invalid action" }, { status: 400 });
+    const newStatus = action === "done" ? "done" : "cancelled";

Then remove the redeclaration at Line 77 (the second const newStatus = assignment).

📝 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 { appointmentId, action } = await req.json();
if (!appointmentId || !action) return NextResponse.json({ error: "Missing fields" }, { status: 400 });
if (!["done", "cancelled"].includes(action)) return NextResponse.json({ error: "Invalid action" }, { status: 400 });
const newStatus = action;
const { appointmentId, action } = await req.json();
if (!appointmentId || !action) return NextResponse.json({ error: "Missing fields" }, { status: 400 });
if (!["done", "cancel"].includes(action)) return NextResponse.json({ error: "Invalid action" }, { status: 400 });
const newStatus = action === "done" ? "done" : "cancelled";
🤖 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 26 - 31, The action
allowlist only accepts "done" and "cancelled" but dashboards send "cancel", and
newStatus is declared twice causing a compile error; update validation to accept
"cancel" (or normalize "cancel" to "cancelled") when reading const {
appointmentId, action } = await req.json(), set a single normalized newStatus
variable (e.g., map "cancel" -> "cancelled") immediately after validation, and
remove the later redeclaration of newStatus so there's only one declaration in
the scope; ensure NextResponse.json error branches remain unchanged and that the
patient-specific checks that depend on newStatus use the normalized variable.

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 });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Comment on lines +32 to +45

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

🌐 Web query:

In @supabase/supabase-jsv2, what does.single()return when zero rows match, and when should.maybeSingle() be used instead?

💡 Result:

In @supabase/supabase-js v2, .single returns an error (typically status 406 with message "JSON object requested, multiple (or no) rows returned") when zero rows match the query. It expects exactly one row and throws an error for zero or multiple rows. Use .maybeSingle instead when the query might return zero or one row (e.g., lookups where the record may not exist). It returns data: null and error: null for zero rows, or the single object for one row, but errors if more than one row is found.

Citations:


.single() makes the 404 branch unreachable.

When appointmentId doesn't exist, .single() returns an error (Supabase returns status 406 when zero rows match), which is caught and returns a 500 response. The 404 check on line 44 never executes for missing records.

Use .maybeSingle() instead. It returns data: null with no error when zero rows match, allowing the downstream null check to return 404 correctly.

Suggested fix
     const { data, error } = await supabaseAdmin
       .from("appointments")
       .select("id, status, patient_id, doctor_id, slot_id, slots(start_time)")
       .eq("id", appointmentId)
-      .single();
+      .maybeSingle();
 
     if (error) {
       return NextResponse.json({ error: "Failed to fetch appointment" }, { status: 500 });
     }
 
     const appointment = data as AppointmentWithSlot | null;
 
     if (!appointment) return NextResponse.json({ error: "Appointment not found" }, { status: 404 });
📝 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, error } = await supabaseAdmin
.from("appointments")
.select("id, status, patient_id, doctor_id, slot_id, slots(start_time)")
.eq("id", appointmentId)
.single();
if (error) {
return NextResponse.json({ error: "Failed to fetch appointment" }, { status: 500 });
}
const appointment = data as AppointmentWithSlot | null;
if (!appointment) return NextResponse.json({ error: "Appointment not found" }, { status: 404 });
const { data, error } = await supabaseAdmin
.from("appointments")
.select("id, status, patient_id, doctor_id, slot_id, slots(start_time)")
.eq("id", appointmentId)
.maybeSingle();
if (error) {
return NextResponse.json({ error: "Failed to fetch appointment" }, { status: 500 });
}
const appointment = data as AppointmentWithSlot | null;
if (!appointment) return NextResponse.json({ error: "Appointment not found" }, { status: 404 });
🤖 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 32 - 45, The current
Supabase query uses .single() which causes missing appointments to surface as an
error (preventing the 404 path); change the query chain on
supabaseAdmin.from("appointments").select(...).eq("id", appointmentId).single()
to use .maybeSingle() so data will be null instead of an error when no row
matches, then keep the existing null check of appointment (AppointmentWithSlot |
null) to return the 404; ensure existing error handling still returns 500 only
for real query errors (i.e., when error is set and data is undefined).

const isDoctor = appointment.doctor_id === user.id;
const isPatient = appointment.patient_id === user.id;

if (!isDoctor && !isPatient) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
if (isPatient && action === "done") return NextResponse.json({ error: "Patients cannot mark as done" }, { status: 403 });
if (appointment.status !== "active") return NextResponse.json({ error: `Appointment is already ${appointment.status}` }, { status: 409 });

if (isPatient && action === "cancel") {
const startTime = new Date((appointment.slots as any).start_time).getTime();
if (startTime - Date.now() < 60 * 60 * 1000) {
return NextResponse.json({ error: "Cannot cancel within 1 hour of appointment" }, { status: 409 });
}
}
Comment on lines +60 to +68

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

Consider using the existing canCancelAppointment validation helper.

The 1-hour cancellation restriction logic duplicates lib/validation.ts:canCancelAppointment(). Reusing that helper would improve maintainability and ensure consistent validation with unit tests.

🤖 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 36 - 41, Replace the
inlined 1-hour check with the existing canCancelAppointment helper: import
canCancelAppointment from lib/validation.ts, call
canCancelAppointment(appointment) inside the isPatient && action === "cancel"
branch, and if it returns false return the same NextResponse.json({ error:
"Cannot cancel within 1 hour of appointment" }, { status: 409 }); remove the
duplicated new Date(...) logic so the route uses the shared validation function.


const newStatus = action === "done" ? "done" : "cancelled";
const { error: updateError } = await supabaseAdmin
.from("appointments").update({ status: newStatus }).eq("id", appointmentId);

if (updateError) return NextResponse.json({ error: "Failed to update" }, { status: 500 });

return NextResponse.json({ message: "Updated successfully" });
} catch {
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
111 changes: 43 additions & 68 deletions app/doctor/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ type Slot = {
id: string;
start_time: string;
end_time: string;
is_booked: boolean;
};

type Appointment = {
id: string;
status: "active" | "done" | "cancelled";
created_at: string;
slot_id: string;
patients: { name: string };
slots: { start_time: string; end_time: string };
};
Expand All @@ -43,57 +43,47 @@ export default function DoctorDashboard() {

useEffect(() => {
async function load() {
const {
data: { user },
} = await supabase.auth.getUser();

if (!user) {
router.push("/doctor/login");
return;
}
const { data: { user } } = await supabase.auth.getUser();
if (!user) { router.push("/doctor/login"); return; }

const { data: doctorData } = await supabase
.from("doctors")
.select("*")
.eq("id", user.id)
.single();

if (!doctorData) {
router.push("/doctor/login");
return;
}

.from("doctors").select("*").eq("id", user.id).single();
if (!doctorData) { router.push("/doctor/login"); return; }
setDoctor(doctorData);

const now = new Date().toISOString();

const { data: slotData } = await supabase
.from("slots")
.select("*")
.from("slots").select("id, start_time, end_time")
.eq("doctor_id", user.id)
.gte("start_time", now)
.order("start_time");

setSlots(slotData ?? []);

const { data: apptData } = await supabase
.from("appointments")
.select("id, status, created_at, patients(name), slots(start_time, end_time)")
.select("id, status, created_at, slot_id, patients(name), slots(start_time, end_time)")
.eq("doctor_id", user.id)
.order("created_at", { ascending: false });

setAppointments((apptData as Appointment[]) ?? []);
setLoading(false);
}

load();
}, [router]);

async function handleAction(
appointmentId: string,
action: "done" | "cancel"
) {
function isSlotBooked(slotId: string) {
return appointments.some((a) => a.slot_id === slotId && a.status === "active");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function handleAction(appointmentId: string, 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 }),
});
const data = await res.json();
Expand Down Expand Up @@ -132,24 +122,25 @@ export default function DoctorDashboard() {
<h1 className="text-2xl font-bold">{doctor?.name}</h1>
<p className="text-sm text-gray-500">{doctor?.specialty}</p>
</div>
<button
onClick={handleLogout}
className="rounded-lg border px-4 py-2 text-sm text-gray-600 hover:bg-gray-100"
>
<button onClick={handleLogout} className="rounded-lg border px-4 py-2 text-sm text-gray-600 hover:bg-gray-100">
Logout
</button>
</div>

{actionMsg && (
<p className="mb-4 rounded-lg bg-blue-50 px-4 py-2 text-sm text-blue-700">
<p className={`mb-4 rounded-lg px-4 py-2 text-sm ${
actionMsg.includes("successfully") || actionMsg.includes("Updated")
? "bg-green-50 text-green-700"
: "bg-red-50 text-red-700"
}`}>
{actionMsg}
</p>
)}

<section className="mb-8">
<h2 className="mb-3 text-lg font-semibold">My Slots</h2>
{slots.length === 0 ? (
<p className="text-sm text-gray-500">No slots found.</p>
<p className="text-sm text-gray-500">No upcoming slots.</p>
) : (
<div className="overflow-hidden rounded-xl border bg-white">
<table className="w-full text-sm">
Expand All @@ -164,19 +155,13 @@ export default function DoctorDashboard() {
<tr key={slot.id}>
<td className="px-4 py-3">
{formatDateTime(slot.start_time)} —{" "}
{new Date(slot.end_time).toLocaleTimeString("en-IN", {
timeStyle: "short",
})}
{new Date(slot.end_time).toLocaleTimeString("en-IN", { timeStyle: "short" })}
</td>
<td className="px-4 py-3">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
slot.is_booked
? "bg-red-100 text-red-700"
: "bg-green-100 text-green-700"
}`}
>
{slot.is_booked ? "Booked" : "Available"}
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
isSlotBooked(slot.id) ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"
}`}>
{isSlotBooked(slot.id) ? "Booked" : "Available"}
</span>
</td>
</tr>
Expand Down Expand Up @@ -206,35 +191,25 @@ export default function DoctorDashboard() {
{appointments.map((appt) => (
<tr key={appt.id}>
<td className="px-4 py-3">{appt.patients?.name}</td>
<td className="px-4 py-3">{formatDateTime(appt.slots?.start_time)}</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"
}`}
>
<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">
{appt.status === "active" && (
<div className="flex gap-2">
<button
onClick={() => handleAction(appt.id, "done")}
className="rounded bg-green-600 px-3 py-1 text-xs text-white hover:bg-green-700"
>
<button onClick={() => handleAction(appt.id, "done")}
className="rounded bg-green-600 px-3 py-1 text-xs text-white hover:bg-green-700">
Done
</button>
<button
onClick={() => handleAction(appt.id, "cancel")}
className="rounded bg-red-600 px-3 py-1 text-xs text-white hover:bg-red-700"
>
<button onClick={() => handleAction(appt.id, "cancel")}
className="rounded bg-red-600 px-3 py-1 text-xs text-white hover:bg-red-700">
Cancel
</button>
</div>
Expand All @@ -250,4 +225,4 @@ export default function DoctorDashboard() {
</div>
</main>
);
}
}
Loading