Skip to content

Google Calendar Integration

show.fm integrates with Google Calendar for availability checking and automatic event creation when bookings are confirmed.

Architecture Overview

OAuth Connection Flow

Availability Checking Flow

Event Creation Flow

Recording platforms other than Meet (#200)

The Calendar API only accepts conferenceData.createRequest for Google Meet and add-on solutions, so a Riverside or Zoom room cannot become a first-class conference entry on the event. The standard approach, and ours, is location plus a description line:

  • Google Meet is the only platform with provisioning: 'calendar' in src/lib/constants/meeting-platforms.ts. It is the only one that calls createCalendarEventWithMeet.
  • Every other platform creates the event with createMeetLink = false and carries its room in location (rendered as a tappable link by Google and Apple clients, and included in Google's own invite email) plus a "Join the recording: {url}" description line, built by buildCalendarEventCopy.
  • In person creates an event with no conference and no location URL, and a description line saying the recording (or, on a general booking, the meeting) takes place in person. The noun comes from bookingVocabulary, never from a hardcoded string.
  • No meeting configured creates an event for scheduling only.

Conference removal on PATCH is best-effort. When a session switches away from Meet, the app PATCHes conferenceData: null with conferenceDataVersion=1. Google documents version 1 for creating and copying conferences and says nothing about removing one, so a rejection is expected rather than exceptional: the call is retried without the conference field, which still lands the new location and description. The accepted outcome in that case is a stale Meet entry on the event while every surface the app controls (emails, the portal, magic tags, the host UI) carries the new link — those always render the stored session value, never the event's conference.

Core Components

ComponentFile LocationPurpose
OAuth Authsrc/lib/google-calendar/auth.tsToken exchange, refresh, revocation
Calendar APIsrc/lib/google-calendar/calendar.tsList calendars, busy times, events
OAuth Routessrc/api/routes/auth/google.tsConnect/callback endpoints
Calendar APIsrc/api/routes/calendars/index.tsCalendar management
Availability APIsrc/api/routes/availability/index.tsSlot availability
Booking APIsrc/api/routes/bookings/index.tsBooking CRUD + calendar events
Settings UIsrc/routes/(app)/p/[slug]/settings/calendars/Calendar configuration

OAuth Implementation

Required Scopes

typescript
// src/lib/google-calendar/auth.ts
export const GOOGLE_CALENDAR_SCOPES = [
	'https://www.googleapis.com/auth/calendar.readonly', // Read calendars & events
	'https://www.googleapis.com/auth/calendar.events', // Create/modify events
	'https://www.googleapis.com/auth/userinfo.email' // Get account email
];

OAuth Flow

  1. Initiate Connection (GET /api/auth/google/connect)

    • Requires Bearer token authentication
    • Generates CSRF state token (base64-encoded JSON with userId, returnUrl, nonce)
    • Returns { authUrl } for client redirect
  2. Handle Callback (GET /api/auth/google/callback)

    • Validates state token
    • Exchanges authorization code for tokens
    • Fetches Google user info (email, ID)
    • Lists user's calendars
    • Stores/updates connection in calendar_connections table
    • Redirects to returnUrl with ?connected=google

Token Management

typescript
// src/lib/google-calendar/auth.ts

// Access tokens are NOT stored - obtained on-demand via refresh
export async function refreshAccessToken(
	refreshToken: string,
	clientId: string,
	clientSecret: string
): Promise<{ access_token: string; expires_in: number }>;

// Only refresh_token is stored in database
// Token refresh happens automatically when making API calls

Key Design Decisions:

  • Only refresh_token stored in DB (security best practice)
  • Access tokens obtained on-demand with ~1 hour validity
  • prompt: 'consent' forces refresh token on re-auth
  • access_type: 'offline' ensures refresh token is returned

Environment Variables

bash
# Required in .env and Cloudflare secrets
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxx
PUBLIC_APP_URL=https://app.podcasterplus.com

Database Schema

calendar_connections Table

sql
CREATE TABLE calendar_connections (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES auth.users(id),
  provider calendar_provider NOT NULL,  -- 'google'
  provider_account_id TEXT NOT NULL,    -- Google account ID
  provider_account_email TEXT NOT NULL, -- Display email
  refresh_token TEXT NOT NULL,          -- Encrypted at rest
  calendars_for_availability JSONB,     -- Array of calendar IDs to check
  calendar_for_events TEXT,             -- Calendar ID for event creation
  is_valid BOOLEAN DEFAULT true,        -- False if token refresh fails
  error_message TEXT,                   -- Error details if invalid
  last_validated_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),

  UNIQUE(user_id, provider, provider_account_id)
);

CREATE TYPE calendar_provider AS ENUM ('google');

Column Details

ColumnTypePurpose
calendars_for_availabilityJSONBArray of calendar IDs checked during availability calculations
calendar_for_eventsTEXTSingle calendar ID where confirmed bookings create events
is_validBOOLEANSet to false when token refresh fails
error_messageTEXTHuman-readable error when is_valid is false

Indexes

sql
CREATE INDEX idx_calendar_connections_user ON calendar_connections(user_id);
CREATE INDEX idx_calendar_connections_valid ON calendar_connections(is_valid);

RLS Policies

sql
-- Users can only see their own calendar connections
CREATE POLICY "Users can view own calendar connections"
ON calendar_connections FOR SELECT
USING (auth.uid() = user_id);

-- Users can manage their own connections
CREATE POLICY "Users can manage own calendar connections"
ON calendar_connections FOR ALL
USING (auth.uid() = user_id);

Calendar Library Functions

src/lib/google-calendar/auth.ts

FunctionPurpose
getGoogleAuthUrl(clientId, redirectUri, state)Generate OAuth authorization URL
exchangeCodeForTokens(code, clientId, clientSecret, redirectUri)Exchange auth code for tokens
refreshAccessToken(refreshToken, clientId, clientSecret)Refresh expired access token
getGoogleUserInfo(accessToken)Fetch authenticated user's profile
revokeToken(token)Revoke tokens for safe disconnection

src/lib/google-calendar/calendar.ts

FunctionPurpose
listCalendars(accessToken)List all accessible calendars (with pagination)
getBusyTimes(accessToken, calendarIds, startDate, endDate, timeZone)Fetch busy periods via FreeBusy API
createCalendarEvent(egress, accessToken, calendarId, event, createMeetLink, sendUpdates?)Create event with optional Google Meet
createCalendarEventWithMeet(egress, accessToken, calendarId, event, sendUpdates?)Create + poll ~4.5s for the hangoutLink
updateCalendarEvent(egress, accessToken, calendarId, eventId, event, options?)Update existing event (PATCH); options.removeConference adds conferenceDataVersion=1, options.sendUpdates controls notification
getCalendarEvent(accessToken, calendarId, eventId)Read a single event (not egress-gated)
deleteCalendarEvent(accessToken, calendarId, eventId, sendUpdates)Cancel event with notification control

sendUpdates is not optional in practice (#295 item 3, #184)

Omitting sendUpdates means Google emails nobody. The attendee is written onto the event and never hears about it, which is not the same as "the app will tell them": the app's confirmation email is prose, while Google's invitation carries the .ics that creates the entry in Outlook or Apple Calendar. A guest without a Google account got nothing at all.

That is exactly what shipped. Both creation calls in the booking confirm route passed fewer arguments than the parameter's position, so the parameter was never set and Google's own no-notification default applied. Guests were attendees on an event they had never been told about.

Every call site now states its choice:

Call sitesendUpdatesWhy
Booking confirm, event creation (api/routes/bookings)'all'The invitation IS how the session reaches the guest's calendar
POST /api/booking-sessions/:id/create-calendar-event'all'Same, for host-created sessions and for recovery retries
addAttendeesToEvent / reconcile PATCHes'all'A newly added attendee has no other route to the invitation
removeAttendeesFromEvent, guest removal PATCH'all'Withdraws the phantom entry from the removed person's calendar
Reschedule PATCH (booking and manual-episode paths)'all'Only the updated .ics moves a non-Google calendar entry
deleteCalendarEvent'all'Default on the helper, long-standing
Meeting-platform PATCH (booking-sessions details update)omittedDeliberate: both callers follow it with sendMeetingLinkChangedEmails, the branded email written for exactly this change

When adding a call site, decide explicitly. Google has no "notify only the new attendee" mode, so 'all' also mails the existing attendees; that matches what Google Calendar's own UI does.

Type Definitions

typescript
// Calendar from list response
interface GoogleCalendar {
	id: string;
	summary: string;
	description?: string;
	primary: boolean;
	accessRole: 'owner' | 'writer' | 'reader' | 'freeBusyReader';
	backgroundColor?: string;
	foregroundColor?: string;
	timeZone?: string;
}

// Busy period from FreeBusy response
interface BusyPeriod {
	start: string; // ISO datetime
	end: string; // ISO datetime
}

// Event for creation/update
interface CalendarEvent {
	id?: string;
	summary: string;
	description?: string;
	start: { dateTime: string; timeZone: string };
	end: { dateTime: string; timeZone: string };
	attendees?: Array<{ email: string; displayName?: string }>;
	conferenceData?: {
		createRequest: { requestId: string; conferenceSolutionKey: { type: 'hangoutsMeet' } };
	};
	reminders?: {
		useDefault: boolean;
		overrides?: Array<{ method: 'email' | 'popup'; minutes: number }>;
	};
}

// Event returned after creation
interface CreatedEvent extends CalendarEvent {
	id: string;
	htmlLink: string;
	hangoutLink?: string; // Google Meet URL if created
}

Availability API

Get Available Slots (GET /api/availability)

Returns available time slots for a specific date.

Query Parameters:

ParameterTypeRequiredDescription
bookingLinkIdUUIDYesBooking link configuration
dateYYYY-MM-DDYesDate to check
timezoneStringNoGuest's timezone (default: UTC)

Response:

json
{
	"success": true,
	"data": {
		"date": "2025-01-20",
		"timezone": "America/New_York",
		"bookingLinkTimezone": "America/Los_Angeles",
		"bookingLink": {
			"id": "uuid",
			"name": "Guest Interview",
			"duration_minutes": 60,
			"timezone": "America/Los_Angeles"
		},
		"podcast": {
			"id": "uuid",
			"slug": "my-podcast",
			"title": "My Podcast"
		},
		"slots": [
			{
				"start": "2025-01-20T14:00:00.000Z",
				"end": "2025-01-20T15:00:00.000Z",
				"available": true
			}
		]
	}
}

Availability Calculation Logic

Filters Applied:

  1. Minimum notice: Skip slots within X hours of now (default: 24)
  2. Maximum advance: Skip slots beyond X days (default: 60)
  3. Day of week rules: Only show slots on enabled days
  4. Time windows: Only show slots within configured hours
  5. Busy periods: Remove slots overlapping Google Calendar events (with buffers)
  6. Existing bookings: Remove slots with pending/confirmed bookings (with buffers)

Booking Event Creation

Event Creation Flow

When a host confirms a booking via POST /api/bookings/:id/confirm:

  1. Fetch calendar connections ordered by created_at (first connection used for events)
  2. Select connection with calendar_for_events configured
  3. Refresh OAuth access token
  4. Create Google Calendar event with:
    • Summary: "{Podcast Title}: Recording with {Guest Name}"
    • Description: Booking link name + guest contact info
    • Attendees: Guest email with responseStatus: 'needsAction' (sends Google invite)
    • Reminders: 24-hour email + 30-minute popup (not default)
    • Google Meet: Auto-generated via conferenceData.createRequest
  5. Store google_event_id, google_calendar_id, and meeting_url on booking record

Event Data Structure:

dateTime is always a UTC ISO string (unambiguous absolute instant). The timeZone field is metadata for Google's UI rendering — it tells Google which IANA zone to use when displaying the event in a recipient's calendar. It does not change the absolute time. show.fm passes booking.timezone here so the event renders naturally in the guest's calendar. See Timezone Management for the full picture.

typescript
interface CalendarEvent {
	summary: string; // "{Podcast Title}: Recording with {Guest Name}"
	description: string;
	start: {
		dateTime: string; // ISO 8601 UTC (unambiguous)
		timeZone: string; // IANA zone for display rendering, from booking.timezone
	};
	end: {
		dateTime: string;
		timeZone: string;
	};
	attendees?: Array<{
		email: string;
		displayName?: string;
		responseStatus?: 'needsAction' | 'accepted' | 'declined' | 'tentative';
	}>;
	conferenceData?: {
		createRequest: {
			requestId: string; // crypto.randomUUID()
			conferenceSolutionKey: { type: 'hangoutsMeet' };
		};
	};
	reminders?: {
		useDefault: boolean; // false - we specify our own
		overrides?: Array<{ method: 'email' | 'popup'; minutes: number }>;
		// Default: [{ method: 'email', minutes: 1440 }, { method: 'popup', minutes: 30 }]
	};
}

Google Meet Integration

Meet links are automatically created when confirming bookings:

typescript
const event = await createCalendarEvent(
	accessToken,
	calendarId,
	{
		// ... other event data
		conferenceData: {
			createRequest: {
				requestId: crypto.randomUUID(),
				conferenceSolutionKey: { type: 'hangoutsMeet' }
			}
		}
	},
	true
); // createMeetLink = true

// Store the Meet URL in booking record
await supabase
	.from('bookings')
	.update({
		meeting_url: event.hangoutLink,
		google_event_id: event.id,
		google_calendar_id: calendarId
	})
	.eq('id', bookingId);

Event Cancellation

When a booking is canceled:

typescript
await deleteCalendarEvent(
	accessToken,
	booking.google_calendar_id,
	booking.google_event_id,
	'all' // sendUpdates: notify all attendees
);

Timezone Handling

Challenge

Users across different timezones booking slots requires careful handling to avoid DST issues and date boundary crossings.

Solution

Custom timezone utilities in src/api/routes/availability/index.ts:

typescript
// Create a Date representing a specific time in a timezone
function createDateInTimezone(
	dateStr: string, // "2025-01-15"
	timeStr: string, // "09:00"
	timezone: string // "America/New_York"
): Date;

// Get day of week in a specific timezone
function getDayOfWeekInTimezone(
	dateStr: string, // "2025-01-15"
	timezone: string // "America/New_York"
): number; // 0=Sunday through 6=Saturday

Implementation Details:

  • Uses Intl.DateTimeFormat for DST-aware offset calculations
  • Creates reference date at noon UTC to avoid edge cases
  • Handles day boundary crossings (Jan 1 in Tokyo vs Dec 31 in NY)

Key Rules:

  1. Booking links store host's timezone
  2. Availability rules stored in host's timezone
  3. Slots generated in host's timezone
  4. Converted to UTC ISO strings for storage and API responses
  5. Clients convert to guest's timezone for display

Multi-Calendar Support

Feature

Users can connect multiple Google accounts:

  • All connections checked for availability conflicts
  • Only first connection (by created_at) receives booking events
  • Each connection can have multiple calendars selected

Use Cases

  1. Work + Personal: Check both for conflicts, create events on personal
  2. Multiple Hosts: Producer connects their calendar to check team availability
  3. Shared Calendars: Include team calendars in availability checking

Error Handling

Token Refresh Failures

When token refresh fails (revoked, expired refresh token):

  1. Mark connection as is_valid = false
  2. Store error message in error_message column
  3. Update last_validated_at
  4. Show warning in UI to reconnect
typescript
// src/api/routes/calendars/index.ts
async function getAccessToken(connection, supabase, clientId, clientSecret) {
	try {
		const { access_token } = await refreshAccessToken(
			connection.refresh_token,
			clientId,
			clientSecret
		);
		return access_token;
	} catch (error) {
		await supabase
			.from('calendar_connections')
			.update({
				is_valid: false,
				error_message: 'Failed to refresh access token. Please reconnect.'
			})
			.eq('id', connection.id);
		throw new Error('Calendar connection is no longer valid');
	}
}

API Error Responses

StatusErrorSolution
401invalid_grantRefresh token revoked - reconnect
403accessNotConfiguredCalendar API not enabled in GCP
404notFoundCalendar deleted or access removed
429rateLimitExceededBack off and retry

Security Considerations

Token Storage

  • Only refresh_token stored (not access tokens)
  • Encrypted at rest via Supabase
  • Never exposed in API responses
  • Revoked on disconnect

CSRF Protection

OAuth state parameter contains:

json
{
	"userId": "uuid",
	"returnUrl": "/p/slug/settings/calendars",
	"nonce": "random-uuid"
}

RLS Enforcement

All calendar operations enforce:

  • User can only access own connections
  • Team members cannot see others' calendar tokens
  • Calendar preferences visible only to owner

Test Coverage

Unit Tests

FileCoverage
src/lib/google-calendar/__tests__/auth.test.tsOAuth scopes, URL generation, token operations
src/lib/google-calendar/__tests__/calendar.test.tsCalendar listing, busy times, event CRUD
src/api/routes/calendars/__tests__/index.test.tsConnection management, preferences, token refresh
src/api/routes/availability/__tests__/index.test.tsSlot generation, conflict detection, timezone handling

Test Patterns

typescript
// Example: Testing availability with mock calendar
describe('GET /api/availability', () => {
	it('excludes busy times from available slots', async () => {
		// Mock Google Calendar FreeBusy response
		vi.spyOn(global, 'fetch').mockResolvedValueOnce({
			ok: true,
			json: () =>
				Promise.resolve({
					calendars: {
						primary: {
							busy: [{ start: '2025-01-15T14:00:00Z', end: '2025-01-15T15:00:00Z' }]
						}
					}
				})
		});

		const response = await app.request('/api/availability?...');
		const data = await response.json();

		// Verify 14:00-15:00 slot is excluded
		expect(data.data.slots).not.toContainEqual(
			expect.objectContaining({ start: expect.stringContaining('14:00:00') })
		);
	});
});

Internal documentation - Not for public distribution