Skip to content

Booking Sessions API & Confirm Path

Calendar identifiers (google_event_id, google_calendar_id, meeting_url) live on booking_sessions, not bookings. Per invariants CAL1a/CAL1b/CAL1c, the destructive booking RPCs never touch session calendar fields: the ROUTE is the only writer, and only after the corresponding Google API call returns success. The booking-sessions API is the host's recovery surface when that Google I/O failed.

Host-created sessions (manual episodes)

Since migration 20260807150000 (#200 follow-up, docs/planning/plans/2026-08-06-manual-episode-calendar-invites.md), booking_sessions.booking_link_id is nullable: NULL means host-created, the session a manually created episode (/p/{slug}/e/new) gets so the entire calendar machinery on this page works for it unchanged. Consequences threaded through everything below:

  • Zero bookings, permanently. Every booking-derived signal is empty, so the attendee source is the participants union (next paragraph), the teardown RPC never collapses a host-created session, and the Guests card renders it as a recording rather than a bookable slot.
  • The attendee source is list_session_event_attendees: active booking attendees on confirmed bookings, UNION invited/active episode_guests, UNION active team roster rows (episode_people, email via auth.users), deduped by lowercased email. Both event creators (confirm and create-calendar-event) and the sync helper (src/api/utils/session-attendees.ts) read it, so they cannot drift. This also means booking-created events now carry hand-added guests and team members, which is the #293 §6.4 contract landing early.
  • Owner resolution (resolve_session_calendar_owner) reads podcast_id from the session via a LEFT JOIN; tier 1 (the link's intended owner) simply does not apply without a link. The Meet gate for a link-less session uses podcast_has_calendar_owner (tiers 2/3 at podcast level).
  • Google is the notifier. Host-created event inserts pass sendUpdates: 'all' so Google emails the invitation (there is no booking confirmation email to carry the details), and the app's meeting-link-added recovery email is suppressed for them. Meeting-CHANGE emails go to the episode's invited/active guests (loadMeetingChangeRecipients) instead of confirmed booking attendees.
  • Lifecycle: /e/new inserts the session (platform from the form, one-hour block) and can trigger the create endpoint when the host opts in; the episode's updateScheduling action moves the session and PATCHes the event times (sendUpdates: 'all'); episode deletion deletes the Google event and the session row (the FK alone would orphan both).
  • One host-created session per episode, by partial unique index (idx_booking_sessions_one_manual_per_episode).

Booking-sessions endpoints

Both routes live in src/api/routes/booking-sessions/index.ts, mounted at /api/booking-sessions, with the chain requireAuth() then requirePodcastRoleByResolver('admin', resolveSessionPodcastId) (the resolver looks up booking_sessions.podcast_id; a missing session 404s).

POST /api/booking-sessions/:id/create-calendar-event

Used when a session has confirmed bookings but no calendar event yet: owner resolution failed at first confirm, or a previous create failed and the host has since reconnected a calendar.

Reachable from two host surfaces: the episode's guests card, and (since #295 item 1) the bookings hub detail sheet via retryCalendarEvent() in src/lib/components/bookings/booking-api.ts. The second entry point is not a convenience — the guests card is reachable only through an episode, which a general booking never has, so a failed invite there had no recovery path at all. The same was true of any booking whose episode had been deleted.

  1. Load session context (times, link name/timezone, podcast title; for a host-created session the episode's title and the podcast's default_timezone stand in for the missing link fields).

  2. Atomic claim: the claim_calendar_event_creation RPC transitions calendar_event_status from none/failed to creating and returns the participant set from list_session_event_attendees. Refusals map to: 403 forbidden, 404 session_not_found, 422 no_valid_calendar_connection (UI shows the "Connect a calendar" state), 409 no_participants (nobody to put on an event; no state transition; the pre-migration no_confirmed_attendees code maps to the same 409 across the deploy boundary), 409 invalid_state:* (already creating or created; idempotent callers ignore it).

  3. Resolve the effective meeting (#200): the session's own frozen fields if it has them, otherwise the booking link's booking_link_meeting_defaults row. Then run Google events.insert with email/popup reminders and the confirmed attendees. Only google_meet asks Google for a conference (createCalendarEventWithMeet); every other platform creates a plain event (createCalendarEvent(..., createMeetLink = false)) carrying the room in location and the description, because the Calendar API accepts conferenceData.createRequest for Meet and add-on solutions only.

    On success the route persists google_event_id/google_calendar_id, flips status to created, and stamps meeting_platform/meeting_url when the session had none — for Meet the room only exists now, and for a manual platform this endpoint can be the first place the link is frozen onto the session. The persist error is CHECKED: it used to be discarded, so a failed write left a live invite-bearing event with no id stored while the route returned success. It now throws, which flips the session to failed with event_created_but_persist_failed and logs the orphaned event id. Full idempotency (a retry converging on the same event) is issue #306.

  4. Email: the meeting-link-updated template in added mode goes to the session's confirmed attendees. This endpoint previously persisted a link and told nobody, so guests whose confirmation email had the link suppressed (no calendar owner at the time) never received it at all. Isolated: a send failure cannot undo the recovery.

  5. Reconcile: attendees who confirmed between the RPC and the insert finishing are PATCHed onto the event (merge by email, best-effort).

POST /api/booking-sessions/:id/remove-calendar-event

Used when an event remains on the host's calendar after all confirmed bookings are gone (for example a Google events.delete that failed mid-cancel).

  1. 409 no_event_to_remove when the session has no event identifiers.

  2. 409 session_has_confirmed_bookings when ANY confirmed booking exists; the host must cancel those through the normal flow first. Pending bookings are explicitly allowed (the survives-with-pending recovery case: they can later be confirmed and a fresh event created).

  3. Google events.delete (scope all); failure returns 502 and changes nothing.

  4. On delete success, the clear_session_calendar_event RPC nulls the calendar fields (keeping calendar_owner_user_id frozen). If that DB cleanup fails after the Google delete succeeded, the route returns a loud 500 event_removed_but_db_cleanup_failed so the UI never claims cleanup is done.

    Since #200 the RPC keeps a manual meeting link through teardown and clears only a Meet one: a Meet room dies with its event, a pasted Riverside room does not, and this endpoint runs on exactly the sessions a host is about to re-send an invite for.

    Since 20260807150000 the RPC also never collapses a host-created session: it holds zero bookings permanently, which was exactly the state the collapse branch keyed on, so the first invite removal used to delete the session, its platform choice, and the only route back. An empty BOOKABLE session still collapses exactly as before.

GET /api/booking-sessions/:id/meeting

Reads the session's stored and effective meeting, plus whether Google Meet may be selected. member+ rather than admin+: it is a read.

Returns meet_selectable, meet_blocked_reason and meet_blocked_message from evaluateMeetSwitch, so the override dialog can disable Meet with the right reason before the host tries it. The browser cannot work this out for itself: the answer depends on other members' calendar connections (hidden by RLS) and on the session's live bookings.

PATCH /api/booking-sessions/:id/meeting

The per-episode override. Body is { platform, url? } or { remove: true }.

This endpoint is a PURE DATA WRITE and never touches Google's conference machinery. Three consecutive review rounds each produced a P1 while it tried to provision Meet; the ruling that closed them removed the job entirely. Meet rooms are minted only where calendar events are created (booking confirm, and create-calendar-event above). Do not reintroduce provisioning here.

  1. Preconditions, all before any write, so a refusal always leaves the stored meeting intact. A google_meet target goes through evaluateMeetSwitch (src/api/utils/session-meeting.ts), which carries the full derivation. The rule, stated positively:

    ALLOW iff calendar_event_status IN ('none','failed')
          OR (calendar_event_status = 'created'
              AND no confirmed AND no pending bookings)
    REFUSE otherwise

    It keys on status alone and never on google_event_id. Phrasing the gate in terms of the event existing is what let the in-flight confirm window through in review: confirm_booking_in_session stamps 'creating' and confirms the booking BEFORE the route calls Google, so mid-confirm there is a status but no id. Three refusals, three distinct 409 messages, never shared: meet_requires_calendar_connection (a settings problem), meet_switch_in_progress (retry in a moment), and meet_switch_requires_rebooking (a scheduling problem).

    If a future state is handled badly here, re-derive the rule against that table. Do not append a clause: three separate rounds each found a different door into the same stranded state by doing exactly that.

  2. Primary write: booking_sessions.meeting_platform/meeting_url. A google_meet target stores the platform with a NULL URL, since a Meet URL only ever comes from event creation. This is the fourth sanctioned writer of meeting_url.

  3. Side effects, each isolated: PATCH the calendar event's location and description to match, attempting conference removal when switching away from Meet (documented fallback if Google rejects it, see the calendar service page); and email the meeting-link-updated template to the session's recipients (confirmed attendees on a bookable session; the episode's invited/active guests on a host-created one). Switching TO Meet sends nothing at that moment, because no room exists yet. The rebuilt description's attendee block reads list_session_event_attendees, matching the event's real attendee list.

POST /api/booking-sessions/:id/refresh-meet-link

Recovers the pre-existing invisible state where createCalendarEventWithMeet's ~4.5s poll times out, leaving calendar_event_status = 'created' with meeting_url = NULL. Reads the event and distinguishes three outcomes: recovered (a hangoutLink appeared, persisted), still_provisioning (the event carries a pending conference request), and no_conference_on_event (the event was created under a different platform, so the invite must be removed and re-sent).

What confirm actually does

POST /api/bookings/:id/confirm (src/api/routes/bookings/index.ts, admin+ via resolveBookingPodcastId) follows the standard shape: context load, one atomic RPC, then isolated side effects.

1. Context load

Booking, link, podcast, and the linked episode's recording_scheduled_at. The episodes embed must name the FK (episodes!bookings_episode_id_fkey): the bookings and episodes tables have two relationships and an unhinted embed fails with PGRST201. A defensive assert rejects confirming a booking whose episode has no recording date (structurally NOT NULL since the slot migration).

2. The RPC: confirm_booking_in_session

One transaction covering the state transition (pending to confirmed, with confirmed_by audit; the route passes p_acting_user_id because requireAuth() puts a service-role client in context so auth.uid() is NULL inside the RPC, and the RPC coalesces auth.uid() first so a direct PostgREST caller cannot spoof it), the calendar-creation claim (deciding should_create_calendar_event and freezing the owner), and the linked episode's flip to draft.

For a general booking (#295 item 1) v_booking.episode_id is NULL, so the episode flip matches zero rows and no-ops. That is the whole reason a general booking cannot consume a managed-episode slot: the consume point IS this flip, so month_full is structurally unreachable rather than merely skipped. The attendee JSON the RPC returns is built with a LEFT JOIN to episode_guests, so general attendees (whose episode_guest_id is NULL) still come back with their names and emails and the confirmation emails still send — an inner join would silently return an empty array.

That episode flip is where the GATE-4 slot wall fires: the recording-month reconciler's consume can RAISE month_full (hard on every tier). The route maps it to a 429 with code month_full and the metered month key, which the host UI renders as an upsell ("this month is full: add a pack, upgrade, or reschedule"). The guest is never declined; the booking stays pending while the host resolves capacity. That is the designed race path for a pending booking that raced into a month that filled after submission.

3. Side effects (each in its own try/catch)

In order, none of which can fail the response:

  • 3a-pre. Effective meeting (#200). Resolve the session's frozen fields, or the booking link's default. A manual platform is stamped on the session even when no calendar owner resolves — that is the gap this feature closes, since a podcast with no connected calendar previously had no meeting link anywhere. Google Meet is NOT stamped here: its URL only exists once the event is created, and the calendar branch writes both together.
  • 3a. Calendar. Four branches on the RPC's claim result: creator path (refresh the frozen owner's OAuth token, events.insert — with a Meet conference only when the effective platform is google_meet, otherwise a plain event carrying the room in location and the description — persist identifiers and check the persist error, then the CAL5 reconcile PATCH for attendees that confirmed mid-create); non-creator with the event already created (PATCH this booking's attendees on); non-creator while the creator is still in flight (poll the session up to 5 times, then PATCH, else leave it to the creator's reconcile); and no-owner (log; the session card offers "Send meeting invite" once a calendar is connected). Any exit from the creator branch that does not reach created marks the session failed so the recovery button appears; a session stuck in creating forever would otherwise be unrecoverable.
  • 3b. Show notes. First confirmation on the session applies the resolved show-notes template with guest sections; later confirmations add sections for the new guests only.
  • 3c. Automation. Emit booking.confirmed and schedule the time-based jobs for the slot (scheduleTimeBasedJobsForBooking).
  • 3c2. AI guest research. Build the research plan; when the link enables it, a fail-closed checkFeature('ai_prep_questions') gates enqueueing a guest_research job. Credits are not pre-checked; the worker's reserve refuses over-cap and the job lands skipped with a notification. Duplicate confirms no-op on the one-active-job-per-booking index. See Guest Research.
  • 3d. Emails. One confirmation email per attendee, each with its own guest portal magic link (/guest/e/:episodeId/verify?guest=:guestId). The session's meeting fields are re-read first, so the email carries the freshly minted Meet room, and the join CTA is labelled from the platform catalog. Metered on email_sends_per_month inside sendEmail; a meter-resolution failure skips the emails (fail closed) while the confirm still succeeds.

Response

{ booking_id, session_id, episode_id, calendar_event_status, no_calendar_owner }, which the hub uses to refresh the board.

The sibling mutations, briefly

Decline (decline_booking_in_session), cancel (cancel_confirmed_booking), delete (delete_booking_in_session, pending or canceled only), and reschedule (reschedule_booking_solo) follow the same context/RPC/side-effects shape, with calendar removal handled by the route (CAL1a) and courtesy emails metered the same way. Reschedule specifics are covered in Hub Architecture.

Internal documentation - Not for public distribution