import { decryptToken, encryptToken } from './crypto'; import type { AppSettings, Env, MeetingMode } from './types'; const GOOGLE_SCOPES = [ 'openid', 'email', 'profile', 'https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/calendar.events.freebusy', ]; interface TokenResponse { access_token: string; expires_in: number; refresh_token?: string; scope?: string; token_type: string; error?: string; error_description?: string; } interface GoogleUser { email: string; name?: string; picture?: string; } interface GoogleEventResponse { id: string; htmlLink?: string; hangoutLink?: string; conferenceData?: { entryPoints?: Array<{ entryPointType: string; uri: string }>; }; } export class GoogleCalendarError extends Error { public readonly publicMessage: string; public readonly status: number; constructor(operation: string, status: number, details: string) { super(`${operation} failed (${status})`); this.name = 'GoogleCalendarError'; this.status = status; this.publicMessage = status === 403 ? 'Calendar setup needs attention. The owner needs to enable the Google Calendar API, then reconnect Google Calendar.' : 'Calendar setup needs attention. The owner needs to reconnect Google Calendar or check the calendar settings.'; console.warn(`${operation} failed (${status}): ${details.slice(0, 1000)}`); } } async function throwGoogleCalendarError(response: Response, operation: string): Promise { const details = await response.text().catch(() => ''); throw new GoogleCalendarError(operation, response.status, details); } function redirectUri(env: Env): string { return `${env.APP_URL.replace(/\/$/, '')}/auth/google/callback`; } export async function hasGoogleConnection(env: Env): Promise { const row = await env.DB.prepare('SELECT 1 AS ok FROM oauth_tokens WHERE provider = ?1') .bind('google') .first<{ ok: number }>(); return Boolean(row?.ok); } export async function createGoogleAuthUrl(env: Env, forceConsent = false): Promise { const state = crypto.randomUUID(); const expires = Date.now() + 10 * 60_000; await env.DB.prepare('INSERT INTO oauth_states (state, expires_at) VALUES (?1, ?2)') .bind(state, expires) .run(); const connected = await hasGoogleConnection(env); const params = new URLSearchParams({ client_id: env.GOOGLE_CLIENT_ID, redirect_uri: redirectUri(env), response_type: 'code', scope: GOOGLE_SCOPES.join(' '), access_type: 'offline', include_granted_scopes: 'true', state, }); params.set('prompt', forceConsent || !connected ? 'consent' : 'select_account'); if (env.ADMIN_EMAIL) params.set('login_hint', env.ADMIN_EMAIL); return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; } async function exchangeCode(env: Env, code: string): Promise { const body = new URLSearchParams({ code, client_id: env.GOOGLE_CLIENT_ID, client_secret: env.GOOGLE_CLIENT_SECRET, redirect_uri: redirectUri(env), grant_type: 'authorization_code', }); const response = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); const data = (await response.json()) as TokenResponse; if (!response.ok || data.error) { throw new Error(data.error_description || data.error || `Google token exchange failed (${response.status})`); } return data; } async function fetchGoogleUser(accessToken: string): Promise { const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!response.ok) throw new Error('Could not read Google account identity'); return (await response.json()) as GoogleUser; } export async function completeGoogleOAuth( env: Env, state: string, code: string, ): Promise { const stateRow = await env.DB.prepare('SELECT expires_at FROM oauth_states WHERE state = ?1') .bind(state) .first<{ expires_at: number }>(); await env.DB.prepare('DELETE FROM oauth_states WHERE state = ?1').bind(state).run(); if (!stateRow || stateRow.expires_at < Date.now()) throw new Error('OAuth state expired or invalid'); const token = await exchangeCode(env, code); const user = await fetchGoogleUser(token.access_token); if (user.email.toLowerCase() !== env.ADMIN_EMAIL.toLowerCase()) { throw new Error(`This Google account is not the configured admin (${env.ADMIN_EMAIL})`); } if (token.refresh_token) { const encrypted = await encryptToken(env, token.refresh_token); await env.DB.prepare(` INSERT INTO oauth_tokens (provider, refresh_token_enc, account_email, updated_at) VALUES ('google', ?1, ?2, datetime('now')) ON CONFLICT(provider) DO UPDATE SET refresh_token_enc = excluded.refresh_token_enc, account_email = excluded.account_email, updated_at = excluded.updated_at `).bind(encrypted, user.email).run(); } else { const existing = await hasGoogleConnection(env); if (!existing) { throw new Error('Google did not return a refresh token. Revoke app access and connect again.'); } } return user.email; } async function accessToken(env: Env): Promise { const row = await env.DB.prepare( 'SELECT refresh_token_enc FROM oauth_tokens WHERE provider = ?1', ).bind('google').first<{ refresh_token_enc: string }>(); if (!row?.refresh_token_enc) throw new Error('Google Calendar is not connected'); const refreshToken = await decryptToken(env, row.refresh_token_enc); const response = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: env.GOOGLE_CLIENT_ID, client_secret: env.GOOGLE_CLIENT_SECRET, refresh_token: refreshToken, grant_type: 'refresh_token', }), }); const data = (await response.json()) as TokenResponse; if (!response.ok || !data.access_token) { throw new Error(data.error_description || data.error || 'Google refresh token failed'); } return data.access_token; } export async function queryBusy( env: Env, settings: AppSettings, timeMin: string, timeMax: string, ): Promise> { const token = await accessToken(env); const calendars = settings.busyCalendarIds.length ? settings.busyCalendarIds : [settings.calendarId]; const response = await fetch('https://www.googleapis.com/calendar/v3/freeBusy', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ timeMin, timeMax, timeZone: settings.timezone, items: calendars.map((id) => ({ id })), }), }); if (!response.ok) { await throwGoogleCalendarError(response, 'Google FreeBusy'); } const data = (await response.json()) as { calendars?: Record }>; }; return Object.values(data.calendars ?? {}).flatMap((calendar) => calendar.busy ?? []); } export async function createCalendarEvent( env: Env, settings: AppSettings, booking: { id: string; publicBookingId: string; name: string; email: string; phone?: string; message?: string; start: string; end: string; meetingMode: MeetingMode; }, ): Promise<{ eventId: string; meetUrl: string | null; htmlLink: string | null }> { const token = await accessToken(env); const params = new URLSearchParams({ sendUpdates: 'all', conferenceDataVersion: '1', }); const calendarId = encodeURIComponent(settings.calendarId || 'primary'); const descriptionLines = [ `Booked through ${env.APP_URL}`, `Booking ID: ${booking.publicBookingId}`, booking.phone ? `Phone: ${booking.phone}` : '', booking.message ? `Message: ${booking.message}` : '', ].filter(Boolean); const event: Record = { summary: `Meeting with ${booking.name}`, description: descriptionLines.join('\n\n'), start: { dateTime: booking.start, timeZone: settings.timezone }, end: { dateTime: booking.end, timeZone: settings.timezone }, attendees: [{ email: booking.email, displayName: booking.name }], }; if (booking.meetingMode === 'google_meet') { event.conferenceData = { createRequest: { requestId: booking.id, conferenceSolutionKey: { type: 'hangoutsMeet' }, }, }; } else if (booking.meetingMode === 'in_person' && settings.inPersonLocation) { event.location = settings.inPersonLocation; } else if (booking.meetingMode === 'phone') { event.location = 'Phone call'; } const response = await fetch( `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events?${params.toString()}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(event), }, ); if (!response.ok) { await throwGoogleCalendarError(response, 'Google event creation'); } const data = (await response.json()) as GoogleEventResponse & { error?: { message?: string } }; if (!data.id) { throw new GoogleCalendarError('Google event creation', 502, data.error?.message ?? 'Missing event id'); } const meetUrl = data.hangoutLink ?? data.conferenceData?.entryPoints?.find((entry) => entry.entryPointType === 'video')?.uri ?? null; return { eventId: data.id, meetUrl, htmlLink: data.htmlLink ?? null }; } export async function createCalendarHold( env: Env, settings: AppSettings, booking: { id: string; start: string; end: string; meetingMode: MeetingMode; }, ): Promise<{ eventId: string; meetUrl: string | null }> { const token = await accessToken(env); const params = new URLSearchParams({ sendUpdates: 'none', conferenceDataVersion: '1', }); const calendarId = encodeURIComponent(settings.calendarId || 'primary'); const event: Record = { summary: 'Pending booking payment', description: `Temporary hold created by ${env.APP_URL}. No invitation is sent until payment succeeds.`, start: { dateTime: booking.start, timeZone: settings.timezone }, end: { dateTime: booking.end, timeZone: settings.timezone }, visibility: 'private', transparency: 'opaque', }; if (booking.meetingMode === 'google_meet') { event.conferenceData = { createRequest: { requestId: `hold-${booking.id}`, conferenceSolutionKey: { type: 'hangoutsMeet' }, }, }; } const response = await fetch( `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events?${params.toString()}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(event), }, ); if (!response.ok) await throwGoogleCalendarError(response, 'Google payment hold creation'); const data = (await response.json()) as GoogleEventResponse & { error?: { message?: string } }; if (!data.id) throw new GoogleCalendarError('Google payment hold creation', 502, data.error?.message ?? 'Missing event id'); const meetUrl = data.hangoutLink ?? data.conferenceData?.entryPoints?.find((entry) => entry.entryPointType === 'video')?.uri ?? null; return { eventId: data.id, meetUrl }; } export async function confirmCalendarHold( env: Env, settings: AppSettings, eventId: string, booking: { id: string; publicBookingId: string; name: string; email: string; phone?: string; message?: string; start: string; end: string; meetingMode: MeetingMode; }, ): Promise<{ eventId: string; meetUrl: string | null; htmlLink: string | null }> { const token = await accessToken(env); const calendarId = encodeURIComponent(settings.calendarId || 'primary'); const encodedEventId = encodeURIComponent(eventId); const params = new URLSearchParams({ sendUpdates: 'all', conferenceDataVersion: '1' }); const descriptionLines = [ `Booked through ${env.APP_URL}`, `Booking ID: ${booking.publicBookingId}`, booking.phone ? `Phone: ${booking.phone}` : '', booking.message ? `Message: ${booking.message}` : '', 'Payment received via Stripe.', ].filter(Boolean); const event: Record = { summary: `Meeting with ${booking.name}`, description: descriptionLines.join('\n\n'), start: { dateTime: booking.start, timeZone: settings.timezone }, end: { dateTime: booking.end, timeZone: settings.timezone }, attendees: [{ email: booking.email, displayName: booking.name }], visibility: 'default', transparency: 'opaque', }; // Google Meet was created on the private hold already. Omitting conferenceData // here preserves that conference while the PATCH adds the attendee and sends the invite. if (booking.meetingMode === 'in_person' && settings.inPersonLocation) { event.location = settings.inPersonLocation; } else if (booking.meetingMode === 'phone') { event.location = 'Phone call'; } const response = await fetch( `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events/${encodedEventId}?${params.toString()}`, { method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(event), }, ); if (!response.ok) await throwGoogleCalendarError(response, 'Google paid booking confirmation'); const data = (await response.json()) as GoogleEventResponse & { error?: { message?: string } }; if (!data.id) throw new GoogleCalendarError('Google paid booking confirmation', 502, data.error?.message ?? 'Missing event id'); const meetUrl = data.hangoutLink ?? data.conferenceData?.entryPoints?.find((entry) => entry.entryPointType === 'video')?.uri ?? null; return { eventId: data.id, meetUrl, htmlLink: data.htmlLink ?? null }; } function managedBookingDescription( env: Env, booking: { phone?: string; message?: string; paid?: boolean; publicBookingId?: string }, extraLines: string[] = [], ): string { return [ `Booked through ${env.APP_URL}`, booking.publicBookingId ? `Booking ID: ${booking.publicBookingId}` : '', booking.phone ? `Phone: ${booking.phone}` : '', booking.message ? `Message: ${booking.message}` : '', booking.paid ? 'Payment received via Stripe.' : '', ...extraLines, ].filter(Boolean).join('\n\n'); } export async function rescheduleCalendarEvent( env: Env, settings: AppSettings, eventId: string, booking: { phone?: string; message?: string; paid?: boolean; publicBookingId?: string; start: string; end: string; }, ): Promise<{ eventId: string; meetUrl: string | null }> { const token = await accessToken(env); const calendarId = encodeURIComponent(settings.calendarId || 'primary'); const encodedEventId = encodeURIComponent(eventId); const params = new URLSearchParams({ sendUpdates: 'all', conferenceDataVersion: '1' }); const response = await fetch( `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events/${encodedEventId}?${params.toString()}`, { method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ start: { dateTime: booking.start, timeZone: settings.timezone }, end: { dateTime: booking.end, timeZone: settings.timezone }, description: managedBookingDescription(env, booking), }), }, ); if (!response.ok) await throwGoogleCalendarError(response, 'Google event reschedule'); const data = (await response.json()) as GoogleEventResponse & { error?: { message?: string } }; if (!data.id) throw new GoogleCalendarError('Google event reschedule', 502, data.error?.message ?? 'Missing event id'); const meetUrl = data.hangoutLink ?? data.conferenceData?.entryPoints?.find((entry) => entry.entryPointType === 'video')?.uri ?? null; return { eventId: data.id, meetUrl }; } export async function sendRescheduleSuggestion( env: Env, settings: AppSettings, eventId: string, booking: { phone?: string; message?: string; paid?: boolean; publicBookingId?: string }, proposal: { start: string; end: string; url: string }, ): Promise { const token = await accessToken(env); const calendarId = encodeURIComponent(settings.calendarId || 'primary'); const encodedEventId = encodeURIComponent(eventId); const params = new URLSearchParams({ sendUpdates: 'all' }); const formatter = new Intl.DateTimeFormat('en-US', { timeZone: settings.timezone, weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }); const proposedText = `${formatter.format(new Date(proposal.start))} – ${new Intl.DateTimeFormat('en-US', { timeZone: settings.timezone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }).format(new Date(proposal.end))} (${settings.timezone})`; const description = managedBookingDescription(env, booking, [ 'Slot reschedule suggestion', `Suggested new time: ${proposedText}`, `Review and accept: ${proposal.url}`, 'The existing meeting time stays confirmed until the suggestion is accepted.', ]); const response = await fetch( `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events/${encodedEventId}?${params.toString()}`, { method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ description }), }, ); if (!response.ok) await throwGoogleCalendarError(response, 'Google reschedule suggestion'); } export async function deleteCalendarEvent( env: Env, settings: AppSettings, eventId: string, ): Promise { const token = await accessToken(env); const calendarId = encodeURIComponent(settings.calendarId || 'primary'); const encodedEventId = encodeURIComponent(eventId); const params = new URLSearchParams({ sendUpdates: 'all' }); const response = await fetch( `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events/${encodedEventId}?${params.toString()}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }, ); if (response.status === 410 || response.status === 404) return; if (!response.ok) { await throwGoogleCalendarError(response, 'Google event cancellation'); } } export async function disconnectGoogle(env: Env): Promise { await env.DB.prepare("DELETE FROM oauth_tokens WHERE provider = 'google'").run(); }