Booking Flow
The customer journey from discovering a professional to confirming an appointment, including slot selection, data submission, and conflict resolution.
The booking flow is a core interaction within Odys, enabling customers to discover and schedule appointments with professionals. This process is designed to be intuitive for the client while providing professionals with the necessary tools to manage their schedules efficiently. It encompasses the initial display of a professional's profile, the interactive selection of an available time slot, the submission of client details, and the server-side logic that validates and records the appointment.
Overview
The customer booking journey begins on a public profile page, typically accessed via a unique slug, such as /p/[slug]. This page, rendered by src/app/p/[slug]/page.tsx, serves as the entry point, displaying the professional's details, reviews, and an interactive BookingWidget.
The BookingWidget, a client-side component, orchestrates the slot selection process. It communicates with the /api/booking API route to fetch real-time availability and existing appointments for a given professional and date. Once a slot is chosen and client details are provided, the widget dispatches a POST request to the same /api/booking endpoint. This server-side handler then performs critical validations, including rate limiting, plan limit checks, and an atomic conflict check, before attempting to create or update client records and insert the new appointment into the database.
Upon successful booking, the system triggers various notifications—WhatsApp messages, emails, and in-app notifications—to both the client and the professional, ensuring all parties are informed. The appointments table, with its 13 columns, is central to storing these scheduled sessions, tracking their status and paymentStatus.
Professional Profile and Initial Display
A customer first encounters a professional through their dedicated public page, handled by src/app/p/[slug]/page.tsx. This page dynamically fetches the professional's information from the professionals table, which contains 24 columns, including name, profession, bio, avatarUrl, sessionDuration, and sessionPrice. The slug column, being unique, is used to identify the specific professional.
The page also retrieves associated reviews from the reviews table, joining with the clients table to display client names alongside their ratings and comments. Related professionals, sharing the same profession, are also fetched and displayed, offering alternative options to the customer. For logged-in users, the system checks if the visitor is the professional themselves (isOwner) or an existing client, pre-filling the booking form with known client data from the clients table if available.
Slot Selection and Availability
The interactive core of the booking experience is the BookingWidget (src/app/p/[slug]/booking-widget.tsx). This client-side component presents a calendar for date selection and then dynamically loads available time slots.
When a date is selected, the widget makes a GET request to /api/booking. This API route queries the database for two key pieces of information:
- Availability Rules: It fetches the professional's defined working hours from the
availabilitytable, which specifiesdayOfWeek,startTime, andendTime. - Existing Appointments: It retrieves all confirmed or pending appointments for the selected professional and date from the
appointmentstable, excluding those withstatus"rejected" or "cancelled".
The generateSlots utility function (src/lib/slots.ts) then takes these rules and existing appointments to compute the actual available time slots. It iterates through potential start times, considering the professional's sessionDuration, and filters out any slots that are in the past, overlap with existing bookings, or fall outside the defined availability rules. The resulting slots are then grouped into "morning," "afternoon," and "evening" for a user-friendly display in the widget.
Booking Submission
Once a client selects a slot and fills in their details, the BookingWidget sends a POST request to /api/booking. This endpoint is a critical server-side component responsible for processing and validating the booking.
The process begins with a rate limit check using getBookingLimiter(), which allows for 5 booking attempts per IP address every 10 minutes, preventing abuse. A subtle but important detail is the respondAfterFloor function, which introduces a minimum response time of 250ms. This measure helps mitigate timing attacks, where an attacker might try to infer information about existing client records based on slight differences in response times between database lookups and insertions.
The incoming data is rigorously validated using zod against the bookingSchema, ensuring fields like slug, startsAt, clientName, and clientPhone meet the required criteria. A "honeypot" field, website, is included in the form and schema; if this hidden field is populated, the request is silently dropped, effectively thwarting naive bots without alerting them to the trap.
Before creating the appointment, the system checks the professional's plan limits. The effectivePlan function determines the active plan, and if limits are in place (e.g., clients or appointmentsPerMonth), the system verifies that the professional has not exceeded them. If a new client would push the professional over their client limit, the booking is rejected unless the client already exists. Similarly, if the professional has reached their monthly appointment limit, the booking is denied.
The core of the booking logic resides within a database transaction with isolationLevel: "serializable". This is a crucial design choice to prevent race conditions. Inside this transaction:
- Conflict Check: The system first checks for any overlapping appointments for the professional within the proposed
startsAtandendsAttimes, excluding "rejected" or "cancelled" appointments. If a conflict is found, the transaction is rolled back, and aSLOT_TAKENerror is thrown, resulting in a409 Conflictresponse. - Client Upsert: The system attempts to find an existing client record in the
clientstable using the providedclientPhoneorclientEmail(if available) for the specific professional. If a client is found, theiremailis updated if it was previously null. If no client is found, a new record is inserted into theclientstable. - Appointment Insertion: Finally, a new appointment is inserted into the
appointmentstable, linking it to the professional and client. Thestatusis set to "confirmed" if the professional hasautoConfirmenabled, otherwise it defaults to "pending_confirmation". ThepaymentStatusis initially set to "none".
Following a successful booking, a series of notifications are dispatched:
- An in-app notification is created in the
notificationstable for the professional. - A WhatsApp message (
msgBookingRequest) is sent to the professional'sphone. - An email (
sendBookingRequestEmailToProfessional) is sent to the professional'semailif available.
Analytics events, booking_received_server and first_booking_at, are captured using Posthog, attributing the booking to the professional's id. The response to the client is a simple success: true, intentionally omitting sensitive appointment details to maintain privacy and prevent information leakage.
Appointment Management
While the initial booking is handled by /api/booking, the subsequent management of appointments (e.g., confirming, rejecting, cancelling, marking as paid or completed) is managed through the /api/appointments/[id] API route. This PATCH endpoint allows authenticated users (either the professional or the client, depending on the action) to update an appointment's status or paymentStatus. This separation of concerns ensures that the initial booking process is streamlined, while post-booking actions are handled by a dedicated, permission-controlled endpoint.
Design Decisions
Several key design decisions shape the booking flow:
-
Data Minimization in GET Requests: The
GET /api/bookingendpoint, which fetches availability, explicitly selects onlyidandsessionDurationfrom theprofessionalstable. This was a deliberate choice to prevent the accidental leakage of sensitive professional data (likeemail,phone,userId,plan,trialEndsAt,stripeCustomerId,stripeSubscriptionId) to unauthenticated visitors, which was a vulnerability in a previous iteration. TheBookingWidgetalready receivessessionDurationas a prop, making thesessionDurationfetch in the API redundant, but theidis still necessary for subsequent queries. -
Atomic Transaction for Booking POST: The
POST /api/bookingendpoint wraps the conflict check, client upsert, and appointment insertion in a DrizzletransactionwithisolationLevel: "serializable". This is critical for data integrity. Without it, two simultaneous booking requests for the same slot could both pass an initial conflict check and then both attempt to insert, leading to double-bookings. The serializable isolation level forces the database to detect and prevent such "phantom reads," ensuring only one booking succeeds for a given slot. -
Honeypot and Response-Time Floor for Spam/Security: The inclusion of a hidden
websitefield (honeypot) in thebookingSchemaand its server-side check is an effective, low-cost measure against automated spam bots. By returning a200 OKwithsuccess: truefor bot submissions, the system avoids signaling to the bot that the field is a trap. TherespondAfterFloorfunction, which delays the response to a minimum of 250ms, further enhances security by blunting timing attacks. This prevents attackers from distinguishing between existing and non-existing client records based on minute differences in database query times. -
Client Upsert Logic: The booking
POSTintelligently handles client records. It first attempts to find an existing client associated with the professional using eitherclientPhoneorclientEmail. If found, it updates the client's email if it was previously null. If no matching client exists, a new client record is created. This prevents duplicate client entries and maintains a clean client list for the professional. -
Decoupled Notifications: The system uses a "fire-and-forget" approach for sending WhatsApp messages and emails (
.catch(console.warn)). This design ensures that a failure in a notification service does not prevent a successful booking from being recorded. While this prioritizes booking completion, it means notification failures are logged but not retried automatically within this flow.
Potential Improvements
-
Redundant
sessionDurationFetch: TheBookingWidget(src/app/p/[slug]/booking-widget.tsx) already receivessessionDurationas a prop fromsrc/app/p/[slug]/page.tsx. However, theGET /api/bookingroute still queries theprofessionalstable to fetchsessionDurationagain. TheBookingWidgetcould pass itssessionDurationprop directly to thegenerateSlotsfunction, eliminating the need for the API to fetch it, thereby reducing database load and API response time. -
Integrated Payment Processing: The
appointmentstable includespaymentStatusandstripePaymentIntentId, and theBookingWidgetdisplaysPixQRfor immediate payment. However, thePOST /api/bookingcurrently setspaymentStatus: "none". This suggests that actual payment capture (e.g., via Stripe or confirming a Pix payment) is not directly integrated into the initial booking submission. An improvement would be to integrate a payment intent creation or confirmation step within the booking transaction, updatingpaymentStatusto "pending" or "authorized" and storingstripePaymentIntentIdimmediately, rather than relying on an out-of-band process. -
Robust Notification Delivery: The current notification sending (
sendWhatsApp,sendBookingRequestEmailToProfessional) uses.catch(console.warn), which means notification failures are logged but not retried. For critical notifications, such as booking confirmations, implementing a dedicated queueing system (e.g., using a message broker or a simple database table for pending notifications) with retry logic would significantly improve reliability and ensure messages are eventually delivered, even if external services are temporarily unavailable.
References
src/app/api/booking/route.tssrc/app/p/[slug]/page.tsxsrc/app/p/[slug]/booking-widget.tsxsrc/lib/slots.tssrc/lib/ratelimit.tssrc/app/api/appointments/[id]/route.ts