Booking Flow Guide
This guide documents the complete booking flow from guest submission through host confirmation, calendar event creation, and email notifications.
Overview
The booking system connects four key components:
- Bookings API (
src/api/routes/bookings/) - Handles CRUD and lifecycle - Email Service (
src/lib/email/) - Sends transactional emails via Resend - Google Calendar (
src/lib/google-calendar/) - Creates events; a Meet conference only when the session's recording platform is Google Meet, otherwise the room rides inlocation+ description (#200) - Automation Engine (
src/lib/automation/) - Triggers time-based and event-based actions
Booking mode: episode vs general (#295 item 1)
A booking link carries creates_episode. TRUE (the default) is Episode booking, the flow this page describes. FALSE is General booking: the meeting is scheduled and nothing else.
create_booking_with_attendees snapshots the link's mode onto booking_sessions.creates_episode when the session row is created, and the ON CONFLICT DO UPDATE deliberately does not touch it. Every downstream decision reads the session snapshot.
bookings.episode_id IS NULLis not a mode test. The FK isON DELETE SET NULL, so deleting an episode nulls it on an EPISODE-mode booking too. Readbooking_sessions.creates_episode(orbooking_links.creates_episodeat offer time). The client helper isisGeneralBooking()insrc/lib/components/bookings/board-utils.ts, which treats a missing value as episode mode.
What general mode skips:
| Step | Episode mode | General mode |
|---|---|---|
episodes INSERT at submit | yes, pending_confirmation | never |
episode_guests INSERT per attendee | yes | never (attendees live on booking_attendees with episode_guest_id NULL) |
podcast_guests identity + guest network | via the episode_guests trigger | never |
bookings.episode_id / guest_id | set | NULL for life |
| Managed-episode slot at confirm | consumed by the pending_confirmation → draft flip | structurally impossible — no episode to flip, so PT429 cannot fire |
| Guest portal + verification emails | yes | no portal, no magic link, no verification email |
| Show-notes template at confirm | applied | skipped (guarded on result.episode_id) |
| AI guest research | available | rejected at the API (400) |
| Month-capacity gates (availability + submission) | applied | skipped |
| Collaboration analytics funnel | counted | excluded |
Automations fire identically in both modes. Episode magic tags zero-fill to ''; guests_* roster tags resolve from booking_attendees.
Complete Flow Diagram
Phase 1: Guest Submits Booking
Entry Point
Guest visits book.podcasterplus.com/{podcast-slug}/{booking-link-slug}
Source: src/routes/(book)/[podcastSlug]/[bookingSlug]/+page.svelte
Timezone Capture
The booking page detects the guest's browser timezone on load and lets them override it via the shared TimezoneSelector component. That value is submitted alongside the chosen slot and stored on the booking row as bookings.timezone. Every downstream consumer (email templates, Google Calendar, automation magic tags) reads the booking's own timezone as the authoritative source — see Timezone Management. The absolute instants in start_time / end_time are UTC ISO strings parsed via parseDateTimeLocal(naive, timezone); never call new Date(naiveString) on datetime-local input.
API Call
POST /api/bookingsSource: src/api/routes/bookings/index.ts (Line 148-458)
Validation Steps
Booking Link Validation (Line 161-186)
- Fetch booking link with podcast info
- Verify link is active
- Load availability rules
Time Validation (Line 188-220)
- Reject if time is in the past
- Check minimum notice (default: 24 hours)
- Check maximum advance booking (default: 60 days)
Conflict Detection (Line 222-232)
- Query existing pending/confirmed bookings
- Reject if time slot overlaps
Database Operations
| Order | Table | Operation | Status | General mode |
|---|---|---|---|---|
| 1 | bookings | INSERT | pending | same, with episode_id NULL |
| 2 | booking_links | UPDATE | total_bookings++ | same |
| 3 | episodes | INSERT | pending_confirmation | skipped |
| 4 | episode_guests | INSERT | invited | skipped |
| 5 | bookings | UPDATE | Link episode_id, guest_id | skipped |
booking_attendees rows are written in BOTH modes; in general mode they carry episode_guest_id = NULL.
Guest Access Token
A 32-character random access token is generated for the guest:
const accessToken = crypto.randomUUID().replace(/-/g, '').slice(0, 32);This token enables magic link portal access: /guest/e/[episodeId]?token=[access_token]. General bookings mint no episode_guests row, so they have no token and no portal.
Emails Sent
1. Booking Request Email (to Guest)
Template: src/lib/email/templates/booking-request.ts
| Field | Content |
|---|---|
| Subject | Your booking request for {Podcast} has been received |
| Header | Purple gradient with clock icon |
| Body | Booking details + "Awaiting host confirmation" notice |
2. Host Notification Email (to Podcast Owner)
Template: src/lib/email/templates/host-notification.ts
| Field | Content |
|---|---|
| Subject | New booking request: {Guest} for {Booking Type} |
| Header | Orange gradient with bell icon |
| Body | Guest info + booking details + custom field responses + dashboard CTA |
Host Lookup: The podcast owner is found via:
SELECT user_profiles.* FROM podcast_members
JOIN user_profiles ON podcast_members.user_id = user_profiles.id
WHERE podcast_members.podcast_id = ? AND podcast_members.role = 'owner'Phase 2: Host Confirms Booking
API Call
POST /api/bookings/:id/confirmSource: src/api/routes/bookings/index.ts (Line 572-854)
Authorization
- Requires Bearer token authentication
- User must have
owneroradminrole on the podcast
Google Calendar Event Creation
If the host has a connected Google Calendar (Line 632-717):
Get Calendar Connection
typescriptconst connections = await supabase .from('calendar_connections') .select('*') .eq('user_id', hostId) .eq('is_valid', true) .order('created_at', { ascending: true });Refresh OAuth Token
typescriptconst { access_token } = await refreshAccessToken( connection.refresh_token, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET );Create Calendar Event
typescriptconst event = await createCalendarEvent( accessToken, connection.calendar_for_events, { summary: `${podcast.title}: Recording with ${booking.guest_name}`, description: `${bookingLink.name}\n\nGuest: ${booking.guest_name}\nEmail: ${booking.guest_email}`, start: { dateTime: booking.start_time, timeZone: booking.timezone }, end: { dateTime: booking.end_time, timeZone: booking.timezone }, attendees: [{ email: booking.guest_email, responseStatus: 'needsAction' }], reminders: { useDefault: false, overrides: [ { method: 'email', minutes: 1440 }, // 24 hours { method: 'popup', minutes: 30 } ] } }, true // createMeetLink — ONLY when the effective platform is google_meet );Since #200 the flag is
shouldRequestMeetConference(effectivePlatform). Any other platform callscreateCalendarEvent(..., false)with the room inlocationand the description instead;in_personand "no meeting configured" create a plain event with neither.Store Calendar Data
meeting_url←event.hangoutLink(Meet), or the frozen manual linkmeeting_platform← the effective platform, frozen on the sessiongoogle_event_id←event.idgoogle_calendar_id← Calendar ID used
The persist error is checked: a failed write marks the session
failedrather than reporting success over an event the app has no id for.
Database Updates
| Table | Field Changes |
|---|---|
bookings | status→confirmed, confirmed_at, confirmed_by |
booking_sessions | meeting_url, meeting_platform, google_event_id, google_calendar_id, calendar_event_status |
episodes | status→draft |
episode_guests | status→active |
Booking Confirmed Email (to Guest)
Template: src/lib/email/templates/booking-confirmed.ts
| Field | Content |
|---|---|
| Subject | Confirmed: Your {Podcast} recording session |
| Header | Green gradient with checkmark icon |
| Body | Booking details + a platform-labelled join block (or an in-person note, or nothing when no link is set) + "What to expect" |
Automation Integration
After confirmation (Line 803-844):
Emit Event
typescriptawait emitAutomationEvent({ trigger_type: 'booking.confirmed', podcast_id: booking.podcast_id, context: { booking_id: booking.id, guest_email: booking.guest_email, guest_name: booking.guest_name, episode_id: booking.episode_id } });Schedule Time-Based Jobs
time.before_recordingtriggers (e.g., reminder 24h before)time.after_recordingtriggers (e.g., follow-up after recording)
Phase 3: Alternative Flows
Host Declines Booking
Endpoint: POST /api/bookings/:id/decline (Line 1035-1208)
Conditions: Only works on pending status bookings
Actions:
- Set booking
status→canceled - Set episode
status→draft - Set guest
status→expired - Send
booking-declinedemail - Emit
booking.declinedautomation event
Host Cancels Confirmed Booking
Endpoint: POST /api/bookings/:id/cancel (Line 863-1026)
Actions:
- Delete Google Calendar event (with
sendUpdates: 'all') - Set booking
status→canceled - Set episode
status→draft - Set guest
status→expired - Emit
booking.canceledautomation event - Cancel pending automation scheduled jobs
Error Handling
Non-Blocking Failures
These errors are logged but don't fail the booking operation:
| Operation | Failure Behavior |
|---|---|
| Calendar event creation | Booking confirmed without meeting URL |
| Email sending | Logged, user can retry manually |
| Automation event emission | Logged, automation may not trigger |
Validation Errors
| Code | Scenario |
|---|---|
| 400 | Invalid input, missing required fields, time validation failed |
| 401 | No authentication token |
| 403 | User not authorized (not owner/admin) |
| 404 | Booking link not found or inactive |
| 409 | Time slot conflict |
Data Model Reference
bookings Table
CREATE TABLE bookings (
id UUID PRIMARY KEY,
booking_link_id UUID REFERENCES booking_links(id),
podcast_id UUID REFERENCES podcasts(id),
episode_id UUID REFERENCES episodes(id),
guest_id UUID REFERENCES episode_guests(id),
-- Guest info
guest_name TEXT NOT NULL,
guest_email TEXT NOT NULL,
guest_phone TEXT,
guest_notes TEXT,
custom_field_responses JSONB,
-- Timing
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
timezone TEXT NOT NULL,
-- Status
status booking_status NOT NULL DEFAULT 'pending',
confirmed_at TIMESTAMPTZ,
confirmed_by UUID REFERENCES auth.users(id),
canceled_at TIMESTAMPTZ,
canceled_by TEXT, -- 'host' or 'guest'
cancellation_reason TEXT,
-- Calendar
google_event_id TEXT,
google_calendar_id TEXT,
meeting_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);episode_guests Table
CREATE TABLE episode_guests (
id UUID PRIMARY KEY,
episode_id UUID REFERENCES episodes(id),
user_id UUID REFERENCES auth.users(id), -- NULL for email-only guests
email TEXT NOT NULL,
name TEXT NOT NULL,
status guest_status DEFAULT 'invited',
access_token TEXT UNIQUE, -- 32-char for magic link
invited_at TIMESTAMPTZ DEFAULT NOW(),
last_accessed_at TIMESTAMPTZ,
notes TEXT -- Custom field responses as JSON
);Related Documentation
- Bookings API Reference - Endpoint details
- Google Calendar Integration - OAuth and event creation
- Resend Email Service - Email templates and delivery
- Automation Architecture - Event-driven automation
- Availability API - Time slot calculation
- Timezone Management - How
bookings.timezonepropagates through emails, calendar events, and magic tags