Status: 🟡 Built & wired, but unproven in production and missing two advertised legs (Google Calendar push, pre-meeting reminders). · Audience: GoBuild staff (eng, sales, ops) — internal, plus the
/bookpage itself which is prospect-facing.
A prospect lands on a contractor's public/bookpage, picks a meeting type and an open time slot, and confirms. GoBuild writes one Appointment, mints or matches a Lead (sourceOnline booking), logs a meeting activity, fires the nurture trigger, pings office staff, and emails the prospect a confirmation. This is a top-of-funnel capture surface — the booked consult is a hot inbound lead.
Status badges: 🟢 Live · 🟡 WIP / partial · ⚪ Dormant / not wired · ❌ Dead-end
House terms: a Client is the contractor org running on GoBuild (the tenant); a Job is one of their projects. On the booking page the two parties are the contractor (tenant staff who own the calendar) and the prospect (the homeowner picking a slot — not yet a Client, not yet a Job). A booked consult becomes a Lead in the pipeline, never a Job directly.
Cross-links: Sales Command Center · Integrations Catalog · Proposals & Estimates · Marketing & Nurture
Booking is GoBuild-native — it replaced an earlier Cal.com integration, and there is no third-party booking engine in the loop (app/models/appointment.py:1). Everything is computed in-app from config stored on the org.
| Piece | Where | Status |
|---|---|---|
Public booking page (GET /book) |
app/routers/booking.py:44 → templates/book_public.html |
🟢 |
Slot feed (GET /book/slots) |
app/routers/booking.py:63 |
🟢 |
Booking submit (POST /book) |
app/routers/booking.py:80 |
🟢 |
| Slot engine | app/services/booking.py:90 (open_slots) |
🟢 |
| Booking → Appointment + Lead + activity | app/services/booking.py:133 (create_booking) |
🟢 |
Appointment model (Hub-owned table) |
app/models/appointment.py:16 |
🟢 |
Admin builder (/portal/booking) — hours, meeting types, page copy |
app/routers/booking.py:117 |
🟢 |
| Bookings pane in Sales CC | app/routers/sales_cc.py:531 |
🟢 |
| New-booking staff notification | app/services/notify.py:146 |
🟢 |
| Confirmation email to prospect | app/services/booking.py:211 |
🟢 |
| Google Calendar push of the appointment | — | ❌ not wired (see §7) |
| Pre-meeting reminder to the prospect | — | ❌ not wired (see §8) |
There are no booking tables beyond appointments. Availability, meeting types, and page copy all live on Organization.settings['booking'] as JSON — no migrations to add a meeting type (app/services/booking.py:1, :50).
booking.get_config(org) merges the org's saved settings['booking'] blob over a DEFAULTS dict (app/services/booking.py:50, defaults at :21). The shape:
| Key | Meaning | Default |
|---|---|---|
timezone |
IANA tz slots are computed in | America/Toronto |
slot_minutes |
grid step between slot starts | 30 |
lead_hours |
earliest bookable = now + this | 12 |
horizon_days |
latest bookable = today + this | 21 |
hours |
per-weekday list of [start, end] "HH:MM" windows; [] = closed |
Mon–Fri 09:00–17:00, weekends closed |
meeting_types |
list of {slug, name, minutes, location, description, active} |
"Free 30-min consult" (phone) + "On-site visit" (60 min) |
page |
public-page copy: headline, intro, perks[] (AI-draftable) |
canned "Let's build something great together" |
Admin saves happen through small POST handlers under /portal/booking/*: availability (:238), hours (:255), meeting-types (:274, add/delete/toggle), and page (:203) — each rewrites the blob and calls booking.save_config (:64). Page copy can be AI-drafted via /portal/booking/page/ai → ai_assistant.draft_booking_page (:221). 🟢
Because /book is unauthenticated, the router must figure out which contractor is being booked from the request host (app/routers/booking.py:28, _resolve_org). The precedence:
request.scope['site_org_id'] — set by the marketing-host middleware when the prospect is on a partner's own marketing site. Used directly if it resolves to a real org (:33).resolve_request_org() reads the tenant slug from the host (app/tenancy/resolver.py:66, :73).sole_org() returns the org only when exactly one Organization exists (app/tenancy/resolver.py:59). This is the legacy single-tenant / per-droplet path.None and /book returns a 404 "Booking is not available." rather than mis-booking against the wrong contractor (app/routers/booking.py:47, resolver docstring at :70).⚠️ Gotcha: the sole-org fallback means that on a fresh single-tenant deploy
/book"just works" with no host wiring — but the moment a second org exists on the same host without subdomain routing, that same URL starts 404-ing. This is intentional fail-closed behavior, not a bug. 🟡
Three requests make up a booking:
a) GET /book (app/routers/booking.py:44) renders book_public.html with the active meeting types, the page copy, the org timezone, the horizon, and brand colours/logo pulled from preferences.get_prefs (:56). Query params pre-fill the form (name, email, phone, lead, type) so an existing lead can be sent to a pre-seeded booking link (:53).
b) GET /book/slots?type=…&date=… (:63) returns JSON {slots: [{iso, label}]} for one meeting type on one day. Empty list on any bad input (unknown type, unparseable date, ambiguous org) — the UI just shows "no times."
c) POST /book (:80) — the confirm. It:
open_slots and checking the chosen ISO is in the set — the guard against a stale UI double-booking (:96). If gone: "That time was just taken — please pick another.":99),booking.create_booking(...), passing the visitor anon_id cookie for attribution (:107).On success it returns {ok: true, title, when} — the page shows a confirmation inline. Note all error paths return HTTP 200 with {ok: false, error} (:86, :90, :98), so the front end handles them as JSON, not HTTP failures.
open_slots(db, org, cfg, mtype, day) (app/services/booking.py:90) is pure server-side computation — there is no availability table:
hours windows; no window ⇒ no slots (:95).earliest = now + lead_hours and horizon = today + horizon_days; reject days outside [today, horizon] (:99).(start, end) UTC ranges (:108).slot_minutes, emitting a start iff start + meeting_minutes ≤ window_end, the slot is ≥ earliest, and it doesn't overlap an existing booking (:123–:129).Slots are computed tz-aware in the org timezone, then converted to UTC for storage and overlap checks. Meeting length comes from the meeting type (mtype.minutes); the grid step comes from slot_minutes, so a 60-min visit can still start on 30-min boundaries.
Single-calendar model: availability is per-org, not per-staff-member. There's one weekly-hours grid and one appointment pool for the whole contractor. Overlap detection means the org can hold one meeting at a time across all types — there's no notion of two crews taking two consults in the same hour. 🟡
create_booking (app/services/booking.py:133) is the important part. In one transaction it:
Appointment — status="booked", start_at/end_at in UTC, meeting_type slug, prospect name/email/phone, notes (:145).:157):
lead_id was passed (a "Book a site visit" link from an existing lead) → link to it;source="Online booking", status=new, and next_action="Prep for {title} on {when}" (:162). For new leads it also ensure_person(...) so the prospect becomes a Person with role="lead" (:168).LeadActivity of kind meeting: "📅 Booked "{title}" for {when}" + location/notes (:178).:183).workflows.trigger_event(event="lead_created"), same entry point as manual-create and web-form leads, so a booked consult enters the nurture sequence (:193). See Marketing & Nurture.notify.notify_new_booking sends an in-app / email / SMS / push alert (per each staff member's channel prefs) linking to the lead (:202, notify at app/services/notify.py:146).Steps 4–7 are all wrapped in best-effort try/except — a hiccup in nurture, notify, or email never blocks the booking (:187, :198, :207, :218).
The tie into the pipeline: a booking does not create a Job. It creates (or attaches to) a Lead and drops it at the top of the CRM funnel with a scheduled meeting on it. From there it follows the normal lead → proposal → Job path documented in Proposals & Estimates. 🟢
No. Booked appointments are NOT pushed to Google Calendar. This is the most important caveat on the page.
app/integrations/gcal/client.py:1) and a two-way sync orchestrator (app/services/gcal_sync.py:1). It is dormant unless settings.gcal_configured (app/integrations/gcal/client.py:25) — see the Integrations Catalog.gcal_sync only ever pushes/pulls ScheduleItem — i.e. Job task dates, mapped 1:1 as all-day events (app/services/gcal_sync.py:43 push_item, :79 push_all, :96 pull). Its map table keys on schedule_item_id (GCalEventMap).Appointment from any gcal code path. Grep confirms: appointments are read by the booking admin, the Sales-CC pane, and command-center — never by gcal_sync.Net: even when a contractor connects Google Calendar, their booked consults do not appear on it. Only Job schedule tasks sync. Wiring appointment → GCal would be a net-new feature (upsert an all-day/timed event in create_booking + cancel_appointment). 🟡 opportunity, ❌ today.
What exists: on booking, if the prospect gave an email, create_booking sends the appointment template once, immediately, as a confirmation (app/services/booking.py:211–:219). It honours the org's System-Emails toggle via outbound_email.send. The template is registered as "Booking confirmation" — "Automatically, when a client books" (app/services/email_registry.py:133), though the Email-Studio card confusingly labels the same slug "Appointment reminder" (app/services/email_studio.py:90). Same template, sent at booking time only.
What does NOT exist: there is no scheduled job that sends a reminder before the meeting. The scheduler runs lead follow-ups and drips (app/services/scheduler.py:307) but never queries appointments to send a T-minus-24h nudge. The confirmation copy even promises "We'll text when we're on the way" — that text is manual, not automated.
The one in-app "reminder": the Command Center surfaces a card "Appointment(s) coming up" for appointments within 36 hours (command_center.py:256, :261; soon flag set at command_center.py:105). That's a staff-facing dashboard nudge, not a message to the prospect.
Summary: prospect gets one confirmation email at booking. Prospect gets no automated reminder. Staff get a booking alert + a 36h dashboard card. 🟡
Inside the Sales Command Center, the Bookings pane calls GET /portal/sales/bookings (app/routers/sales_cc.py:531), which returns command_center.upcoming_appointments(db, org, days=30, limit=40) (app/services/command_center.py:75). That helper lists non-canceled appointments in the next 30 days, converted to the org timezone, each tagged soon if ≤36h out and carrying the lead_id so the pane can deep-link to the lead (:100–:106).
Separately, the standalone booking admin at /portal/booking (app/routers/booking.py:125) is the fuller surface: a 6-week month calendar with appointments bucketed onto local days, an upcoming-40 list, plus the config editors (hours, meeting types, availability, page design). Cancel is a POST /portal/booking/appointments/{id}/cancel that just flips status="canceled" (:306) — canceled appointments free their slot again (they're excluded from the overlap query at booking.py:110).
Both admin surfaces require require_portal_role(UserRole.pm, UserRole.office) (booking.py:117) — field role is excluded, consistent with the rest of Sales CC.
The website builder exposes a {{gb:booking}} shortcode (app/services/shortcodes.py:70, partial at templates/partials/landing/shortcodes/booking.html) — a themed CTA that links to /book (overridable href). This is how a contractor drops a "Book a consult" button onto their marketing pages. The [gobuild-page skill] can also wire the booker into a hand-coded custom page.
used/adoption telemetry was found in-code; the module is fully built but there's no evidence any live org has taken a real booking. Worth checking the appointments table across tenants.appointment template's split personality — it's "Booking confirmation" in the registry but "Appointment reminder" in Email Studio, and its body talks about an on-site visit ("we'll be by… we'll text when we're on the way"). Should confirmation vs. reminder be two distinct templates?/portal/booking; the prospect gets no self-serve link. Is a client-facing reschedule/cancel flow on the roadmap, and does it need a signed token like the other portals?