Skip to content

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:

  1. Bookings API (src/api/routes/bookings/) - Handles CRUD and lifecycle
  2. Email Service (src/lib/email/) - Sends transactional emails via Resend
  3. 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 in location + description (#200)
  4. 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 NULL is not a mode test. The FK is ON DELETE SET NULL, so deleting an episode nulls it on an EPISODE-mode booking too. Read booking_sessions.creates_episode (or booking_links.creates_episode at offer time). The client helper is isGeneralBooking() in src/lib/components/bookings/board-utils.ts, which treats a missing value as episode mode.

What general mode skips:

StepEpisode modeGeneral mode
episodes INSERT at submityes, pending_confirmationnever
episode_guests INSERT per attendeeyesnever (attendees live on booking_attendees with episode_guest_id NULL)
podcast_guests identity + guest networkvia the episode_guests triggernever
bookings.episode_id / guest_idsetNULL for life
Managed-episode slot at confirmconsumed by the pending_confirmation → draft flipstructurally impossible — no episode to flip, so PT429 cannot fire
Guest portal + verification emailsyesno portal, no magic link, no verification email
Show-notes template at confirmappliedskipped (guarded on result.episode_id)
AI guest researchavailablerejected at the API (400)
Month-capacity gates (availability + submission)appliedskipped
Collaboration analytics funnelcountedexcluded

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/bookings

Source: src/api/routes/bookings/index.ts (Line 148-458)

Validation Steps

  1. Booking Link Validation (Line 161-186)

    • Fetch booking link with podcast info
    • Verify link is active
    • Load availability rules
  2. 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)
  3. Conflict Detection (Line 222-232)

    • Query existing pending/confirmed bookings
    • Reject if time slot overlaps

Database Operations

OrderTableOperationStatusGeneral mode
1bookingsINSERTpendingsame, with episode_id NULL
2booking_linksUPDATEtotal_bookings++same
3episodesINSERTpending_confirmationskipped
4episode_guestsINSERTinvitedskipped
5bookingsUPDATELink episode_id, guest_idskipped

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:

typescript
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

FieldContent
SubjectYour booking request for {Podcast} has been received
HeaderPurple gradient with clock icon
BodyBooking details + "Awaiting host confirmation" notice

2. Host Notification Email (to Podcast Owner)

Template: src/lib/email/templates/host-notification.ts

FieldContent
SubjectNew booking request: {Guest} for {Booking Type}
HeaderOrange gradient with bell icon
BodyGuest info + booking details + custom field responses + dashboard CTA

Host Lookup: The podcast owner is found via:

sql
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/confirm

Source: src/api/routes/bookings/index.ts (Line 572-854)

Authorization

  • Requires Bearer token authentication
  • User must have owner or admin role on the podcast

Google Calendar Event Creation

If the host has a connected Google Calendar (Line 632-717):

  1. Get Calendar Connection

    typescript
    const connections = await supabase
    	.from('calendar_connections')
    	.select('*')
    	.eq('user_id', hostId)
    	.eq('is_valid', true)
    	.order('created_at', { ascending: true });
  2. Refresh OAuth Token

    typescript
    const { access_token } = await refreshAccessToken(
    	connection.refresh_token,
    	GOOGLE_CLIENT_ID,
    	GOOGLE_CLIENT_SECRET
    );
  3. Create Calendar Event

    typescript
    const 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 calls createCalendarEvent(..., false) with the room in location and the description instead; in_person and "no meeting configured" create a plain event with neither.

  4. Store Calendar Data

    • meeting_urlevent.hangoutLink (Meet), or the frozen manual link
    • meeting_platform ← the effective platform, frozen on the session
    • google_event_idevent.id
    • google_calendar_id ← Calendar ID used

    The persist error is checked: a failed write marks the session failed rather than reporting success over an event the app has no id for.

Database Updates

TableField Changes
bookingsstatusconfirmed, confirmed_at, confirmed_by
booking_sessionsmeeting_url, meeting_platform, google_event_id, google_calendar_id, calendar_event_status
episodesstatusdraft
episode_guestsstatusactive

Booking Confirmed Email (to Guest)

Template: src/lib/email/templates/booking-confirmed.ts

FieldContent
SubjectConfirmed: Your {Podcast} recording session
HeaderGreen gradient with checkmark icon
BodyBooking 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):

  1. Emit Event

    typescript
    await 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
    	}
    });
  2. Schedule Time-Based Jobs

    • time.before_recording triggers (e.g., reminder 24h before)
    • time.after_recording triggers (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:

  1. Set booking statuscanceled
  2. Set episode statusdraft
  3. Set guest statusexpired
  4. Send booking-declined email
  5. Emit booking.declined automation event

Host Cancels Confirmed Booking

Endpoint: POST /api/bookings/:id/cancel (Line 863-1026)

Actions:

  1. Delete Google Calendar event (with sendUpdates: 'all')
  2. Set booking statuscanceled
  3. Set episode statusdraft
  4. Set guest statusexpired
  5. Emit booking.canceled automation event
  6. Cancel pending automation scheduled jobs

Error Handling

Non-Blocking Failures

These errors are logged but don't fail the booking operation:

OperationFailure Behavior
Calendar event creationBooking confirmed without meeting URL
Email sendingLogged, user can retry manually
Automation event emissionLogged, automation may not trigger

Validation Errors

CodeScenario
400Invalid input, missing required fields, time validation failed
401No authentication token
403User not authorized (not owner/admin)
404Booking link not found or inactive
409Time slot conflict

Data Model Reference

bookings Table

sql
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

sql
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
);

Internal documentation - Not for public distribution