From 036f8c59dcf003e6d0e85f2a27b2f8119fcdda84 Mon Sep 17 00:00:00 2001 From: Janarthanan Soundhararajan Date: Tue, 18 Aug 2026 00:38:38 +0530 Subject: [PATCH] feat(booking): implement guest booking submission flow with 100% test coverage --- .../[locale]/(dashboard)/bookings/page.tsx | 131 ++++ apps/app/app/[locale]/(dashboard)/layout.tsx | 77 +- .../app/[locale]/[username]/[slug]/page.tsx | 21 +- .../app/app/actions/__tests__/booking.test.ts | 162 ++++ apps/app/app/actions/booking.ts | 102 +++ apps/app/components/booking/booking-form.tsx | 192 +++++ .../components/booking/booking-success.tsx | 66 ++ apps/app/components/booking/booking-view.tsx | 242 +++++- apps/app/components/dashboard/app-sidebar.tsx | 83 ++ apps/app/components/ui/alert.tsx | 76 ++ apps/app/components/ui/separator.tsx | 25 + apps/app/components/ui/sheet.tsx | 138 ++++ apps/app/components/ui/sidebar.tsx | 723 ++++++++++++++++++ apps/app/components/ui/skeleton.tsx | 13 + apps/app/components/ui/tooltip.tsx | 66 ++ apps/app/hooks/use-mobile.ts | 19 + apps/app/lib/schemas/booking.ts | 20 + apps/app/lib/schemas/index.ts | 1 + 18 files changed, 2046 insertions(+), 111 deletions(-) create mode 100644 apps/app/app/[locale]/(dashboard)/bookings/page.tsx create mode 100644 apps/app/app/actions/__tests__/booking.test.ts create mode 100644 apps/app/app/actions/booking.ts create mode 100644 apps/app/components/booking/booking-form.tsx create mode 100644 apps/app/components/booking/booking-success.tsx create mode 100644 apps/app/components/dashboard/app-sidebar.tsx create mode 100644 apps/app/components/ui/alert.tsx create mode 100644 apps/app/components/ui/separator.tsx create mode 100644 apps/app/components/ui/sheet.tsx create mode 100644 apps/app/components/ui/sidebar.tsx create mode 100644 apps/app/components/ui/skeleton.tsx create mode 100644 apps/app/components/ui/tooltip.tsx create mode 100644 apps/app/hooks/use-mobile.ts create mode 100644 apps/app/lib/schemas/booking.ts diff --git a/apps/app/app/[locale]/(dashboard)/bookings/page.tsx b/apps/app/app/[locale]/(dashboard)/bookings/page.tsx new file mode 100644 index 0000000..9dbd026 --- /dev/null +++ b/apps/app/app/[locale]/(dashboard)/bookings/page.tsx @@ -0,0 +1,131 @@ +import { db } from "@repo/db"; +import { auth } from "@/auth"; +import { redirect } from "next/navigation"; +import { format, startOfDay } from "date-fns"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { CalendarClock, User, Mail, AlignLeft } from "lucide-react"; + +export default async function BookingsPage() { + const session = await auth(); + + if (!session?.user?.id) { + redirect("/login"); + } + + const todayStart = startOfDay(new Date()); + + // 1. Fetch upcoming bookings for the logged-in Host + const upcomingBookings = await db.booking.findMany({ + where: { + hostId: session.user.id, + startTime: { + gte: todayStart, // Only show meetings happening in the future + }, + }, + orderBy: { + startTime: "asc", // Show the soonest meetings first + }, + include: { + eventType: { + select: { + title: true, + duration: true, + }, + }, + }, + }); + + return ( +
+
+

Upcoming Bookings

+

+ Manage your scheduled meetings and events. +

+
+ + {upcomingBookings.length === 0 ? ( + + + No upcoming meetings + + When guests schedule time with you, their bookings will appear here. + Share your event links to get started! + + + ) : ( +
+ {upcomingBookings.map((booking) => ( + +
+ +
+
+ + {booking.eventType.title} + + + {booking.eventType.duration} minutes + +
+ + {booking.status} + +
+
+ + {/* Time Context */} +
+

+ {/* Displaying time in UTC for the host for now - we can add timezone parsing later */} + {format(booking.startTime, "EEEE, MMMM d, yyyy")} +

+

+ {format(booking.startTime, "h:mm a")} -{" "} + {format(booking.endTime, "h:mm a")} +

+
+ + {/* Guest Details */} +
+
+ + {booking.guestName} +
+ + {booking.guestNotes && ( +
+ +

+ "{booking.guestNotes}" +

+
+ )} +
+
+ + ))} +
+ )} +
+ ); +} diff --git a/apps/app/app/[locale]/(dashboard)/layout.tsx b/apps/app/app/[locale]/(dashboard)/layout.tsx index 120371f..0fa6feb 100644 --- a/apps/app/app/[locale]/(dashboard)/layout.tsx +++ b/apps/app/app/[locale]/(dashboard)/layout.tsx @@ -1,80 +1,17 @@ -import Link from "next/link"; import { ReactNode } from "react"; -import { - CalendarClock, - CalendarRange, - BookOpen, - BarChart2, -} from "lucide-react"; - -const NAV_ITEMS = [ - { - label: "Event Types", - href: "/event-types", - icon: CalendarRange, - }, - { - label: "Availability", - href: "/availability", - icon: CalendarClock, - }, - { - label: "Bookings", - href: "/bookings", - icon: BookOpen, - }, - { - label: "Polls", - href: "/polls", - icon: BarChart2, - }, -]; +import { AppSidebar } from "@/components/dashboard/app-sidebar"; +import { SidebarProvider } from "@/components/ui/sidebar"; export default function DashboardLayout({ children }: { children: ReactNode }) { return ( -
- {/* Sidebar Navigation */} - + + {/* Client-side Sidebar Component */} + {/* Main Page Workspace */}
- {children} +
{children}
-
+ ); } diff --git a/apps/app/app/[locale]/[username]/[slug]/page.tsx b/apps/app/app/[locale]/[username]/[slug]/page.tsx index 4782432..d4a1a40 100644 --- a/apps/app/app/[locale]/[username]/[slug]/page.tsx +++ b/apps/app/app/[locale]/[username]/[slug]/page.tsx @@ -1,6 +1,7 @@ import { db } from "@repo/db"; import { notFound } from "next/navigation"; import { BookingView } from "@/components/booking/booking-view"; +import { auth } from "@/auth"; interface PublicBookingPageProps { params: Promise<{ @@ -15,7 +16,18 @@ export default async function PublicBookingPage({ }: PublicBookingPageProps) { const { locale, username, slug } = await params; - // 🎯 Query host by username OR fallback to user ID if username is null + // 1. Fetch current user session to auto-populate the booking form + const session = await auth(); + + const loggedInUser = session?.user?.id + ? { + id: session.user.id as string, + name: session.user.name || "", + email: session.user.email || "", + } + : null; + + // 2. Query host by username OR fallback to user ID if username is null const host = await db.user.findFirst({ where: { OR: [{ username: username }, { id: username }], @@ -29,7 +41,7 @@ export default async function PublicBookingPage({ notFound(); } - // Fetch the event type scoped to this host + // 3. Fetch the event type scoped to this host const eventType = await db.eventType.findUnique({ where: { userId_slug: { @@ -43,12 +55,12 @@ export default async function PublicBookingPage({ notFound(); } - // Fetch existing bookings to calculate availability conflicts + // 4. Fetch existing bookings to calculate availability conflicts const existingBookings = await db.booking.findMany({ where: { hostId: host.id, startTime: { - gte: new Date(), + gte: new Date(), // Only fetch future bookings to save memory }, }, }); @@ -62,6 +74,7 @@ export default async function PublicBookingPage({ weeklyAvailability={host.weeklyAvailability} existingBookings={existingBookings} locale={locale} + loggedInUser={loggedInUser} // Type error resolved />
diff --git a/apps/app/app/actions/__tests__/booking.test.ts b/apps/app/app/actions/__tests__/booking.test.ts new file mode 100644 index 0000000..aca2cac --- /dev/null +++ b/apps/app/app/actions/__tests__/booking.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createBooking } from "../booking"; +import { db } from "@repo/db"; +import type { Booking, EventType } from "@prisma/client"; + +vi.mock("@repo/db", () => ({ + db: { + eventType: { + findUnique: vi.fn(), + }, + booking: { + findFirst: vi.fn(), + create: vi.fn(), + }, + }, +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +describe("createBooking Server Action", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const validPayload = { + eventTypeId: "evt_123", + hostId: "usr_host", + guestName: "Alice Smith", + guestEmail: "alice@example.com", + guestNotes: "Looking forward to our chat", + guestTimeZone: "America/New_York", + startTime: "2026-09-01T10:00:00.000Z", + }; + + it("fails validation when required fields are missing or invalid", async () => { + const response = await createBooking({ + ...validPayload, + guestEmail: "invalid-email", + }); + + expect(response.success).toBe(false); + if (!response.success) { + expect(response.fieldErrors?.guestEmail).toBeDefined(); + } + }); + + it("returns error if event type does not exist or is archived", async () => { + vi.mocked(db.eventType.findUnique).mockResolvedValue(null); + + const response = await createBooking(validPayload); + expect(response.success).toBe(false); + if (!response.success) { + expect(response.error).toContain("no longer active"); + } + }); + + it("returns error if conflicting booking exists during fast UX check", async () => { + vi.mocked(db.eventType.findUnique).mockResolvedValue({ + id: "evt_123", + duration: 30, + userId: "usr_host", + isArchived: false, + } as unknown as EventType); + + vi.mocked(db.booking.findFirst).mockResolvedValue({ + id: "existing_bk_1", + } as unknown as Booking); + + const response = await createBooking(validPayload); + expect(response.success).toBe(false); + if (!response.success) { + expect(response.error).toContain("just booked by someone else"); + } + }); + + it("creates booking successfully when slot is available", async () => { + vi.mocked(db.eventType.findUnique).mockResolvedValue({ + id: "evt_123", + duration: 30, + userId: "usr_host", + isArchived: false, + } as unknown as EventType); + + vi.mocked(db.booking.findFirst).mockResolvedValue(null); + vi.mocked(db.booking.create).mockResolvedValue({ + id: "bk_new_789", + } as unknown as Booking); + + const response = await createBooking(validPayload); + expect(response.success).toBe(true); + if (response.success) { + expect(response.bookingId).toBe("bk_new_789"); + } + }); + + it("returns a generic fallback error if the database unexpectedly throws", async () => { + // 1. Mock a successful event type fetch + vi.mocked(db.eventType.findUnique).mockResolvedValue({ + id: "evt_123", + duration: 30, + userId: "usr_host", + isArchived: false, + } as unknown as EventType); + + // 2. Mock that the slot is completely free + vi.mocked(db.booking.findFirst).mockResolvedValue(null); + + // 3. Force the Prisma create method to CRASH + vi.mocked(db.booking.create).mockRejectedValue( + new Error("Database connection dropped"), + ); + + // 4. Temporarily silence console.error so our test output stays clean + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + // 5. Execute the action + const response = await createBooking(validPayload); + + // 6. Verify we gracefully hit the catch block + expect(response.success).toBe(false); + if (!response.success) { + expect(response.error).toContain("unexpected error occurred"); + } + + // Cleanup the console spy + consoleSpy.mockRestore(); + }); + + it("creates booking successfully when guestNotes is omitted (fallback to null)", async () => { + // 1. Mock DB dependencies + vi.mocked(db.eventType.findUnique).mockResolvedValue({ + id: "evt_123", + duration: 30, + userId: "usr_host", + isArchived: false, + } as unknown as EventType); + vi.mocked(db.booking.findFirst).mockResolvedValue(null); + vi.mocked(db.booking.create).mockResolvedValue({ + id: "bk_new_no_notes", + } as unknown as Booking); + + // 2 & 3. Execute Action with an empty string + const response = await createBooking({ + ...validPayload, + guestNotes: "", // The empty string satisfies TS, but is falsy enough to trigger `|| null` + }); + + // 4. Assert Success and verify the fallback to null was used + expect(response.success).toBe(true); + + // Pro-tip: Assert that Prisma was actually called with `guestNotes: null` + expect(db.booking.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + guestNotes: null, + }), + }), + ); + }); +}); diff --git a/apps/app/app/actions/booking.ts b/apps/app/app/actions/booking.ts new file mode 100644 index 0000000..ec91ff5 --- /dev/null +++ b/apps/app/app/actions/booking.ts @@ -0,0 +1,102 @@ +"use server"; + +import { db } from "@repo/db"; +import { createBookingSchema, type CreateBookingInput } from "@/lib/schemas"; +import { revalidatePath } from "next/cache"; + +export type CreateBookingResult = + | { success: true; bookingId: string } + | { success: false; error: string; fieldErrors?: Record }; + +export async function createBooking( + rawInput: CreateBookingInput, +): Promise { + const result = createBookingSchema.safeParse(rawInput); + + if (!result.success) { + return { + success: false, + error: "Validation failed. Please review your details.", + fieldErrors: result.error.flatten().fieldErrors, + }; + } + + const { + eventTypeId, + hostId, + guestName, + guestEmail, + guestNotes, + guestTimeZone, + startTime, + } = result.data; + + try { + // 1. Fetch EventType details to determine duration & active status + const eventType = await db.eventType.findUnique({ + where: { id: eventTypeId }, + select: { duration: true, userId: true, isArchived: true }, + }); + + if (!eventType || eventType.isArchived || eventType.userId !== hostId) { + return { + success: false, + error: "This event type is no longer active or available.", + }; + } + + const startDateTime = new Date(startTime); + const endDateTime = new Date( + startDateTime.getTime() + eventType.duration * 60 * 1000, + ); + + // 2. Prevent Double Booking / Race Condition (Check overlapping bookings) + const conflictingBooking = await db.booking.findFirst({ + where: { + hostId, + status: { in: ["PENDING", "ACCEPTED"] }, + AND: [ + { startTime: { lt: endDateTime } }, + { endTime: { gt: startDateTime } }, + ], + }, + }); + + if (conflictingBooking) { + return { + success: false, + error: + "This time slot was just booked by someone else. Please select another slot.", + }; + } + + // 3. Create Booking Record in Database + const newBooking = await db.booking.create({ + data: { + eventTypeId, + hostId, + guestName, + guestEmail, + guestNotes: guestNotes || null, + guestTimeZone, + startTime: startDateTime, + endTime: endDateTime, + status: "ACCEPTED", + }, + }); + + revalidatePath("/[username]/[slug]", "page"); + + return { + success: true, + bookingId: newBooking.id, + }; + } catch (error) { + console.error("Failed to create booking:", error); + return { + success: false, + error: + "An unexpected error occurred while scheduling your meeting. Please try again.", + }; + } +} diff --git a/apps/app/components/booking/booking-form.tsx b/apps/app/components/booking/booking-form.tsx new file mode 100644 index 0000000..8a8792f --- /dev/null +++ b/apps/app/components/booking/booking-form.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useTransition } from "react"; +import { useForm, type Resolver } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { createBookingSchema, type CreateBookingInput } from "@/lib/schemas"; +import { createBooking } from "@/app/actions/booking"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { ArrowLeft, Loader2, InfoIcon } from "lucide-react"; +import { format } from "date-fns"; + +interface BookingFormProps { + eventTypeId: string; + hostId: string; + hostName: string; + eventTitle: string; + duration: number; + selectedSlot: Date; + guestTimeZone: string; + loggedInUser?: { id: string; name: string; email: string } | null; + onBack: () => void; + onSuccess: (bookingId: string) => void; +} + +export function BookingForm({ + eventTypeId, + hostId, + hostName, + eventTitle, + duration, + selectedSlot, + guestTimeZone, + loggedInUser, + onBack, + onSuccess, +}: BookingFormProps) { + const [isPending, startTransition] = useTransition(); + + // Check if the current viewer is the owner of the page + const isOwnPage = loggedInUser?.id === hostId; + + const { + register, + handleSubmit, + setError, + formState: { errors }, + } = useForm({ + resolver: zodResolver(createBookingSchema) as Resolver, + defaultValues: { + eventTypeId, + hostId, + guestTimeZone, + startTime: selectedSlot.toISOString(), + // Auto-populate inputs if the user is authenticated + guestName: loggedInUser?.name || "", + guestEmail: loggedInUser?.email || "", + guestNotes: "", + }, + }); + + const onSubmit = (data: CreateBookingInput) => { + startTransition(async () => { + const response = await createBooking(data); + if (response.success) { + onSuccess(response.bookingId); + } else { + if (response.fieldErrors) { + Object.entries(response.fieldErrors).forEach(([field, messages]) => { + if (messages && messages.length > 0) { + setError(field as keyof CreateBookingInput, { + message: messages[0], + }); + } + }); + } else { + setError("root", { message: response.error }); + } + } + }); + }; + + return ( +
+
+ +
+ +
+

+ You are booking{" "} + {eventTitle} with{" "} + {hostName} for{" "} + {duration} mins on{" "} + + {format(selectedSlot, "EEEE, MMMM d, yyyy 'at' h:mm a")} + {" "} + ({guestTimeZone}). +

+
+ + {isOwnPage && ( + + + + You are viewing your own booking page. You can submit a test booking + below. + + + )} + +
+ {errors.root && ( +
+ {errors.root.message} +
+ )} + +
+ + + {errors.guestName && ( +

+ {errors.guestName.message} +

+ )} +
+ +
+ + + {errors.guestEmail && ( +

+ {errors.guestEmail.message} +

+ )} +
+ +
+ +