Skip to main content

Public Booking Widget — Design Spec

Date: 2026-05-24 Status: Approved by user, ready for implementation plan Subsystem: Public API + Embed widget + In-app inbox Touches: lead_form_configs, lead_submissions, appointments, public routes, Cloudflare Pages, permission tree

1. Purpose

Ship a public booking widget that any OdontoX clinic can embed on its own website. Visitors pick a real, live slot from the clinic’s actual availability and submit a booking request. The request lands inside OdontoX as a lead_submissions row (formType='booking'):
  • Returning patients (phone matches an existing patient in the clinic): an appointments row is created in requested status linked to the existing patient. Reception can one-click Accept to schedule.
  • New people (no phone match): the lead sits in a Pending inbox. Reception clicks Accept → patient is created (MRN auto-assigned, no portal user, no PIN, no invite email) using the form data, and an appointment is created in requested status.
Goal: kill the manual back-and-forth where reception phones every web inquiry to negotiate a time. Patient submits a real slot; reception just confirms.

2. Non-goals (v1)

  • Online payment / deposits.
  • Patient self-cancel from the widget (manage-booking link via email is reserved for later; the existing /public-appointments/respond flow already handles cancel/confirm with signed tokens).
  • Real-time slot push (SSE/WebSocket). Re-check at submit is sufficient.
  • Multi-clinic booking on a single form. Each form is single-clinic; multi-branch clinics get multiple forms.
  • Mobile-app integration. Widget is web-only.
  • Doctor selection on the public form. v1 is “any available doctor”; reception assigns at Accept time.

3. System architecture

Three subsystems.

3.1 Public API

Hono on Cloudflare Workers under /api/v1/public/booking/*. Three endpoints. All gated by X-OdontoX-Token header (per-form, rotatable), origin allowlist (per-form), invisible Cloudflare Turnstile, and rate limits. Pattern mirrors the existing public-leads.ts.

3.2 Embed widget

Small JS bundle (~25-40KB gzipped target) hosted on a new Cloudflare Pages project (booking-embed-app/) at https://embed.odontox.io/v1/booking.js. Drop-in <script> tag renders the form into the host page via Shadow DOM (CSS isolation without iframe friction). Calls only the public API; carries no secrets.

3.3 Inbox surfaces (in the existing OdontoX app)

Two views over the same underlying lead_submissions table:
  • Website Leads page — chronological log of every public-form submission (contact + booking). Existing page, extended.
  • Appointments → Requests tab — action-oriented filter of pending booking submissions, with one-click Accept / Decline.
  • Dashboard card — “Pending booking requests: N” → links to the Requests tab.

4. Data model (additive migration)

No new tables. Three additive changes to existing tables.

4.1 lead_form_configs

// New enum
export const leadFormTypeEnum = pgEnum('lead_form_type', ['contact', 'booking']);

// New columns on lead_form_configs:
formType: leadFormTypeEnum('form_type').default('contact').notNull(),
bookingConfig: jsonb('booking_config').$type<BookingFormConfig | null>(),
BookingFormConfig shape (null for contact forms):
type BookingFormConfig = {
  requireDob: boolean;             // default false
  showTreatmentDropdown: boolean;  // default true
  treatmentOptions: string[];      // default ["Consultation","Cleaning","Pain","Other"]
  primaryColor: string | null;     // hex, widget submit button color
  introText: string | null;        // shown above form fields
  maxDaysAhead: number;            // default 30
  minHoursAhead: number;           // default 2
  autoConfirmIfPhoneMatch: boolean;// default false
  dailySubmissionCap: number;      // default 50 — per-form per-day rate limit
};

4.2 lead_submissions

Nullable booking-specific columns (null for contact submissions):
requestedDate: date('requested_date'),                  // YYYY-MM-DD, NOT encrypted (queryable)
requestedTime: text('requested_time'),                  // "HH:mm", NOT encrypted
requestedDurationMinutes: integer('requested_duration_minutes').default(30),
treatmentType: text('treatment_type'),                  // NOT encrypted
appointmentId: text('appointment_id'),                  // FK to appointments, nullable
phoneMatchPatientId: text('phone_match_patient_id'),    // FK to patients, nullable
idempotencyKey: text('idempotency_key'),                // client-generated UUID, 24h replay window

4.3 Indexes

lead_submissions_clinic_requested_date_idx ON (clinic_id, requested_date, status)
lead_submissions_idempotency_idx ON (clinic_id, idempotency_key)  // unique

4.4 Status semantics

lead_status_enum is unchanged. Lifecycle for booking submissions:
  • new — submitted, pending reception action (or, if autoConfirmIfPhoneMatch=true and phone matched, may already have appointmentId set but lead still new until reception confirms).
  • converted — reception accepted; appointmentId populated, convertedPatientId populated.
  • archived — reception declined for a non-spam reason.
  • spam — reception flagged as spam (no patient email sent).
  • contacted — reserved; existing flow for contact-form leads.

5. Public API surface

All endpoints live under /api/v1/public/booking/*. Mounted in server/src/api.ts. Common middleware order: CORS (origin allowlist echo) → token validation → tenant-status gate (per trial-gating memory) → rate-limit → handler.

5.1 GET /api/v1/public/booking/config

Headers: X-OdontoX-Token: <form_token>, Origin: <embed-origin>. Response 200:
{
  "clinic": {
    "name": "ssh & Associates",
    "phone": "+923001234567",
    "timezone": "Asia/Karachi"
  },
  "form": {
    "label": "Book Online",
    "requireDob": false,
    "showTreatmentDropdown": true,
    "treatmentOptions": ["Consultation","Cleaning","Pain","Other"],
    "primaryColor": "#1f6feb",
    "introText": "Walk-ins also welcome…",
    "maxDaysAhead": 30,
    "minHoursAhead": 2
  },
  "turnstileSiteKey": "<CF Turnstile public site key>"
}
Errors:
  • 404 FORM_NOT_FOUND — token invalid / revoked / form inactive / clinic deleted.
  • 403 ORIGIN_NOT_ALLOWED — Origin header not in allowedOrigins.
  • 503 CLINIC_UNAVAILABLE — clinic on expired trial / suspended (trial-gating memory).
Caching: 60s edge cache keyed by (formToken). No PII.

5.2 GET /api/v1/public/booking/available-slots?date=YYYY-MM-DD

Same headers. Internals: calls calculateAvailableSlots({ clinicId, date, doctorId: null }) (union of all doctors), applies the existing 30-min buffer, additionally excludes any slot earlier than now + minHoursAhead and any date outside [today, today + maxDaysAhead]. Response 200:
{ "date": "2026-05-27", "slots": ["09:00","09:30","10:00"], "clinicClosed": false }
Caching: 60s edge cache keyed by (formToken, date). Freshness within ~1 min is fine because submit re-checks. Rate limits: 20 req/min/IP, 300 req/min/form.

5.3 POST /api/v1/public/booking/submissions

Same headers, plus Idempotency-Key: <uuid> from the widget. Body (zod-validated):
{
  firstName: string,        // 1..80
  lastName: string,         // 1..80
  phone: string,            // E.164 after normalization
  email?: string,           // RFC valid if present
  dateOfBirth?: string,     // YYYY-MM-DD, required only if bookingConfig.requireDob
  requestedDate: string,    // YYYY-MM-DD, within window
  requestedTime: string,    // "HH:mm", must align with slot increment
  treatmentType?: string,   // free text or from treatmentOptions
  notes?: string,           // <=500
  honeypot?: string,        // must be empty
  turnstileToken: string,
  sourceUrl?: string,
  referrer?: string,
  utm?: Record<string,string>
}
Server-side pipeline (in order):
  1. Token + origin + clinic-status check (cached config). 403/404/503 on failure.
  2. Turnstile siteverify → 403 INVALID_TURNSTILE if fails.
  3. Honeypot check — if non-empty, return 202 with synthetic {ok:true,status:"received"} and short-circuit (no row inserted, no telemetry leak to attacker).
  4. Body validation (zod + phone E.164 normalize). 400 INVALID_PAYLOAD on failure.
  5. Idempotency checkSELECT … WHERE clinic_id=$1 AND idempotency_key=$2 within 24h. If found, return the stored response (replay-safe).
  6. Dedupe check — existing 5-min dedupeHash upsert window. Same-hash repeat returns the original row.
  7. Pending uniquenessSELECT COUNT(*) WHERE clinic_id=$1 AND formType='booking' AND status='new' AND phone_match_patient_id=$x OR (encrypted_phone=$norm). If >0, return 422 DUPLICATE_PENDING.
  8. Slot re-checkcalculateAvailableSlots({clinicId, date: requestedDate, doctorId: null}). If requestedTime ∉ slots, return 409 SLOT_TAKEN with the fresh slot list in the response body so the widget can re-prompt.
  9. Phone lookup — search patients (this clinic, not deleted) by normalized phone. If matched → phoneMatchPatientId is set on the lead. If bookingConfig.autoConfirmIfPhoneMatch=true, immediately create the appointments row in requested status linked to the matched patient; populate appointmentId on the lead.
  10. Insert lead_submissions row with PII encrypted via existing encryptPatientPHI helpers; status new; formType='booking'.
  11. Side-effects via executionCtx.waitUntil(), non-blocking:
    • Patient auto-reply email (if email provided and autoReplyEnabled) — existing pipeline, sender voice “[Clinic Name]”.
    • In-app notification + browser push to clinic users with website_leads:view permission.
    • Per-user “email me on web booking” emails (per Q7 notification matrix).
    • Audit log: lead.booking.submitted, appointment.requested.created (if auto-confirmed).
  12. Response 200:
{
  "ok": true,
  "submissionId": "ls_…",
  "status": "received",
  "appointment": null | {
    "id": "ap_…",
    "status": "requested",
    "date": "2026-05-27",
    "time": "11:30"
  }
}
Error codes:
CodeHTTPMeaning
INVALID_TOKEN403Token unknown/revoked/inactive
ORIGIN_NOT_ALLOWED403Origin not in allowedOrigins
INVALID_TURNSTILE403Turnstile siteverify failed
CLINIC_UNAVAILABLE503Tenant trial expired / suspended
INVALID_PAYLOAD400Body validation failure (per-field details)
SLOT_TAKEN409Requested slot no longer available; response includes fresh slots
DUPLICATE_PENDING422Phone already has a pending booking at this clinic
RATE_LIMITED429IP, form, or clinic cap exceeded
INTERNAL_ERROR500Anything else
Rate limits (Cloudflare rate-limit binding + DB count for clinic cap):
ScopeLimitNotes
Slot queries / IP20/minCF binding
Slot queries / form300/minCF binding
Submissions / IP5/hourCF binding
Submissions / clinicdailySubmissionCap (default 50/day)DB count WHERE created_at > now() - '24h'

6. Embed widget

6.1 Hosting

New Cloudflare Pages project booking-embed-app/ (separate from marketplace-app/). Deployed at embed.odontox.io. Bundle path: embed.odontox.io/v1/booking.js (versioned path so we can ship v2 without breaking v1 embeds).

6.2 Clinic embed snippet

<div id="odontox-booking"></div>
<script async
  src="https://embed.odontox.io/v1/booking.js"
  data-token="ofb_xxxxxxxxxxxx"
  data-target="#odontox-booking"
  data-lang="en"></script>
Single required attribute: data-token. data-target defaults to #odontox-booking. data-lang defaults to en.

6.3 Rendering

  • Shadow DOM (open mode for inspectability). CSS isolation from host; layout still flows in the host.
  • Mobile-first responsive. Single-column form on narrow viewports.
  • Inherits host page font stack so it looks native; uses bookingConfig.primaryColor for accent (submit button, focused inputs).
  • Two states:
    • Form state — fields in order: First name, Last name, Phone, Email (if email field shown), DOB (if requireDob), Treatment dropdown (if showTreatmentDropdown), Notes, Date picker, Time picker, Submit.
    • Success state — “Thanks , has received your booking request for at . You’ll get a confirmation email shortly.” + “Book another” button.

6.4 Behavior

  • On load: GET /config → render form → mount Turnstile invisible.
  • Date picker is calendar-style. Greys out: closed days, fully-booked days, days outside [now + minHoursAhead, today + maxDaysAhead]. Lazy-fetches /available-slots when the picker opens; pre-fetches today + next 7 days in background.
  • Time picker shows actual openings for the selected date. Empty → “No slots available, try another day.”
  • Submit:
    • Client generates Idempotency-Key UUID in memory.
    • Show spinner; disable form.
    • On 409 SLOT_TAKEN: toast “That slot was just taken, please pick another”; re-render time picker with the fresh slots from the response.
    • On 422 DUPLICATE_PENDING: toast “You already have a pending request. We’ll be in touch.”
    • On 429: toast “Too many requests. Please try again in a few minutes.”
    • On 503: toast “Online booking is temporarily unavailable. Please call the clinic.”
    • On 200: render Success state.
  • All copy keyed by lang. en v1; ur is a follow-up.
Bottom-right, ~16px tall OdontoX logo SVG only (no “OdontoX” text). <a href="https://odontox.io" target="_blank" rel="noopener"> wrapping the SVG. Non-removable in v1.

6.6 Security in the widget

  • No secrets in the bundle. Token is by design semi-public; origin allowlist + Turnstile + rate limits are the defense.
  • CSP-friendly: no inline scripts, no eval. Strict CSP nonce hooks supported via data-attr if a clinic wants it (data-csp-nonce="...").
  • All API calls hit https://api.odontox.io/api/v1/public/booking/* (same origin family as the rest of the platform).

7. In-app surfaces

7.1 Settings → Website Forms (extending existing leads admin)

Extend the existing leads form configuration page in dashboard-app/.
  • Existing “Contact Form” config UI unchanged.
  • Add ”+ Add Booking Form” button → wizard creating a lead_form_configs row with formType='booking'. Sections:
    • Basic — label, allowed origins (multi-input), active toggle.
    • Form fields — Require DOB, Show Treatment Dropdown, Treatment Options editor.
    • Branding — Primary color picker, Intro text.
    • Slot rules — Max days ahead (1..90, default 30), Min hours ahead (0..72, default 2), Auto-confirm returning patients (default off), Daily submission cap (10..500, default 50).
    • Auto-reply email — existing toggle + subject + body editor.
    • Embed snippet — copy-paste box with the <script>, plus “Test in new tab” button that opens the widget against this token in isolation against a host-page sandbox we ship at embed.odontox.io/v1/test.
    • Danger zone — Rotate token (immediate invalidation), Delete form.
  • Inline stats per form: last submission timestamp, 7-day submission count, token preview (masked).

7.2 Website Leads page (extending existing)

  • Adds a “Form type” filter chip: All / Contact / Booking.
  • Booking rows render extra columns: Requested date, Requested time, Treatment, “Returning” badge (when phoneMatchPatientId is set).
  • Clicking a booking row opens the existing lead detail drawer with a new “Booking” section showing the slot/treatment/notes; primary actions become Accept / Decline for booking-type leads (contact-type leads keep “Convert to Patient”).

7.3 Appointments → Requests tab (new tab on existing Appointments page)

  • Tab order: Calendar | List | Requests (N) — N is a badge with the pending count, refreshed via TanStack Query on focus.
  • Query:
    SELECTFROM lead_submissions
    WHERE clinic_id = $me
      AND form_type = 'booking'
      AND status = 'new'
    ORDER BY created_at DESC
    
  • Columns: Time received, Patient name (+ “Returning” badge if phoneMatchPatientId), Phone, Requested date/time, Treatment, Notes (truncated, hover full), Source URL (small subtle), Actions.
  • Inline buttons: Accept, Decline.
Accept flow:
  • Returning patient (phoneMatchPatientId set):
    • One click. Server: create appointments row (status requested) against that patient if appointmentId not already populated; update lead status='converted', set appointmentId. Existing notification pipeline fires (in-app + WhatsApp + email per status notification matrix).
    • UI: side-drawer opens with the new appointment for review.
  • New person (no phoneMatchPatientId):
    • Modal opens pre-filled from lead form data (First/Last/Phone/Email/DOB).
    • Reception edits if needed → “Confirm” → server txn: create patients row (MRN auto-assigned via document-numbering; no portal user, no PIN, no invite email) + create appointments row (requested) + update lead (status='converted', convertedPatientId, appointmentId).
    • UI: side-drawer opens with the new appointment.
Decline flow:
  • Modal with reason dropdown: “Slot no longer available” / “Spam” / “Duplicate” / “Other” + optional text.
  • Optional checkbox “Send polite email to patient if email provided” — default ON for non-spam reasons, force-OFF (and disabled) for Spam.
  • Submit → lead status='archived' (or 'spam' if reason is Spam).
Empty state: “No pending booking requests. New requests appear here automatically.”

7.4 Dashboard widget

Small card on the main dashboard. Shows “Pending booking requests: N” (current count, gated by website_leads:view). Clicks through to Appointments → Requests tab. ~30min of work; included because of explicit “full-on environment inbox” feedback.

7.5 Notifications

  • New event type: booking_request_received. Triggers:
    • In-app notification (existing createNotification) to all users at the clinic with website_leads:view.
    • Browser push (existing PushNotification path) to the same set.
    • Optional per-user email (per-user opt-in in notification preferences: “Email me when a web booking comes in”).
  • Patient-facing email is the auto-reply that fires at submit (existing pipeline).
  • WhatsApp/email to the patient at the appointment level (existing pipeline) fires when reception Accepts, gated by the existing WhatsApp module/feature flags.

7.6 Permissions

  • website_leads:view (existing) — gates Website Leads page + Requests tab + dashboard card.
  • website_leads:manage (existing) — gates Accept/Decline buttons.
  • bookings:forms:configure (new) — gates Settings → Website Forms booking-form editor. Defaults to admin only.
Role defaults in the permission tree (per feature-persona memory):
  • Admin: view + manage + forms:configure.
  • Reception: view + manage. Default ON (explicit per user request).
  • Doctor: view only. Default ON, toggleable per clinic.
  • Patient: no access.

8. Security, observability, edge cases

8.1 Layered defense

LayerPurpose
X-OdontoX-Token headerIdentifies form; rotatable; revocable per form
Origin allowlist (per form)CORS only echoes allowed origins; non-browser callers blocked
Cloudflare Turnstile (invisible)Bot defense; siteverify on every submit
Honeypot fieldCheap second-line bot defense; fake 202 response
IP rate limits20 slot/min, 5 submits/hour per IP (CF binding)
Per-form daily capDefault 50/day; configurable in form settings
Phone uniquenessMax 1 pending booking per (clinic, normalized phone)
Dedupe hash (existing)5-min upsert window for accidental double-clicks
PII encryptionReuses encryptPatientPHI helpers for First/Last/Email/Phone/DOB/Notes
Idempotency-KeyClient UUID, 24h replay window
Slot re-check at submitRace-condition guard with 409 + fresh slots
Audit logEvery submission, accept, decline, token rotation
IP hashingsha256(ip + clinicId), purged after 90 days (existing pattern)
No PHI in logsWorker logs structured with formId, clinicId, eventType only

8.2 Observability

  • Worker logs: structured, no PII, includes formId, clinicId, eventType, status code, response time.
  • Counters (CF analytics / D1):
    • submissions_total per clinic per day
    • slot_queries_total per clinic per day
    • submissions_409_rate (signals slot scarcity → ops can suggest more doctors)
    • submissions_429_rate (signals abuse or legit spike)
    • turnstile_failure_rate (signals widget integration breakage)
  • Superadmin dashboard: new “Web Booking activity” card — top clinics by submissions this week, error rates, key/form health.
  • No new Langfuse traces (mechanical, not AI).

8.3 Edge cases

  • Clinic closes mid-day → available-slots recomputes next request; widget re-fetches on next date pick.
  • Slot taken between picker render and submit → 409 with fresh slots, widget re-prompts.
  • Patient with no email → on-screen confirmation only, no auto-reply.
  • Phone normalization fails → 400 INVALID_PAYLOAD before insert.
  • Multiple booking forms on same site → fine, different tokens.
  • Token rotated mid-session → 403 on submit; widget shows “Please refresh the page” toast.
  • Phone matches a soft-deleted patient → treated as no-match; falls into Pending inbox.
  • Clinic on expired trial / suspended → /config returns 503 CLINIC_UNAVAILABLE; widget shows tasteful “Online booking is temporarily unavailable” message (per trial-gating memory).
  • Per-clinic license patient cap reached at Accept time → server returns clear error to reception (“Patient limit reached, upgrade to add new patients”); same guard as existing patient creation.
  • Returning patient with autoConfirmIfPhoneMatch=true AND slot taken between submit and insert → 409; widget re-prompts even though phone matched.

9. Rollout

9.1 Phase 1 (this spec)

  • Schema migrations.
  • Public API endpoints.
  • Cloudflare Pages project booking-embed-app/ + initial v1 bundle.
  • Settings → Website Forms booking-form editor.
  • Website Leads page extension (form-type filter, booking columns, Accept/Decline actions).
  • Appointments → Requests tab.
  • Dashboard “Pending booking requests” card.
  • Permission tree update (reception default ON).
  • Tenant feature flag: clinic_modules.config.tabs.webBookingEnabled (default OFF per WA lifecycle pattern). Clinics opt in.
  • Internal docs (docs/api-reference.md per API documentation discipline memory).
  • Superadmin parity (per superadmin UI parity memory): superadmin can list/inspect forms + submissions across all tenants, override flag per tenant.

9.2 Phase 2 (post-launch, NOT in this spec)

  • Per-doctor booking forms (doctor selector in widget).
  • Urdu (ur) language pack.
  • .ics calendar attachment in auto-reply email.
  • SMS auto-reply option.
  • Optional phone-OTP verification toggle per form.
  • Patient self-cancel via signed manage-booking link in auto-reply email (reuses existing /public-appointments/respond flow).

10. Out of scope (explicit)

  • Online payment / deposits.
  • Real-time slot push (SSE/WebSocket).
  • Multi-clinic single-form booking.
  • Native mobile app integration.
  • Marketing/SEO booking pages (book.odontox.io/<slug> was considered as a fallback in brainstorming; not in v1).

11. Open follow-ups for the implementation plan

  • Confirm exact migration file numbering (next after most recent migration).
  • Confirm whether appointments.appointmentDate/appointmentTime typing matches what the slot picker produces (YYYY-MM-DD + HH:mm).
  • Confirm existing calculateAvailableSlots accepts doctorId: null for “any doctor” or whether a small adaptation is needed.
  • Confirm the existing Cloudflare rate-limit binding has a separate namespace for booking-public endpoints.
  • Confirm whether the existing leads form admin page lives in dashboard-app/ and not a separate route bundle.
  • Identify the exact superadmin route to extend for the inspection view.

Approved by user: Yes (2026-05-24 brainstorming session). Ready for implementation plan: Yes.