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
| Aspect | Client Mode | Internal (User) Mode |
|---|---|---|
| Owner | booking_links.client_id | booking_links.user_id |
| Calendar table | calendar_connections | user_calendar_connections |
| Meeting table | meetings | sales_meetings |
| Contact table | contacts | sales_prospects |
| Availability source | clients.availability_settings | user_profiles.availability_settings |
| OAuth state format | {clientId}:{provider} | user:{userId}:{provider} |
| Post-OAuth redirect | /workspace/{clientId}/client-info | /admin/sales/scheduling |
| Notification recipient | Client's primary contact email | User'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)
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
client_id | UUID | FK to clients |
provider | TEXT | google or outlook |
access_token | TEXT | OAuth access token |
refresh_token | TEXT | OAuth refresh token |
token_expires_at | TIMESTAMPTZ | Token expiry |
calendar_id | TEXT | Selected calendar ID |
connected_email | TEXT | Calendar account email |
is_active | BOOLEAN | Connection 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:
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
user_id | UUID | FK to auth.users |
provider | TEXT | google or outlook |
access_token | TEXT | OAuth access token |
refresh_token | TEXT | OAuth refresh token |
token_expires_at | TIMESTAMPTZ | Token expiry |
calendar_id | TEXT | Selected calendar ID |
connected_email | TEXT | Calendar account email |
is_active | BOOLEAN | Connection active |
Unique constraint: (user_id, provider)
booking_links (Modified)
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
stateto determine client vs user mode - Exchanges auth code for tokens with Google OAuth2
- Upserts tokens to
calendar_connectionsoruser_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_idoruser_idparameter - Reads calendar connection from the correct table
- Fetches busy times from Google Calendar API or Microsoft Graph API
- Reads availability settings from
clientsoruser_profiles - Returns available time slots
create-calendar-event
- Accepts
meeting_idorsales_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_idorsales_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_aton 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:
| Component | File | Purpose |
|---|---|---|
SalesCalendarCard | src/components/sales/SalesCalendarCard.tsx | Google/Outlook OAuth connection |
SalesAvailabilitySettings | src/components/sales/SalesAvailabilitySettings.tsx | 7-day availability picker + timezone |
SalesBookingLinks | src/components/sales/SalesBookingLinks.tsx | CRUD for booking links |
Migrations
| Migration File | Purpose |
|---|---|
20260208120000_add_internal_booking.sql | Creates user_calendar_connections, sales_meetings, makes booking_links.client_id nullable, adds user_profiles.availability_settings and timezone |
Related Documentation
- Resend Email -- Email notification details for booking confirmations
- Close CRM -- Auto-creates Close leads when meetings are booked