Tiago Fortunato
ProjectsOdysUser Journeys

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: falsenotFound() (Next renders 404).
  • If the visitor is authenticated as a client, pre-fills name / phone / email from the matching clients row.
  • Fetches related professionals (same profession) and existing reviews (inner join on clients for 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:

  1. A useEffect fires GET /api/booking?slug=...&date=... (unauthenticated, no rate limit on GET).
  2. The endpoint reads the professional, the weekly availability ruleset, and every non-rejected/cancelled appointment whose startsAt falls in the selected day.
  3. The browser calls generateSlots() from src/lib/slots.ts — a pure function that combines the availability window, session duration, and existing appointments to emit available time strings like "14:00".
  4. 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:

  1. Rate-limit — 5 bookings per rolling 10 minutes per IP via getBookingLimiter(). IP is derived from the first entry in x-forwarded-for (standard Vercel pattern).

  2. Zod validationbookingSchema: 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.

  3. Professional resolution — look up by slug.

  4. Plan limit enforcement — free plan caps at 10 clients and 20 appointmentsPerMonth. Paid plans (basic / pro / premium) set both to Infinity. At cap on free → booking is rejected, unless the phone already exists as a client (existing clients re-book for free).

  5. 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..."
  6. Client upsert + appointment insert — upsert the client (matched by phone OR email, scoped to the professional), then insert the appointments row as pending_confirmation (or confirmed if the professional has autoConfirm: true).

4. Side effects (non-blocking)

After the booking insert, three fire-and-forget operations run:

  • notifications row written with type: "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] with action: "confirm" → client gets msgBookingConfirmed via WhatsApp.
  • Rejects → client gets msgBookingRejected.
  • Has autoConfirm: true → the appointment was already confirmed at 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:

  1. First request passes overlap check → inserts → returns 200.
  2. 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 Date in 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_Paulo via tzOffsetMs helper.

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.

On this page