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'insrc/lib/constants/meeting-platforms.ts. It is the only one that callscreateCalendarEventWithMeet. - Every other platform creates the event with
createMeetLink = falseand carries its room inlocation(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 bybuildCalendarEventCopy. - 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
| Component | File Location | Purpose |
|---|---|---|
| OAuth Auth | src/lib/google-calendar/auth.ts | Token exchange, refresh, revocation |
| Calendar API | src/lib/google-calendar/calendar.ts | List calendars, busy times, events |
| OAuth Routes | src/api/routes/auth/google.ts | Connect/callback endpoints |
| Calendar API | src/api/routes/calendars/index.ts | Calendar management |
| Availability API | src/api/routes/availability/index.ts | Slot availability |
| Booking API | src/api/routes/bookings/index.ts | Booking CRUD + calendar events |
| Settings UI | src/routes/(app)/p/[slug]/settings/calendars/ | Calendar configuration |
OAuth Implementation
Required Scopes
// 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
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
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_connectionstable - Redirects to returnUrl with
?connected=google
Token Management
// 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 callsKey Design Decisions:
- Only
refresh_tokenstored in DB (security best practice) - Access tokens obtained on-demand with ~1 hour validity
prompt: 'consent'forces refresh token on re-authaccess_type: 'offline'ensures refresh token is returned
Environment Variables
# Required in .env and Cloudflare secrets
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxx
PUBLIC_APP_URL=https://app.podcasterplus.comDatabase Schema
calendar_connections Table
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
| Column | Type | Purpose |
|---|---|---|
calendars_for_availability | JSONB | Array of calendar IDs checked during availability calculations |
calendar_for_events | TEXT | Single calendar ID where confirmed bookings create events |
is_valid | BOOLEAN | Set to false when token refresh fails |
error_message | TEXT | Human-readable error when is_valid is false |
Indexes
CREATE INDEX idx_calendar_connections_user ON calendar_connections(user_id);
CREATE INDEX idx_calendar_connections_valid ON calendar_connections(is_valid);RLS Policies
-- 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
| Function | Purpose |
|---|---|
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
| Function | Purpose |
|---|---|
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 site | sendUpdates | Why |
|---|---|---|
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) | omitted | Deliberate: 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
// 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:
| Parameter | Type | Required | Description |
|---|---|---|---|
bookingLinkId | UUID | Yes | Booking link configuration |
date | YYYY-MM-DD | Yes | Date to check |
timezone | String | No | Guest's timezone (default: UTC) |
Response:
{
"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:
- Minimum notice: Skip slots within X hours of now (default: 24)
- Maximum advance: Skip slots beyond X days (default: 60)
- Day of week rules: Only show slots on enabled days
- Time windows: Only show slots within configured hours
- Busy periods: Remove slots overlapping Google Calendar events (with buffers)
- 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:
- Fetch calendar connections ordered by
created_at(first connection used for events) - Select connection with
calendar_for_eventsconfigured - Refresh OAuth access token
- 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
- Summary:
- Store
google_event_id,google_calendar_id, andmeeting_urlon 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.
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:
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:
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:
// 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=SaturdayImplementation Details:
- Uses
Intl.DateTimeFormatfor 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:
- Booking links store host's timezone
- Availability rules stored in host's timezone
- Slots generated in host's timezone
- Converted to UTC ISO strings for storage and API responses
- 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
- Work + Personal: Check both for conflicts, create events on personal
- Multiple Hosts: Producer connects their calendar to check team availability
- Shared Calendars: Include team calendars in availability checking
Error Handling
Token Refresh Failures
When token refresh fails (revoked, expired refresh token):
- Mark connection as
is_valid = false - Store error message in
error_messagecolumn - Update
last_validated_at - Show warning in UI to reconnect
// 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
| Status | Error | Solution |
|---|---|---|
| 401 | invalid_grant | Refresh token revoked - reconnect |
| 403 | accessNotConfigured | Calendar API not enabled in GCP |
| 404 | notFound | Calendar deleted or access removed |
| 429 | rateLimitExceeded | Back off and retry |
Security Considerations
Token Storage
- Only
refresh_tokenstored (not access tokens) - Encrypted at rest via Supabase
- Never exposed in API responses
- Revoked on disconnect
CSRF Protection
OAuth state parameter contains:
{
"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
| File | Coverage |
|---|---|
src/lib/google-calendar/__tests__/auth.test.ts | OAuth scopes, URL generation, token operations |
src/lib/google-calendar/__tests__/calendar.test.ts | Calendar listing, busy times, event CRUD |
src/api/routes/calendars/__tests__/index.test.ts | Connection management, preferences, token refresh |
src/api/routes/availability/__tests__/index.test.ts | Slot generation, conflict detection, timezone handling |
Test Patterns
// 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') })
);
});
});Related Documentation
- Calendars API Reference - API endpoint details
- Availability API Reference - Slot calculation
- Google OAuth API Reference - OAuth flow
- Bookings API Reference - Event creation on confirmation
- Architecture Overview - System architecture