Two engines

Appointments and table reservations are separate products sharing plumbing. Read this first.

Gaplessly runs two separate booking engines behind one multi-tenant product.

  • Appointments: a client books one service with one staff member for a set duration. Tables: book_services, book_providers, book_appointments. Overlap is prevented by book_appointments_no_overlap, keyed on the provider.
  • Table reservations (hospitality): a guest books a table for a party size inside a service period, for a turn. Tables: book_tables, book_service_periods, book_reservations. Overlap is prevented by book_reservations_no_overlap, keyed on the table.

They are two products, not one product with a flag. Separate tables, separate exclusion constraints, separate slot mathematics. Shared plumbing (clients, notifications, auth, uploads, organization settings) is intended and is grouped under Shared. Shared booking logic is not.

An organization picks its engine once, at creation, via businessType. It cannot be changed afterwards: flipping a live org strands whatever it already has, so a conversion is a data migration rather than a settings toggle.

The 403 you will hit first

Every dashboard operation tagged Appointments engine or Hospitality engine carries x-engine. Those operations call requireMember(role, vertical), which compares the vertical against the caller org's business_type and returns 403 on a mismatch: an appointments org calling POST /api/tables is refused before any write. Operations without x-engine do not make that check.

That distinction is load-bearing, and one place it bites: the public POST /api/book/{slug}/appointments and GET /api/book/{slug}/availability are tagged as the appointments engine but carry no x-engine, because they check only that the org is active. Their hospitality counterparts resolve the org through a helper that refuses a non-hospitality business outright. So the public surface enforces the boundary in one direction only.

On this page