From cbf959ec12c6b0b7e6f9418027c207e9f5dc8f5a Mon Sep 17 00:00:00 2001 From: brook-studio Date: Mon, 27 Apr 2026 00:17:52 +0530 Subject: [PATCH 1/3] inital commit for this PR --- app/admin/dashboard/page.tsx | 122 ++++ app/admin/login/page.tsx | 81 +++ app/api/admin/appointments/route.ts | 21 + app/api/admin/login/route.ts | 25 + app/api/admin/logout/route.ts | 7 + app/api/appointments/book/route.ts | 56 +- app/api/appointments/cancel/route.ts | 53 +- app/doctor/dashboard/page.tsx | 8 +- app/page.tsx | 6 + app/patient/dashboard/page.tsx | 16 +- lib/appointments.ts | 27 + lib/supabase-server.ts | 7 + package-lock.json | 877 ++++++++++++++++++++++++- package.json | 13 +- tests/integration/booking.test.ts | 78 +++ tests/integration/cancellation.test.ts | 84 +++ tests/setup.ts | 5 + tests/unit/book.test.ts | 22 + tests/unit/cancel.test.ts | 34 + vitest.config.ts | 16 + 20 files changed, 1545 insertions(+), 13 deletions(-) create mode 100644 app/admin/dashboard/page.tsx create mode 100644 app/admin/login/page.tsx create mode 100644 app/api/admin/appointments/route.ts create mode 100644 app/api/admin/login/route.ts create mode 100644 app/api/admin/logout/route.ts create mode 100644 lib/appointments.ts create mode 100644 lib/supabase-server.ts create mode 100644 tests/integration/booking.test.ts create mode 100644 tests/integration/cancellation.test.ts create mode 100644 tests/setup.ts create mode 100644 tests/unit/book.test.ts create mode 100644 tests/unit/cancel.test.ts create mode 100644 vitest.config.ts diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..ee1185c --- /dev/null +++ b/app/admin/dashboard/page.tsx @@ -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([]); + 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 ( +
+

Loading...

+
+ ); + } + + return ( +
+
+
+

Admin Dashboard

+ +
+ +
+

All Appointments

+ {appointments.length === 0 ? ( +

No appointments found.

+ ) : ( +
+ + + + + + + + + + + + + {appointments.map((appt) => ( + + + + + + + + + ))} + +
PatientDoctorSpecialtySlotStatusCreated
{appt.patients?.name}{appt.doctors?.name} + {appt.doctors?.specialty} + + {formatDateTime(appt.slots?.start_time)} + + + {appt.status} + + + {formatDateTime(appt.created_at)} +
+
+ )} +
+
+
+ ); +} diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx new file mode 100644 index 0000000..a2af0a0 --- /dev/null +++ b/app/admin/login/page.tsx @@ -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); + } + } + + return ( +
+
+

Admin Login

+
+
+ + 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" + /> +
+
+ + 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" + /> +
+ {error &&

{error}

} + +
+

+ + ← Back to home + +

+
+
+ ); +} diff --git a/app/api/admin/appointments/route.ts b/app/api/admin/appointments/route.ts new file mode 100644 index 0000000..737d3fe --- /dev/null +++ b/app/api/admin/appointments/route.ts @@ -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 }); + + return NextResponse.json({ appointments: appointments ?? [] }); +} diff --git a/app/api/admin/login/route.ts b/app/api/admin/login/route.ts new file mode 100644 index 0000000..636a817 --- /dev/null +++ b/app/api/admin/login/route.ts @@ -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 }); + + const { data: admin } = await supabaseAdmin + .from("system_admins") + .select("id, email") + .eq("email", email) + .eq("password", password) + .single(); + if (!admin) return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); + + const res = NextResponse.json({ success: true }); + res.cookies.set("admin_session", admin.id, { + httpOnly: true, + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 8, + }); + return res; +} diff --git a/app/api/admin/logout/route.ts b/app/api/admin/logout/route.ts new file mode 100644 index 0000000..9a71fcc --- /dev/null +++ b/app/api/admin/logout/route.ts @@ -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; +} diff --git a/app/api/appointments/book/route.ts b/app/api/appointments/book/route.ts index 4fcd17b..0221a6b 100644 --- a/app/api/appointments/book/route.ts +++ b/app/api/appointments/book/route.ts @@ -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 ", ""); + 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 }); + + 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 }); + + 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); + + return NextResponse.json({ appointment }); } diff --git a/app/api/appointments/cancel/route.ts b/app/api/appointments/cancel/route.ts index 4fcd17b..5596051 100644 --- a/app/api/appointments/cancel/route.ts +++ b/app/api/appointments/cancel/route.ts @@ -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 }); + 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 }); + + 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 }); } diff --git a/app/doctor/dashboard/page.tsx b/app/doctor/dashboard/page.tsx index 3f89c4c..0198122 100644 --- a/app/doctor/dashboard/page.tsx +++ b/app/doctor/dashboard/page.tsx @@ -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 }), }); const data = await res.json(); diff --git a/app/page.tsx b/app/page.tsx index 0daa930..2be4813 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -17,6 +17,12 @@ export default function Home() { > Patient Login + + Admin Login + ); diff --git a/app/patient/dashboard/page.tsx b/app/patient/dashboard/page.tsx index 443e06c..b5f1d8a 100644 --- a/app/patient/dashboard/page.tsx +++ b/app/patient/dashboard/page.tsx @@ -95,9 +95,15 @@ export default function PatientDashboard() { setActionMsg(""); setBookingSlotId(slotId); + const { + data: { session }, + } = await supabase.auth.getSession(); const res = await fetch("/api/appointments/book", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.access_token ?? ""}`, + }, body: JSON.stringify({ slotId, doctorId }), }); const data = await res.json(); @@ -114,9 +120,15 @@ export default function PatientDashboard() { async function handleCancel(appointmentId: string) { 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: "cancel" }), }); const data = await res.json(); diff --git a/lib/appointments.ts b/lib/appointments.ts new file mode 100644 index 0000000..596009d --- /dev/null +++ b/lib/appointments.ts @@ -0,0 +1,27 @@ +export type AppointmentStatus = "active" | "done" | "cancelled"; + +export function validateSlotAvailable(isBooked: boolean): string | null { + return isBooked ? "Slot is already booked" : null; +} + +export function validateAppointmentActive(status: AppointmentStatus): string | null { + return status !== "active" ? `Appointment is already ${status}` : null; +} + +export function validatePatientCancelTime( + startTime: string, + isDoctor: boolean, + now = Date.now() +): string | null { + if (isDoctor) return null; + const diffHours = (new Date(startTime).getTime() - now) / (1000 * 60 * 60); + return diffHours < 1 + ? "Cannot cancel an appointment starting in less than 1 hour" + : null; +} + +export function validateNoActiveAppointmentWithDoctor(hasActive: boolean): string | null { + return hasActive + ? "You already have an active appointment with this doctor" + : null; +} diff --git a/lib/supabase-server.ts b/lib/supabase-server.ts new file mode 100644 index 0000000..67d179a --- /dev/null +++ b/lib/supabase-server.ts @@ -0,0 +1,7 @@ +import { createClient } from "@supabase/supabase-js"; + +export const supabaseAdmin = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY!, + { auth: { autoRefreshToken: false, persistSession: false } } +); diff --git a/package-lock.json b/package-lock.json index 9054318..ed860da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "@types/react": "^19", "@types/react-dom": "^19", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.5" } }, "node_modules/@alloc/quick-lru": { @@ -34,6 +35,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -43,6 +56,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -529,6 +553,25 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@next/env": { "version": "15.3.1", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.1.tgz", @@ -654,6 +697,287 @@ "node": ">= 10" } }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.104.1", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.104.1.tgz", @@ -1002,6 +1326,42 @@ "tailwindcss": "4.2.4" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", @@ -1036,6 +1396,129 @@ "@types/node": "*" } }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -1066,11 +1549,28 @@ } ] }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1099,6 +1599,66 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1478,11 +2038,42 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -1530,6 +2121,40 @@ "react": "^19.2.5" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1591,6 +2216,13 @@ "@img/sharp-win32-x64": "0.34.5" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1599,6 +2231,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -1648,6 +2294,50 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1671,6 +2361,191 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", diff --git a/package.json b/package.json index 454e4cc..fda08e9 100644 --- a/package.json +++ b/package.json @@ -6,20 +6,23 @@ "dev": "next dev", "build": "next build", "start": "next start", - "seed": "node --env-file=.env seed.mjs" + "seed": "node --env-file=.env seed.mjs", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { + "@supabase/supabase-js": "^2.49.4", "next": "15.3.1", "react": "^19.0.0", - "react-dom": "^19.0.0", - "@supabase/supabase-js": "^2.49.4" + "react-dom": "^19.0.0" }, "devDependencies": { - "typescript": "^5", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "tailwindcss": "^4", - "@tailwindcss/postcss": "^4" + "typescript": "^5", + "vitest": "^4.1.5" } } diff --git a/tests/integration/booking.test.ts b/tests/integration/booking.test.ts new file mode 100644 index 0000000..9b9197a --- /dev/null +++ b/tests/integration/booking.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createClient } from "@supabase/supabase-js"; +import { NextRequest } from "next/server"; +import { POST } from "@/app/api/appointments/book/route"; +import { supabaseAdmin } from "@/lib/supabase-server"; + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + +describe("Booking Flow", () => { + let token: string; + let patientId: string; + let slotId: string; + let doctorId: string; + let createdAppointmentId: string | null = null; + + beforeAll(async () => { + // Sign in as patient2 — no pre-existing appointments from seed + const client = createClient(supabaseUrl, supabaseAnonKey); + const { + data: { session }, + } = await client.auth.signInWithPassword({ + email: "patient2@test.com", + password: "patient123", + }); + token = session!.access_token; + patientId = session!.user.id; + + // Find any available slot + const { data: slot } = await supabaseAdmin + .from("slots") + .select("id, doctor_id") + .eq("is_booked", false) + .limit(1) + .single(); + slotId = slot!.id; + doctorId = slot!.doctor_id; + }); + + afterAll(async () => { + if (createdAppointmentId) { + await supabaseAdmin.from("appointments").delete().eq("id", createdAppointmentId); + } + await supabaseAdmin.from("slots").update({ is_booked: false }).eq("id", slotId); + }); + + it("creates an appointment in the database when a patient books an available slot", async () => { + const req = new NextRequest("http://localhost/api/appointments/book", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ slotId, doctorId }), + }); + + const res = await POST(req); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.appointment).toBeDefined(); + expect(data.appointment.patient_id).toBe(patientId); + expect(data.appointment.status).toBe("active"); + + createdAppointmentId = data.appointment.id; + + // Verify the appointment exists in the database + const { data: dbAppt } = await supabaseAdmin + .from("appointments") + .select("id, status, patient_id") + .eq("id", createdAppointmentId!) + .single(); + + expect(dbAppt).not.toBeNull(); + expect(dbAppt!.status).toBe("active"); + expect(dbAppt!.patient_id).toBe(patientId); + }); +}); diff --git a/tests/integration/cancellation.test.ts b/tests/integration/cancellation.test.ts new file mode 100644 index 0000000..348f769 --- /dev/null +++ b/tests/integration/cancellation.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createClient } from "@supabase/supabase-js"; +import { NextRequest } from "next/server"; +import { POST } from "@/app/api/appointments/cancel/route"; +import { supabaseAdmin } from "@/lib/supabase-server"; + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + +describe("Cancellation Flow", () => { + let token: string; + let appointmentId: string; + let testSlotId: string; + + beforeAll(async () => { + // Sign in as patient3 — separate from booking integration test + const client = createClient(supabaseUrl, supabaseAnonKey); + const { + data: { session }, + } = await client.auth.signInWithPassword({ + email: "patient3@test.com", + password: "patient123", + }); + token = session!.access_token; + const patientId = session!.user.id; + + // Find a slot that starts more than 1 hour from now (required for patient cancellation) + const { data: slot } = await supabaseAdmin + .from("slots") + .select("id, doctor_id") + .eq("is_booked", false) + .gt("start_time", new Date(Date.now() + 60 * 60 * 1000).toISOString()) + .limit(1) + .single(); + + testSlotId = slot!.id; + + // Create a test appointment directly in the database + const { data: appt } = await supabaseAdmin + .from("appointments") + .insert({ + patient_id: patientId, + doctor_id: slot!.doctor_id, + slot_id: testSlotId, + status: "active", + }) + .select() + .single(); + + appointmentId = appt!.id; + await supabaseAdmin.from("slots").update({ is_booked: true }).eq("id", testSlotId); + }); + + afterAll(async () => { + await supabaseAdmin.from("appointments").delete().eq("id", appointmentId); + await supabaseAdmin.from("slots").update({ is_booked: false }).eq("id", testSlotId); + }); + + it("updates the appointment status to cancelled in the database", async () => { + const req = new NextRequest("http://localhost/api/appointments/cancel", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ appointmentId, action: "cancel" }), + }); + + const res = await POST(req); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.status).toBe("cancelled"); + + // Verify the status was updated in the database + const { data: dbAppt } = await supabaseAdmin + .from("appointments") + .select("status") + .eq("id", appointmentId) + .single(); + + expect(dbAppt!.status).toBe("cancelled"); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..ac20319 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,5 @@ +try { + process.loadEnvFile(".env"); +} catch { + // .env not found — environment variables should be set externally +} diff --git a/tests/unit/book.test.ts b/tests/unit/book.test.ts new file mode 100644 index 0000000..35f5173 --- /dev/null +++ b/tests/unit/book.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { validateSlotAvailable, validateNoActiveAppointmentWithDoctor } from "@/lib/appointments"; + +describe("validateSlotAvailable", () => { + it("returns an error when the slot is already booked", () => { + expect(validateSlotAvailable(true)).not.toBeNull(); + }); + + it("returns null when the slot is available", () => { + expect(validateSlotAvailable(false)).toBeNull(); + }); +}); + +describe("validateNoActiveAppointmentWithDoctor", () => { + it("returns an error when the patient already has an active appointment with this doctor", () => { + expect(validateNoActiveAppointmentWithDoctor(true)).not.toBeNull(); + }); + + it("returns null when the patient has no active appointment with this doctor", () => { + expect(validateNoActiveAppointmentWithDoctor(false)).toBeNull(); + }); +}); diff --git a/tests/unit/cancel.test.ts b/tests/unit/cancel.test.ts new file mode 100644 index 0000000..be7bbcd --- /dev/null +++ b/tests/unit/cancel.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { validateAppointmentActive, validatePatientCancelTime } from "@/lib/appointments"; + +describe("validateAppointmentActive", () => { + it("returns an error when the appointment is already done", () => { + expect(validateAppointmentActive("done")).not.toBeNull(); + }); + + it("returns an error when the appointment is already cancelled", () => { + expect(validateAppointmentActive("cancelled")).not.toBeNull(); + }); + + it("returns null when the appointment is active", () => { + expect(validateAppointmentActive("active")).toBeNull(); + }); +}); + +describe("validatePatientCancelTime", () => { + const now = Date.now(); + const in30Min = new Date(now + 30 * 60 * 1000).toISOString(); + const in2Hours = new Date(now + 2 * 60 * 60 * 1000).toISOString(); + + it("returns an error when a patient tries to cancel within 1 hour of the appointment", () => { + expect(validatePatientCancelTime(in30Min, false, now)).not.toBeNull(); + }); + + it("returns null when a patient cancels more than 1 hour before the appointment", () => { + expect(validatePatientCancelTime(in2Hours, false, now)).toBeNull(); + }); + + it("returns null when a doctor cancels within 1 hour of the appointment", () => { + expect(validatePatientCancelTime(in30Min, true, now)).toBeNull(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..5918997 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + setupFiles: ["./tests/setup.ts"], + fileParallelism: false, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + }, + }, +}); From 94cb22e9c8b274a6c66f3610306b67243ebc49ad Mon Sep 17 00:00:00 2001 From: brook-studio Date: Mon, 27 Apr 2026 01:39:47 +0530 Subject: [PATCH 2/3] few changes maded in terms fo security --- app/admin/dashboard/page.tsx | 46 +++++++++++++++++++++----- app/admin/login/page.tsx | 25 ++++++++------ app/api/admin/appointments/route.ts | 29 ++++++++++------ app/api/admin/login/route.ts | 26 +++++++++------ app/api/admin/logout/route.ts | 3 +- app/api/appointments/book/route.ts | 41 +++++++++++++++-------- app/api/appointments/cancel/route.ts | 24 ++++++++++++-- app/doctor/dashboard/page.tsx | 6 +++- app/patient/dashboard/page.tsx | 12 +++++-- lib/appointments.ts | 4 ++- lib/session.ts | 41 +++++++++++++++++++++++ package-lock.json | 17 ++++++++++ package.json | 5 +++ schema.sql | 4 +-- seed.mjs | 9 +++++ tests/integration/booking.test.ts | 24 ++++++++------ tests/integration/cancellation.test.ts | 33 +++++++++--------- tests/setup.ts | 4 +++ tests/unit/book.test.ts | 6 ++-- 19 files changed, 268 insertions(+), 91 deletions(-) create mode 100644 lib/session.ts diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx index ee1185c..03b3907 100644 --- a/app/admin/dashboard/page.tsx +++ b/app/admin/dashboard/page.tsx @@ -23,21 +23,34 @@ export default function AdminDashboard() { const router = useRouter(); const [appointments, setAppointments] = useState([]); const [loading, setLoading] = useState(true); + const [page, setPage] = useState(0); + const [hasMore, setHasMore] = useState(false); useEffect(() => { + let cancelled = false; async function load() { - const res = await fetch("/api/admin/appointments"); - if (!res.ok) { - router.push("/admin/login"); - return; + 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); } - const data = await res.json(); - setAppointments(data.appointments); - setLoading(false); } load(); - }, [router]); + return () => { cancelled = true; }; + }, [page, router]); async function handleLogout() { await fetch("/api/admin/logout", { method: "POST" }); @@ -115,6 +128,23 @@ export default function AdminDashboard() { )} +
+ + Page {page + 1} + +
diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx index a2af0a0..589611f 100644 --- a/app/admin/login/page.tsx +++ b/app/admin/login/page.tsx @@ -16,17 +16,22 @@ export default function AdminLogin() { setError(""); setLoading(true); - const res = await fetch("/api/admin/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }), - }); + 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."); + 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); } } diff --git a/app/api/admin/appointments/route.ts b/app/api/admin/appointments/route.ts index 737d3fe..b5208e9 100644 --- a/app/api/admin/appointments/route.ts +++ b/app/api/admin/appointments/route.ts @@ -1,21 +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 adminId = req.cookies.get("admin_session")?.value; + 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 { data: admin } = await supabaseAdmin - .from("system_admins") - .select("id") - .eq("id", adminId) - .single(); - if (!admin) 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 } = await supabaseAdmin + 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 }); + .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: appointments ?? [] }); + return NextResponse.json({ appointments: pageData, page, hasMore }); } diff --git a/app/api/admin/login/route.ts b/app/api/admin/login/route.ts index 636a817..be3074a 100644 --- a/app/api/admin/login/route.ts +++ b/app/api/admin/login/route.ts @@ -1,25 +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) { - const { email, password } = await req.json(); + 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; if (!email || !password) return NextResponse.json({ error: "Email and password are required" }, { status: 400 }); const { data: admin } = await supabaseAdmin .from("system_admins") - .select("id, email") + .select("id, email, password") .eq("email", email) - .eq("password", password) .single(); - if (!admin) return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); + const valid = admin && (await bcrypt.compare(password as string, admin.password)); + if (!valid) return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); + + const token = createAdminSession(admin.id); const res = NextResponse.json({ success: true }); - res.cookies.set("admin_session", admin.id, { - httpOnly: true, - sameSite: "lax", - path: "/", - maxAge: 60 * 60 * 8, - }); + res.cookies.set(SESSION_COOKIE, token, { ...COOKIE_OPTIONS, maxAge: 60 * 60 * 8 }); return res; } diff --git a/app/api/admin/logout/route.ts b/app/api/admin/logout/route.ts index 9a71fcc..5303c18 100644 --- a/app/api/admin/logout/route.ts +++ b/app/api/admin/logout/route.ts @@ -1,7 +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.delete("admin_session"); + res.cookies.set(SESSION_COOKIE, "", { ...COOKIE_OPTIONS, maxAge: 0 }); return res; } diff --git a/app/api/appointments/book/route.ts b/app/api/appointments/book/route.ts index 0221a6b..3a5f543 100644 --- a/app/api/appointments/book/route.ts +++ b/app/api/appointments/book/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { supabaseAdmin } from "@/lib/supabase-server"; -import { validateSlotAvailable, validateNoActiveAppointmentWithDoctor } from "@/lib/appointments"; +import { validateNoActiveAppointmentWithDoctor } from "@/lib/appointments"; export async function POST(req: NextRequest) { const token = req.headers.get("authorization")?.replace("Bearer ", ""); @@ -19,39 +19,52 @@ export async function POST(req: NextRequest) { .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 }); + 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") - .select("id, is_booked") + .update({ is_booked: true }) .eq("id", slotId) - .single(); - if (!slot) return NextResponse.json({ error: "Slot not found" }, { status: 404 }); + .eq("is_booked", false) + .select("id, doctor_id") + .maybeSingle(); - const slotError = validateSlotAvailable(slot.is_booked); - if (slotError) return NextResponse.json({ error: slotError }, { status: 409 }); + 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", doctorId) + .eq("doctor_id", slot.doctor_id) .eq("status", "active") .maybeSingle(); const duplicateError = validateNoActiveAppointmentWithDoctor(!!existing); - if (duplicateError) return NextResponse.json({ error: duplicateError }, { status: 409 }); + 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 }); + } const { data: appointment, error: insertError } = await supabaseAdmin .from("appointments") - .insert({ patient_id: user.id, doctor_id: doctorId, slot_id: slotId }) + .insert({ patient_id: user.id, doctor_id: slot.doctor_id, 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); + 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 }); } diff --git a/app/api/appointments/cancel/route.ts b/app/api/appointments/cancel/route.ts index 5596051..d9ab613 100644 --- a/app/api/appointments/cancel/route.ts +++ b/app/api/appointments/cancel/route.ts @@ -16,7 +16,14 @@ export async function POST(req: NextRequest) { } = await supabaseAdmin.auth.getUser(token); if (authError || !user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - const { appointmentId, action } = await req.json(); + let appointmentId: string, action: string; + try { + const body = await req.json(); + appointmentId = body.appointmentId; + action = body.action; + } catch { + return NextResponse.json({ error: "Invalid or missing JSON body" }, { status: 400 }); + } if (!appointmentId || !action) return NextResponse.json({ error: "appointmentId and action are required" }, { status: 400 }); if (!["cancel", "done"].includes(action)) @@ -39,12 +46,25 @@ export async function POST(req: NextRequest) { const activeError = validateAppointmentActive(appointment.status as AppointmentStatus); if (activeError) return NextResponse.json({ error: activeError }, { status: 409 }); + 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 timeError = validatePatientCancelTime(startTime, isDoctor); if (timeError) return NextResponse.json({ error: timeError }, { status: 409 }); const newStatus = action === "done" ? "done" : "cancelled"; - await supabaseAdmin.from("appointments").update({ status: newStatus }).eq("id", appointmentId); + + // Conditional update — only updates if the appointment is still active (prevents TOCTOU) + const { data: updated } = await supabaseAdmin + .from("appointments") + .update({ status: newStatus }) + .eq("id", appointmentId) + .eq("status", "active") + .select("id") + .maybeSingle(); + + if (!updated) return NextResponse.json({ error: "Appointment is no longer active" }, { status: 409 }); if (newStatus === "cancelled") { await supabaseAdmin.from("slots").update({ is_booked: false }).eq("id", appointment.slot_id); diff --git a/app/doctor/dashboard/page.tsx b/app/doctor/dashboard/page.tsx index 0198122..55df0e4 100644 --- a/app/doctor/dashboard/page.tsx +++ b/app/doctor/dashboard/page.tsx @@ -94,11 +94,15 @@ export default function DoctorDashboard() { const { data: { session }, } = await supabase.auth.getSession(); + if (!session) { + router.push("/doctor/login"); + return; + } const res = await fetch("/api/appointments/cancel", { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${session?.access_token ?? ""}`, + Authorization: `Bearer ${session.access_token}`, }, body: JSON.stringify({ appointmentId, action }), }); diff --git a/app/patient/dashboard/page.tsx b/app/patient/dashboard/page.tsx index b5f1d8a..65695b5 100644 --- a/app/patient/dashboard/page.tsx +++ b/app/patient/dashboard/page.tsx @@ -98,11 +98,15 @@ export default function PatientDashboard() { const { data: { session }, } = await supabase.auth.getSession(); + if (!session) { + router.push("/patient/login"); + return; + } const res = await fetch("/api/appointments/book", { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${session?.access_token ?? ""}`, + Authorization: `Bearer ${session.access_token}`, }, body: JSON.stringify({ slotId, doctorId }), }); @@ -123,11 +127,15 @@ export default function PatientDashboard() { const { data: { session }, } = await supabase.auth.getSession(); + if (!session) { + router.push("/patient/login"); + return; + } const res = await fetch("/api/appointments/cancel", { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${session?.access_token ?? ""}`, + Authorization: `Bearer ${session.access_token}`, }, body: JSON.stringify({ appointmentId, action: "cancel" }), }); diff --git a/lib/appointments.ts b/lib/appointments.ts index 596009d..0c79fe5 100644 --- a/lib/appointments.ts +++ b/lib/appointments.ts @@ -14,7 +14,9 @@ export function validatePatientCancelTime( now = Date.now() ): string | null { if (isDoctor) return null; - const diffHours = (new Date(startTime).getTime() - now) / (1000 * 60 * 60); + const startMs = new Date(startTime).getTime(); + if (Number.isNaN(startMs)) return "Invalid appointment start time"; + const diffHours = (startMs - now) / (1000 * 60 * 60); return diffHours < 1 ? "Cannot cancel an appointment starting in less than 1 hour" : null; diff --git a/lib/session.ts b/lib/session.ts new file mode 100644 index 0000000..ff803be --- /dev/null +++ b/lib/session.ts @@ -0,0 +1,41 @@ +import { createHmac } from "crypto"; + +const SESSION_DURATION_MS = 8 * 60 * 60 * 1000; // 8 hours + +function getSecret(): string { + const secret = process.env.ADMIN_SESSION_SECRET; + if (!secret) throw new Error("ADMIN_SESSION_SECRET is not set"); + return secret; +} + +export function createAdminSession(adminId: string): string { + const exp = Date.now() + SESSION_DURATION_MS; + const payload = Buffer.from(JSON.stringify({ adminId, exp })).toString("base64url"); + const sig = createHmac("sha256", getSecret()).update(payload).digest("base64url"); + return `${payload}.${sig}`; +} + +export function verifyAdminSession(token: string): string | null { + const dotIdx = token.lastIndexOf("."); + if (dotIdx === -1) return null; + const payload = token.slice(0, dotIdx); + const sig = token.slice(dotIdx + 1); + const expected = createHmac("sha256", getSecret()).update(payload).digest("base64url"); + if (sig !== expected) return null; + try { + const { adminId, exp } = JSON.parse(Buffer.from(payload, "base64url").toString()); + if (Date.now() > exp) return null; + return adminId as string; + } catch { + return null; + } +} + +export const SESSION_COOKIE = "admin_session"; + +export const COOKIE_OPTIONS = { + httpOnly: true, + sameSite: "lax" as const, + path: "/", + secure: process.env.NODE_ENV === "production", +}; diff --git a/package-lock.json b/package-lock.json index ed860da..b39ac57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "@supabase/supabase-js": "^2.49.4", + "@types/bcryptjs": "^2.4.6", + "bcryptjs": "^3.0.3", "next": "15.3.1", "react": "^19.0.0", "react-dom": "^19.0.0" @@ -1337,6 +1339,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1519,6 +1527,15 @@ "node": ">=12" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", diff --git a/package.json b/package.json index fda08e9..96afc7b 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,15 @@ }, "dependencies": { "@supabase/supabase-js": "^2.49.4", + "@types/bcryptjs": "^2.4.6", + "bcryptjs": "^3.0.3", "next": "15.3.1", "react": "^19.0.0", "react-dom": "^19.0.0" }, + "engines": { + "node": ">=20.12.0" + }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", diff --git a/schema.sql b/schema.sql index 426d998..acfd3d0 100644 --- a/schema.sql +++ b/schema.sql @@ -62,6 +62,4 @@ CREATE POLICY "patient_read_own_appointments" ON appointments CREATE POLICY "doctor_read_own_appointments" ON appointments FOR SELECT USING (auth.uid() = doctor_id); -INSERT INTO system_admins (email, password) VALUES - ('admin@test.com', 'admin123') -ON CONFLICT (email) DO NOTHING; +-- Admin credentials are seeded via seed.mjs (password is bcrypt-hashed) diff --git a/seed.mjs b/seed.mjs index 99b1293..823c6f9 100644 --- a/seed.mjs +++ b/seed.mjs @@ -1,4 +1,5 @@ import { createClient } from "@supabase/supabase-js"; +import bcrypt from "bcryptjs"; const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL, @@ -93,4 +94,12 @@ const { error: apptError } = await supabase.from("appointments").insert({ if (apptError) console.error("insert appointment", apptError.message); else console.log("created pre-existing appointment"); +// Seed admin with bcrypt-hashed password +const hashedPassword = await bcrypt.hash("admin123", 10); +const { error: adminError } = await supabase + .from("system_admins") + .upsert({ email: "admin@test.com", password: hashedPassword }, { onConflict: "email" }); +if (adminError) console.error("upsert admin", adminError.message); +else console.log("seeded admin@test.com"); + console.log("done"); diff --git a/tests/integration/booking.test.ts b/tests/integration/booking.test.ts index 9b9197a..01af69a 100644 --- a/tests/integration/booking.test.ts +++ b/tests/integration/booking.test.ts @@ -17,31 +17,33 @@ describe("Booking Flow", () => { beforeAll(async () => { // Sign in as patient2 — no pre-existing appointments from seed const client = createClient(supabaseUrl, supabaseAnonKey); - const { - data: { session }, - } = await client.auth.signInWithPassword({ - email: "patient2@test.com", - password: "patient123", + const { data: { session }, error: signInError } = await client.auth.signInWithPassword({ + email: process.env.TEST_PATIENT2_EMAIL!, + password: process.env.TEST_PATIENT2_PASSWORD!, }); - token = session!.access_token; - patientId = session!.user.id; + if (signInError || !session) throw new Error(`Sign-in failed: ${signInError?.message ?? "no session"}`); + token = session.access_token; + patientId = session.user.id; // Find any available slot - const { data: slot } = await supabaseAdmin + const { data: slot, error: slotError } = await supabaseAdmin .from("slots") .select("id, doctor_id") .eq("is_booked", false) .limit(1) .single(); - slotId = slot!.id; - doctorId = slot!.doctor_id; + if (slotError || !slot) throw new Error("No available slot found for testing"); + slotId = slot.id; + doctorId = slot.doctor_id; }); afterAll(async () => { if (createdAppointmentId) { await supabaseAdmin.from("appointments").delete().eq("id", createdAppointmentId); } - await supabaseAdmin.from("slots").update({ is_booked: false }).eq("id", slotId); + if (slotId) { + await supabaseAdmin.from("slots").update({ is_booked: false }).eq("id", slotId); + } }); it("creates an appointment in the database when a patient books an available slot", async () => { diff --git a/tests/integration/cancellation.test.ts b/tests/integration/cancellation.test.ts index 348f769..d1e193f 100644 --- a/tests/integration/cancellation.test.ts +++ b/tests/integration/cancellation.test.ts @@ -9,51 +9,52 @@ const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; describe("Cancellation Flow", () => { let token: string; - let appointmentId: string; - let testSlotId: string; + let appointmentId: string | undefined; + let testSlotId: string | undefined; beforeAll(async () => { // Sign in as patient3 — separate from booking integration test const client = createClient(supabaseUrl, supabaseAnonKey); - const { - data: { session }, - } = await client.auth.signInWithPassword({ - email: "patient3@test.com", - password: "patient123", + const { data: { session }, error: signInError } = await client.auth.signInWithPassword({ + email: process.env.TEST_PATIENT3_EMAIL!, + password: process.env.TEST_PATIENT3_PASSWORD!, }); - token = session!.access_token; - const patientId = session!.user.id; + if (signInError || !session) throw new Error(`Sign-in failed: ${signInError?.message ?? "no session"}`); + token = session.access_token; + const patientId = session.user.id; // Find a slot that starts more than 1 hour from now (required for patient cancellation) - const { data: slot } = await supabaseAdmin + const { data: slot, error: slotError } = await supabaseAdmin .from("slots") .select("id, doctor_id") .eq("is_booked", false) .gt("start_time", new Date(Date.now() + 60 * 60 * 1000).toISOString()) .limit(1) .single(); + if (slotError || !slot) throw new Error("No eligible slot found for testing (need a slot > 1 hour from now)"); - testSlotId = slot!.id; + testSlotId = slot.id; // Create a test appointment directly in the database - const { data: appt } = await supabaseAdmin + const { data: appt, error: apptError } = await supabaseAdmin .from("appointments") .insert({ patient_id: patientId, - doctor_id: slot!.doctor_id, + doctor_id: slot.doctor_id, slot_id: testSlotId, status: "active", }) .select() .single(); + if (apptError || !appt) throw new Error(`Failed to create test appointment: ${apptError?.message}`); - appointmentId = appt!.id; + appointmentId = appt.id; await supabaseAdmin.from("slots").update({ is_booked: true }).eq("id", testSlotId); }); afterAll(async () => { - await supabaseAdmin.from("appointments").delete().eq("id", appointmentId); - await supabaseAdmin.from("slots").update({ is_booked: false }).eq("id", testSlotId); + if (appointmentId) await supabaseAdmin.from("appointments").delete().eq("id", appointmentId); + if (testSlotId) await supabaseAdmin.from("slots").update({ is_booked: false }).eq("id", testSlotId); }); it("updates the appointment status to cancelled in the database", async () => { diff --git a/tests/setup.ts b/tests/setup.ts index ac20319..dc8193a 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,3 +1,7 @@ +if (typeof process.loadEnvFile !== "function") { + throw new Error("Node.js >= 20.12.0 is required to run tests. Please upgrade Node.js."); +} + try { process.loadEnvFile(".env"); } catch { diff --git a/tests/unit/book.test.ts b/tests/unit/book.test.ts index 35f5173..da6e8d4 100644 --- a/tests/unit/book.test.ts +++ b/tests/unit/book.test.ts @@ -3,7 +3,7 @@ import { validateSlotAvailable, validateNoActiveAppointmentWithDoctor } from "@/ describe("validateSlotAvailable", () => { it("returns an error when the slot is already booked", () => { - expect(validateSlotAvailable(true)).not.toBeNull(); + expect(validateSlotAvailable(true)).toBe("Slot is already booked"); }); it("returns null when the slot is available", () => { @@ -13,7 +13,9 @@ describe("validateSlotAvailable", () => { describe("validateNoActiveAppointmentWithDoctor", () => { it("returns an error when the patient already has an active appointment with this doctor", () => { - expect(validateNoActiveAppointmentWithDoctor(true)).not.toBeNull(); + expect(validateNoActiveAppointmentWithDoctor(true)).toBe( + "You already have an active appointment with this doctor" + ); }); it("returns null when the patient has no active appointment with this doctor", () => { From 319e5a4a7a7f632821a6017bf099fe0f6ea834b9 Mon Sep 17 00:00:00 2001 From: brook-studio Date: Mon, 27 Apr 2026 01:56:26 +0530 Subject: [PATCH 3/3] few changes for testing and packages --- .gitignore | 2 +- app/api/admin/login/route.ts | 11 ++++++++++- app/api/appointments/book/route.ts | 11 ++++++++++- app/api/appointments/cancel/route.ts | 13 +++++++++++-- lib/session.ts | 6 ++++-- package-lock.json | 10 +++------- package.json | 1 - schema.sql | 5 +++++ tests/integration/booking.test.ts | 5 +++-- 9 files changed, 47 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 3634a2f..8327a1d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ node_modules/ .next/ out/ - +.claude .DS_Store *.pem npm-debug.log* diff --git a/app/api/admin/login/route.ts b/app/api/admin/login/route.ts index be3074a..60d5acd 100644 --- a/app/api/admin/login/route.ts +++ b/app/api/admin/login/route.ts @@ -3,6 +3,9 @@ import bcrypt from "bcryptjs"; import { supabaseAdmin } from "@/lib/supabase-server"; import { createAdminSession, SESSION_COOKIE, COOKIE_OPTIONS } from "@/lib/session"; +// Pre-computed bcrypt hash used only for constant-time comparison when admin is not found +const DUMMY_HASH = "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"; + export async function POST(req: NextRequest) { let body: unknown; try { @@ -11,6 +14,9 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Invalid or missing JSON body" }, { status: 400 }); } + if (typeof body !== "object" || body === null || Array.isArray(body)) + return NextResponse.json({ error: "Invalid or missing JSON body" }, { status: 400 }); + const { email, password } = body as Record; if (!email || !password) return NextResponse.json({ error: "Email and password are required" }, { status: 400 }); @@ -21,7 +27,10 @@ export async function POST(req: NextRequest) { .eq("email", email) .single(); - const valid = admin && (await bcrypt.compare(password as string, admin.password)); + // Always run bcrypt.compare to prevent timing-based user enumeration + const hashToCompare = admin ? admin.password : DUMMY_HASH; + const matched = await bcrypt.compare(password as string, hashToCompare); + const valid = admin && matched; if (!valid) return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); const token = createAdminSession(admin.id); diff --git a/app/api/appointments/book/route.ts b/app/api/appointments/book/route.ts index 3a5f543..6a9e318 100644 --- a/app/api/appointments/book/route.ts +++ b/app/api/appointments/book/route.ts @@ -3,7 +3,12 @@ import { supabaseAdmin } from "@/lib/supabase-server"; import { validateNoActiveAppointmentWithDoctor } from "@/lib/appointments"; export async function POST(req: NextRequest) { - const token = req.headers.get("authorization")?.replace("Bearer ", ""); + const authHeader = req.headers.get("authorization") ?? ""; + const spaceIdx = authHeader.indexOf(" "); + const token = + spaceIdx !== -1 && authHeader.slice(0, spaceIdx).toLowerCase() === "bearer" + ? authHeader.slice(spaceIdx + 1).trim() + : null; if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { @@ -63,6 +68,10 @@ export async function POST(req: NextRequest) { if (insertError) { await supabaseAdmin.from("slots").update({ is_booked: false }).eq("id", slotId); + // Unique constraint violation on (patient_id, doctor_id) WHERE status='active' + if (insertError.code === "23505") { + return NextResponse.json({ error: "You already have an active appointment with this doctor" }, { status: 409 }); + } return NextResponse.json({ error: insertError.message }, { status: 500 }); } diff --git a/app/api/appointments/cancel/route.ts b/app/api/appointments/cancel/route.ts index d9ab613..5c61fa9 100644 --- a/app/api/appointments/cancel/route.ts +++ b/app/api/appointments/cancel/route.ts @@ -7,7 +7,12 @@ import { } from "@/lib/appointments"; export async function POST(req: NextRequest) { - const token = req.headers.get("authorization")?.replace("Bearer ", ""); + const authHeader = req.headers.get("authorization") ?? ""; + const spaceIdx = authHeader.indexOf(" "); + const token = + spaceIdx !== -1 && authHeader.slice(0, spaceIdx).toLowerCase() === "bearer" + ? authHeader.slice(spaceIdx + 1).trim() + : null; if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { @@ -67,7 +72,11 @@ export async function POST(req: NextRequest) { if (!updated) return NextResponse.json({ error: "Appointment is no longer active" }, { status: 409 }); if (newStatus === "cancelled") { - await supabaseAdmin.from("slots").update({ is_booked: false }).eq("id", appointment.slot_id); + const { error: slotError } = await supabaseAdmin + .from("slots") + .update({ is_booked: false }) + .eq("id", appointment.slot_id); + if (slotError) return NextResponse.json({ error: "Failed to release appointment slot" }, { status: 500 }); } return NextResponse.json({ success: true, status: newStatus }); diff --git a/lib/session.ts b/lib/session.ts index ff803be..330b58f 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -1,4 +1,4 @@ -import { createHmac } from "crypto"; +import { createHmac, timingSafeEqual } from "crypto"; const SESSION_DURATION_MS = 8 * 60 * 60 * 1000; // 8 hours @@ -21,7 +21,9 @@ export function verifyAdminSession(token: string): string | null { const payload = token.slice(0, dotIdx); const sig = token.slice(dotIdx + 1); const expected = createHmac("sha256", getSecret()).update(payload).digest("base64url"); - if (sig !== expected) return null; + const sigBuf = Buffer.from(sig, "base64url"); + const expectedBuf = Buffer.from(expected, "base64url"); + if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) return null; try { const { adminId, exp } = JSON.parse(Buffer.from(payload, "base64url").toString()); if (Date.now() > exp) return null; diff --git a/package-lock.json b/package-lock.json index b39ac57..21af6b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.1.0", "dependencies": { "@supabase/supabase-js": "^2.49.4", - "@types/bcryptjs": "^2.4.6", "bcryptjs": "^3.0.3", "next": "15.3.1", "react": "^19.0.0", @@ -23,6 +22,9 @@ "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.1.5" + }, + "engines": { + "node": ">=20.12.0" } }, "node_modules/@alloc/quick-lru": { @@ -1339,12 +1341,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/bcryptjs": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", - "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", - "license": "MIT" - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", diff --git a/package.json b/package.json index 96afc7b..7dcde31 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@supabase/supabase-js": "^2.49.4", - "@types/bcryptjs": "^2.4.6", "bcryptjs": "^3.0.3", "next": "15.3.1", "react": "^19.0.0", diff --git a/schema.sql b/schema.sql index acfd3d0..785b5dd 100644 --- a/schema.sql +++ b/schema.sql @@ -62,4 +62,9 @@ CREATE POLICY "patient_read_own_appointments" ON appointments CREATE POLICY "doctor_read_own_appointments" ON appointments FOR SELECT USING (auth.uid() = doctor_id); +-- Prevent a patient from having more than one active appointment with the same doctor +CREATE UNIQUE INDEX IF NOT EXISTS uq_active_appointment_patient_doctor + ON appointments (patient_id, doctor_id) + WHERE status = 'active'; + -- Admin credentials are seeded via seed.mjs (password is bcrypt-hashed) diff --git a/tests/integration/booking.test.ts b/tests/integration/booking.test.ts index 01af69a..1297d42 100644 --- a/tests/integration/booking.test.ts +++ b/tests/integration/booking.test.ts @@ -59,13 +59,14 @@ describe("Booking Flow", () => { const res = await POST(req); const data = await res.json(); + // Capture ID before any assertion so cleanup always runs + createdAppointmentId = data.appointment?.id ?? null; + expect(res.status).toBe(200); expect(data.appointment).toBeDefined(); expect(data.appointment.patient_id).toBe(patientId); expect(data.appointment.status).toBe("active"); - createdAppointmentId = data.appointment.id; - // Verify the appointment exists in the database const { data: dbAppt } = await supabaseAdmin .from("appointments")