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/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..03b3907 --- /dev/null +++ b/app/admin/dashboard/page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +type AdminAppointment = { + id: string; + status: "active" | "done" | "cancelled"; + created_at: string; + patients: { name: string }; + doctors: { name: string; specialty: string }; + slots: { start_time: string; end_time: string }; +}; + +function formatDateTime(iso: string) { + return new Date(iso).toLocaleString("en-IN", { + dateStyle: "medium", + timeStyle: "short", + }); +} + +export default function AdminDashboard() { + const router = useRouter(); + const [appointments, setAppointments] = useState([]); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(0); + const [hasMore, setHasMore] = useState(false); + + useEffect(() => { + let cancelled = false; + async function load() { + setLoading(true); + try { + const res = await fetch(`/api/admin/appointments?page=${page}`); + if (!res.ok) { + router.push("/admin/login"); + return; + } + const data = await res.json(); + if (!cancelled) { + setAppointments(data.appointments); + setHasMore(data.hasMore); + } + } catch { + if (!cancelled) router.push("/admin/login"); + } finally { + if (!cancelled) setLoading(false); + } + } + + load(); + return () => { cancelled = true; }; + }, [page, router]); + + async function handleLogout() { + await fetch("/api/admin/logout", { method: "POST" }); + router.push("/admin/login"); + } + + if (loading) { + return ( +
+

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)} +
+
+ )} +
+ + Page {page + 1} + +
+
+
+
+ ); +} diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx new file mode 100644 index 0000000..589611f --- /dev/null +++ b/app/admin/login/page.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; + +export default function AdminLogin() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const res = await fetch("/api/admin/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + if (res.ok) { + router.push("/admin/dashboard"); + } else { + const data = await res.json(); + setError(data.error ?? "Login failed."); + } + } catch { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + } + + return ( +
+
+

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..b5208e9 --- /dev/null +++ b/app/api/admin/appointments/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { supabaseAdmin } from "@/lib/supabase-server"; +import { verifyAdminSession, SESSION_COOKIE } from "@/lib/session"; + +const PAGE_SIZE = 50; + +export async function GET(req: NextRequest) { + const token = req.cookies.get(SESSION_COOKIE)?.value; + if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const adminId = verifyAdminSession(token); + if (!adminId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const pageParam = req.nextUrl.searchParams.get("page"); + const page = Math.max(0, parseInt(pageParam ?? "0", 10) || 0); + const from = page * PAGE_SIZE; + + const { data: appointments, error } = await supabaseAdmin + .from("appointments") + .select("id, status, created_at, patients(name), doctors(name, specialty), slots(start_time, end_time)") + .order("created_at", { ascending: false }) + .range(from, from + PAGE_SIZE); // fetch PAGE_SIZE + 1 to determine hasMore + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + + const hasMore = (appointments?.length ?? 0) > PAGE_SIZE; + const pageData = hasMore ? appointments!.slice(0, PAGE_SIZE) : (appointments ?? []); + + return NextResponse.json({ appointments: pageData, page, hasMore }); +} diff --git a/app/api/admin/login/route.ts b/app/api/admin/login/route.ts new file mode 100644 index 0000000..60d5acd --- /dev/null +++ b/app/api/admin/login/route.ts @@ -0,0 +1,40 @@ +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"; + +// 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 { + body = await req.json(); + } catch { + 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 }); + + const { data: admin } = await supabaseAdmin + .from("system_admins") + .select("id, email, password") + .eq("email", email) + .single(); + + // 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); + const res = NextResponse.json({ success: true }); + 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 new file mode 100644 index 0000000..5303c18 --- /dev/null +++ b/app/api/admin/logout/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { SESSION_COOKIE, COOKIE_OPTIONS } from "@/lib/session"; + +export async function POST() { + const res = NextResponse.json({ success: true }); + res.cookies.set(SESSION_COOKIE, "", { ...COOKIE_OPTIONS, maxAge: 0 }); + return res; +} diff --git a/app/api/appointments/book/route.ts b/app/api/appointments/book/route.ts index 4fcd17b..6a9e318 100644 --- a/app/api/appointments/book/route.ts +++ b/app/api/appointments/book/route.ts @@ -1,5 +1,79 @@ import { NextRequest, NextResponse } from "next/server"; +import { supabaseAdmin } from "@/lib/supabase-server"; +import { validateNoActiveAppointmentWithDoctor } from "@/lib/appointments"; -export async function POST(_req: NextRequest) { - return NextResponse.json({ error: "Not implemented" }, { status: 501 }); +export async function POST(req: NextRequest) { + const 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 { + data: { user }, + error: authError, + } = await supabaseAdmin.auth.getUser(token); + if (authError || !user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { data: patient } = await supabaseAdmin + .from("patients") + .select("id") + .eq("id", user.id) + .single(); + if (!patient) return NextResponse.json({ error: "Only patients can book appointments" }, { status: 403 }); + + let slotId: string; + try { + const body = await req.json(); + slotId = body.slotId; + } catch { + return NextResponse.json({ error: "Invalid or missing JSON body" }, { status: 400 }); + } + if (!slotId) return NextResponse.json({ error: "slotId is required" }, { status: 400 }); + + // Atomically claim the slot — only succeeds if is_booked is currently false + const { data: slot } = await supabaseAdmin + .from("slots") + .update({ is_booked: true }) + .eq("id", slotId) + .eq("is_booked", false) + .select("id, doctor_id") + .maybeSingle(); + + if (!slot) return NextResponse.json({ error: "Slot is already booked" }, { status: 409 }); + + // Check for duplicate active appointment with the same doctor (doctor_id comes from slot, not client) + const { data: existing } = await supabaseAdmin + .from("appointments") + .select("id") + .eq("patient_id", user.id) + .eq("doctor_id", slot.doctor_id) + .eq("status", "active") + .maybeSingle(); + + const duplicateError = validateNoActiveAppointmentWithDoctor(!!existing); + if (duplicateError) { + // Release the slot we just claimed + await supabaseAdmin.from("slots").update({ is_booked: false }).eq("id", slotId); + return NextResponse.json({ error: duplicateError }, { status: 409 }); + } + + const { data: appointment, error: insertError } = await supabaseAdmin + .from("appointments") + .insert({ patient_id: user.id, doctor_id: slot.doctor_id, slot_id: slotId }) + .select() + .single(); + + if (insertError) { + await supabaseAdmin.from("slots").update({ is_booked: false }).eq("id", slotId); + // 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 }); + } + + return NextResponse.json({ appointment }); } diff --git a/app/api/appointments/cancel/route.ts b/app/api/appointments/cancel/route.ts index 4fcd17b..5c61fa9 100644 --- a/app/api/appointments/cancel/route.ts +++ b/app/api/appointments/cancel/route.ts @@ -1,5 +1,83 @@ 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 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 { + data: { user }, + error: authError, + } = await supabaseAdmin.auth.getUser(token); + if (authError || !user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + 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)) + 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 }); + + 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"; + + // 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") { + 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/app/doctor/dashboard/page.tsx b/app/doctor/dashboard/page.tsx index 3f89c4c..55df0e4 100644 --- a/app/doctor/dashboard/page.tsx +++ b/app/doctor/dashboard/page.tsx @@ -91,9 +91,19 @@ export default function DoctorDashboard() { action: "done" | "cancel" ) { setActionMsg(""); + 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" }, + 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..65695b5 100644 --- a/app/patient/dashboard/page.tsx +++ b/app/patient/dashboard/page.tsx @@ -95,9 +95,19 @@ export default function PatientDashboard() { setActionMsg(""); setBookingSlotId(slotId); + 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" }, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session.access_token}`, + }, body: JSON.stringify({ slotId, doctorId }), }); const data = await res.json(); @@ -114,9 +124,19 @@ export default function PatientDashboard() { async function handleCancel(appointmentId: string) { setActionMsg(""); + 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" }, + 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..0c79fe5 --- /dev/null +++ b/lib/appointments.ts @@ -0,0 +1,29 @@ +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 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; +} + +export function validateNoActiveAppointmentWithDoctor(hasActive: boolean): string | null { + return hasActive + ? "You already have an active appointment with this doctor" + : null; +} diff --git a/lib/session.ts b/lib/session.ts new file mode 100644 index 0000000..330b58f --- /dev/null +++ b/lib/session.ts @@ -0,0 +1,43 @@ +import { createHmac, timingSafeEqual } 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"); + 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; + 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/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..21af6b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@supabase/supabase-js": "^2.49.4", + "bcryptjs": "^3.0.3", "next": "15.3.1", "react": "^19.0.0", "react-dom": "^19.0.0" @@ -19,7 +20,11 @@ "@types/react": "^19", "@types/react-dom": "^19", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.5" + }, + "engines": { + "node": ">=20.12.0" } }, "node_modules/@alloc/quick-lru": { @@ -34,6 +39,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 +60,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 +557,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 +701,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 +1330,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 +1400,138 @@ "@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/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", @@ -1066,11 +1562,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 +1612,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 +2051,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 +2134,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 +2229,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 +2244,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 +2307,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 +2374,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..7dcde31 100644 --- a/package.json +++ b/package.json @@ -6,20 +6,27 @@ "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", + "bcryptjs": "^3.0.3", "next": "15.3.1", "react": "^19.0.0", - "react-dom": "^19.0.0", - "@supabase/supabase-js": "^2.49.4" + "react-dom": "^19.0.0" + }, + "engines": { + "node": ">=20.12.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/schema.sql b/schema.sql index 426d998..785b5dd 100644 --- a/schema.sql +++ b/schema.sql @@ -62,6 +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); -INSERT INTO system_admins (email, password) VALUES - ('admin@test.com', 'admin123') -ON CONFLICT (email) DO NOTHING; +-- 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/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 new file mode 100644 index 0000000..1297d42 --- /dev/null +++ b/tests/integration/booking.test.ts @@ -0,0 +1,81 @@ +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 }, error: signInError } = await client.auth.signInWithPassword({ + email: process.env.TEST_PATIENT2_EMAIL!, + password: process.env.TEST_PATIENT2_PASSWORD!, + }); + 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, error: slotError } = await supabaseAdmin + .from("slots") + .select("id, doctor_id") + .eq("is_booked", false) + .limit(1) + .single(); + 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); + } + 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 () => { + 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(); + + // 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"); + + // 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..d1e193f --- /dev/null +++ b/tests/integration/cancellation.test.ts @@ -0,0 +1,85 @@ +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 | undefined; + let testSlotId: string | undefined; + + beforeAll(async () => { + // Sign in as patient3 — separate from booking integration test + const client = createClient(supabaseUrl, supabaseAnonKey); + const { data: { session }, error: signInError } = await client.auth.signInWithPassword({ + email: process.env.TEST_PATIENT3_EMAIL!, + password: process.env.TEST_PATIENT3_PASSWORD!, + }); + 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, 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; + + // Create a test appointment directly in the database + const { data: appt, error: apptError } = await supabaseAdmin + .from("appointments") + .insert({ + patient_id: patientId, + 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; + await supabaseAdmin.from("slots").update({ is_booked: true }).eq("id", testSlotId); + }); + + afterAll(async () => { + 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 () => { + 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..dc8193a --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,9 @@ +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 { + // .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..da6e8d4 --- /dev/null +++ b/tests/unit/book.test.ts @@ -0,0 +1,24 @@ +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)).toBe("Slot is already booked"); + }); + + 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)).toBe( + "You already have an active appointment with this doctor" + ); + }); + + 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, "."), + }, + }, +});