Customer Booking Flow
How a customer goes from /p/[slug] to a confirmed appointment — availability lookup, slot selection, server-side overlap check, notifications.
Customer Booking Flow
From arriving at a professional's public page to receiving confirmation. Client-side UX for speed, server-side enforcement for correctness, non-blocking side effects for resilience.
1. Landing on the public page
The customer opens odys.com.br/p/dr-ana. The page at src/app/p/[slug]/page.tsx is an async Server Component:
- Fetches the professional by slug. If missing or
active: false→notFound()(Next renders 404). - If the visitor is authenticated as a client, pre-fills name / phone / email from the matching
clientsrow. - Fetches related professionals (same profession) and existing reviews (inner join on
clientsfor reviewer names). - Passes all server-resolved data down to
<BookingWidget>— a client component.
SEO: generateMetadata pulls title, description, and openGraph.images from the professional row. A dynamic OG image is generated in src/app/p/[slug]/opengraph-image.tsx.
2. Picking a slot
<BookingWidget> keeps three pieces of UI state: the selected date, the selected slot, and the step ("pick" | "form" | "success").
When the user selects a date:
- A
useEffectfiresGET /api/booking?slug=...&date=...(unauthenticated, no rate limit on GET). - The endpoint reads the professional, the weekly
availabilityruleset, and every non-rejected/cancelledappointment whosestartsAtfalls in the selected day. - The browser calls
generateSlots()fromsrc/lib/slots.ts— a pure function that combines the availability window, session duration, and existing appointments to emit available time strings like"14:00". - Slots in the past are dropped. Slots that overlap existing appointments are dropped.
Slot computation is client-side for UX speed. Server-side enforcement comes next.
3. Submitting the booking
When the user picks a slot, fills the form, and submits, the widget POSTs to /api/booking. The handler does six things in order:
-
Rate-limit — 5 bookings per rolling 10 minutes per IP via
getBookingLimiter(). IP is derived from the first entry inx-forwarded-for(standard Vercel pattern). -
Zod validation —
bookingSchema:slug(string min 1),startsAt(ISO datetime),clientName(string min 2),clientPhone(string min 10),clientEmail(valid email, optional + nullable). Failure → 400 with field errors. -
Professional resolution — look up by slug.
-
Plan limit enforcement — free plan caps at
10 clientsand20 appointmentsPerMonth. Paid plans (basic / pro / premium) set both toInfinity. At cap on free → booking is rejected, unless the phone already exists as a client (existing clients re-book for free). -
Server-side overlap check — the double-booking defense:
const conflict = await db.select({ id: appointments.id }) .from(appointments) .where(and( eq(appointments.professionalId, professional.id), notInArray(appointments.status, ["rejected", "cancelled"]), lt(appointments.startsAt, endDate), // existing starts before new ends gt(appointments.endsAt, startDate), // existing ends after new starts )) .limit(1) if (conflict.length > 0) return 409 "Este horário acabou de ser preenchido..." -
Client upsert + appointment insert — upsert the client (matched by phone OR email, scoped to the professional), then insert the
appointmentsrow aspending_confirmation(orconfirmedif the professional hasautoConfirm: true).
4. Side effects (non-blocking)
After the booking insert, three fire-and-forget operations run:
notificationsrow written withtype: "booking_request".- WhatsApp message to the professional via
sendWhatsApp(msgBookingRequest(...)). - Email to the professional via
sendBookingRequestEmailToProfessional(...).
Each wraps in .catch(console.warn) — the booking succeeds even if Evolution API or Resend is down. UX beats strict consistency on notifications.
5. Confirmation and downstream
The professional receives the WhatsApp / email / dashboard alert and either:
- Confirms via dashboard → PATCH
/api/appointments/[id]withaction: "confirm"→ client getsmsgBookingConfirmedvia WhatsApp. - Rejects → client gets
msgBookingRejected. - Has autoConfirm: true → the appointment was already
confirmedat insert time, no manual step needed.
The full action set on PATCH /api/appointments/[id] is confirm, reject, cancel, paid, complete, no_show — enforced by an explicit allowlist; unknown actions return 400. paid / complete / no_show are pro-only (authentication + ownership gate).
If the client booked without a registered account, the confirmation message appends msgRegistrationInvite — a short prompt with a pre-filled /register link so the client can claim their profile for future bookings.
Race condition — honest flag
Two customers clicking the same slot at the same moment both see it as available (client-side computation has no lock). Server-side sequence:
- First request passes overlap check → inserts → returns 200.
- Second request runs the same overlap check → finds the just-inserted row → returns 409 with "Este horário acabou de ser preenchido, por favor escolha outro."
The SELECT and INSERT are not wrapped in a transaction — there's a microsecond window between them where two simultaneous requests could both pass the check and both insert. In practice that window is microseconds on Supabase's pooled connection, and per-professional booking rate is far below contention threshold.
Upgrade path if real contention emerges: either wrap select+insert in db.transaction(...) with SERIALIZABLE isolation, or add a Postgres EXCLUSION CONSTRAINT using tstzrange(starts_at, ends_at) with the && operator. The generateSlots() function stays unchanged — it's UX, not authorization.
Timezone handling — honest flag
- Booking widget builds
Datein browser-local time (date.setHours(h, m, 0, 0)). - API stores it as UTC timestamp.
- Reminder cron computes windows on Vercel's UTC clock.
- AI intake agent explicitly anchors to
America/Sao_Paulo. - MCP server resolves "today" in
America/Sao_PauloviatzOffsetMshelper.
Today this works because every professional and client is in São Paulo. There is no timezone-of-record column on the professional row — if Odys ever goes multi-zone, that's a schema migration.
Why this shape
- Server Component for the public page — SEO-friendly, single async query tree, no API-call waterfall on initial render.
- Client Component only for the widget — the
"use client"boundary is small, interactive state is isolated. - Client-side slot filtering for UX, server-side overlap check for correctness — two-layer defense. Client is fast and may drift at the edge; server is authoritative.
- Non-awaited side effects — never let an email/WhatsApp failure cascade into a booking failure. The booking is the source of truth; notifications are best-effort.