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/activeepisode_guests, UNION active team roster rows (episode_people, email viaauth.users), deduped by lowercased email. Both event creators (confirm andcreate-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) readspodcast_idfrom 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 usespodcast_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-addedrecovery email is suppressed for them. Meeting-CHANGE emails go to the episode's invited/active guests (loadMeetingChangeRecipients) instead of confirmed booking attendees. - Lifecycle:
/e/newinserts the session (platform from the form, one-hour block) and can trigger the create endpoint when the host opts in; the episode'supdateSchedulingaction 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.
Load session context (times, link name/timezone, podcast title; for a host-created session the episode's title and the podcast's
default_timezonestand in for the missing link fields).Atomic claim: the
claim_calendar_event_creationRPC transitionscalendar_event_statusfromnone/failedtocreatingand returns the participant set fromlist_session_event_attendees. Refusals map to: 403forbidden, 404session_not_found, 422no_valid_calendar_connection(UI shows the "Connect a calendar" state), 409no_participants(nobody to put on an event; no state transition; the pre-migrationno_confirmed_attendeescode maps to the same 409 across the deploy boundary), 409invalid_state:*(already creating or created; idempotent callers ignore it).Resolve the effective meeting (#200): the session's own frozen fields if it has them, otherwise the booking link's
booking_link_meeting_defaultsrow. Then run Googleevents.insertwith email/popup reminders and the confirmed attendees. Onlygoogle_meetasks Google for a conference (createCalendarEventWithMeet); every other platform creates a plain event (createCalendarEvent(..., createMeetLink = false)) carrying the room inlocationand the description, because the Calendar API acceptsconferenceData.createRequestfor Meet and add-on solutions only.On success the route persists
google_event_id/google_calendar_id, flips status tocreated, and stampsmeeting_platform/meeting_urlwhen 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 tofailedwithevent_created_but_persist_failedand logs the orphaned event id. Full idempotency (a retry converging on the same event) is issue #306.Email: the meeting-link-updated template in
addedmode 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.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).
409
no_event_to_removewhen the session has no event identifiers.409
session_has_confirmed_bookingswhen 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).Google
events.delete(scopeall); failure returns 502 and changes nothing.On delete success, the
clear_session_calendar_eventRPC nulls the calendar fields (keepingcalendar_owner_user_idfrozen). If that DB cleanup fails after the Google delete succeeded, the route returns a loud 500event_removed_but_db_cleanup_failedso 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
20260807150000the 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.
Preconditions, all before any write, so a refusal always leaves the stored meeting intact. A
google_meettarget goes throughevaluateMeetSwitch(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 otherwiseIt 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_sessionstamps'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), andmeet_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.
Primary write:
booking_sessions.meeting_platform/meeting_url. Agoogle_meettarget stores the platform with a NULL URL, since a Meet URL only ever comes from event creation. This is the fourth sanctioned writer ofmeeting_url.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 isgoogle_meet, otherwise a plain event carrying the room inlocationand 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 reachcreatedmarks the sessionfailedso the recovery button appears; a session stuck increatingforever 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.confirmedand 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 aguest_researchjob. Credits are not pre-checked; the worker's reserve refuses over-cap and the job landsskippedwith 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 onemail_sends_per_monthinsidesendEmail; 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.