Skip to main content

Calendar OAuth (Dual-Mode)

Client Portal's scheduling system supports two modes of calendar integration: client-owned (for external client booking links) and user-owned (for internal 1HR sales booking links). Both modes share the same edge functions, booking page, and OAuth flow, but route data to different tables.

OAuth Flow

Dual-Mode Architecture

The scheduling system detects which mode to use based on the booking_links record:

const isInternalLink = !bookingLink?.client_id && !!bookingLink?.user_id;

Mode Comparison

AspectClient ModeInternal (User) Mode
Ownerbooking_links.client_idbooking_links.user_id
Calendar tablecalendar_connectionsuser_calendar_connections
Meeting tablemeetingssales_meetings
Contact tablecontactssales_prospects
Availability sourceclients.availability_settingsuser_profiles.availability_settings
OAuth state format{clientId}:{provider}user:{userId}:{provider}
Post-OAuth redirect/workspace/{clientId}/client-info/admin/sales/scheduling
Notification recipientClient's primary contact emailUser's auth email

OAuth State Format

The OAuth state parameter encodes both the owner identity and the calendar provider:

Client mode:    "abc123-def456:google"     -> clientId = "abc123-def456", provider = "google"
Internal mode: "user:xyz789-abc123:google" -> userId = "xyz789-abc123", provider = "google"

The callback edge function parses this to determine the correct table and redirect:

// Parsing logic in google-calendar-callback
const parts = state.split(":");
if (parts[0] === "user") {
// Internal mode: user:{userId}:{provider}
const userId = parts[1];
const provider = parts[2];
// Upsert to user_calendar_connections
// Redirect to /admin/sales/scheduling
} else {
// Client mode: {clientId}:{provider}
const clientId = parts[0];
const provider = parts[1];
// Upsert to calendar_connections
// Redirect to /workspace/{clientId}/client-info
}

Database Tables

calendar_connections (Client-Owned)

ColumnTypeDescription
idUUIDPrimary key
client_idUUIDFK to clients
providerTEXTgoogle or outlook
access_tokenTEXTOAuth access token
refresh_tokenTEXTOAuth refresh token
token_expires_atTIMESTAMPTZToken expiry
calendar_idTEXTSelected calendar ID
connected_emailTEXTCalendar account email
is_activeBOOLEANConnection active

Unique constraint: (client_id, provider)

user_calendar_connections (User-Owned)

Same schema as calendar_connections, but keyed by user_id instead of client_id:

ColumnTypeDescription
idUUIDPrimary key
user_idUUIDFK to auth.users
providerTEXTgoogle or outlook
access_tokenTEXTOAuth access token
refresh_tokenTEXTOAuth refresh token
token_expires_atTIMESTAMPTZToken expiry
calendar_idTEXTSelected calendar ID
connected_emailTEXTCalendar account email
is_activeBOOLEANConnection active

Unique constraint: (user_id, provider)

The client_id column is now nullable with a CHECK constraint:

ALTER TABLE booking_links ALTER COLUMN client_id DROP NOT NULL;
ADD CONSTRAINT booking_links_owner_check
CHECK (client_id IS NOT NULL OR user_id IS NOT NULL);

Internal booking links have client_id = NULL and user_id set.

Availability Settings (JSONB)

Both clients.availability_settings and user_profiles.availability_settings use the same format:

{
"monday": { "enabled": true, "start": "09:00", "end": "17:00" },
"tuesday": { "enabled": true, "start": "09:00", "end": "17:00" },
"wednesday": { "enabled": true, "start": "09:00", "end": "17:00" },
"thursday": { "enabled": true, "start": "09:00", "end": "17:00" },
"friday": { "enabled": true, "start": "09:00", "end": "17:00" },
"saturday": { "enabled": false, "start": "09:00", "end": "17:00" },
"sunday": { "enabled": false, "start": "09:00", "end": "17:00" }
}

Edge Functions (Dual-Mode)

All 5 booking-related edge functions detect the mode and adjust behavior:

google-calendar-callback

  • Parses state to determine client vs user mode
  • Exchanges auth code for tokens with Google OAuth2
  • Upserts tokens to calendar_connections or user_calendar_connections
  • Redirects to the appropriate UI page

outlook-calendar-callback

  • Same dual-mode logic as Google callback
  • Exchanges auth code with Microsoft identity platform
  • Handles Microsoft-specific token response format

get-calendar-availability

  • Accepts either client_id or user_id parameter
  • Reads calendar connection from the correct table
  • Fetches busy times from Google Calendar API or Microsoft Graph API
  • Reads availability settings from clients or user_profiles
  • Returns available time slots

create-calendar-event

  • Accepts meeting_id or sales_meeting_id
  • Creates event on the owner's connected calendar
  • Updates the meeting record with calendar_event_id
  • For internal mode: updates sales_meetings
  • For client mode: updates meetings

send-booking-notification

  • Accepts meeting_id or sales_meeting_id
  • For internal mode: gets owner email via auth.admin.getUserById()
  • For client mode: gets client email from clients.primary_contact_email
  • Sends confirmation email to booker with ICS attachment
  • Sends notification email to the meeting owner
  • Updates confirmation_sent_at on the meeting record

Booking Page Detection

The public booking page (/book/:slug) detects the mode and creates records in the correct tables:

// BookingPage.tsx
const isInternalLink = !bookingLink?.client_id && !!bookingLink?.user_id;

if (isInternalLink) {
// Create sales_prospect (if not exists) + sales_meeting
} else {
// Create contact (if not exists) + meeting
}

UI Components

Client Mode (Workspace)

Calendar connection and availability are managed in the client workspace at /workspace/{clientId}/client-info.

Internal Mode (Admin Sales)

Three components on the /admin/sales/scheduling page:

ComponentFilePurpose
SalesCalendarCardsrc/components/sales/SalesCalendarCard.tsxGoogle/Outlook OAuth connection
SalesAvailabilitySettingssrc/components/sales/SalesAvailabilitySettings.tsx7-day availability picker + timezone
SalesBookingLinkssrc/components/sales/SalesBookingLinks.tsxCRUD for booking links

Migrations

Migration FilePurpose
20260208120000_add_internal_booking.sqlCreates user_calendar_connections, sales_meetings, makes booking_links.client_id nullable, adds user_profiles.availability_settings and timezone
  • Resend Email -- Email notification details for booking confirmations
  • Close CRM -- Auto-creates Close leads when meetings are booked