/** * Google Calendar API client. * * Pure functions using native fetch — no googleapis library needed. * Handles token auto-refresh, calendar listing, event fetch (full + incremental), * event creation, and mapping Google events → rCal CalendarEvent fields. */ import { connectionsDocId } from '../modules/rdocs/schemas'; import type { ConnectionsDoc } from '../modules/rdocs/schemas'; import type { CalendarEvent } from '../modules/rcal/schemas'; import type { SyncServer } from './local-first/sync-server'; const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''; const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || ''; const GCAL_BASE = 'https://www.googleapis.com/calendar/v3'; // ── Types ── export interface GoogleCalendarEntry { id: string; summary: string; description?: string; backgroundColor?: string; foregroundColor?: string; primary?: boolean; accessRole: string; selected?: boolean; timeZone?: string; } export interface GoogleEvent { id: string; status: string; summary?: string; description?: string; location?: string; start?: { dateTime?: string; date?: string; timeZone?: string }; end?: { dateTime?: string; date?: string; timeZone?: string }; recurrence?: string[]; attendees?: Array<{ email: string; displayName?: string; responseStatus?: string; self?: boolean }>; hangoutLink?: string; conferenceData?: { entryPoints?: Array<{ entryPointType: string; uri: string; label?: string }> }; htmlLink?: string; updated?: string; } export interface GoogleEventInput { summary: string; description?: string; location?: string; start: { dateTime?: string; date?: string; timeZone?: string }; end: { dateTime?: string; date?: string; timeZone?: string }; } export interface FetchEventsResult { events: GoogleEvent[]; nextSyncToken: string | null; deleted: string[]; } // ── Token management ── /** * Get a valid Google access token for a space, auto-refreshing if expired. * Returns null if no Google connection exists. */ export async function getValidGoogleToken( space: string, syncServer: SyncServer, ): Promise { const docId = connectionsDocId(space); const doc = syncServer.getDoc(docId); if (!doc?.google?.refreshToken) return null; // Check if token is still valid (with 60s buffer) if (doc.google.accessToken && doc.google.expiresAt > Date.now() + 60_000) { return doc.google.accessToken; } // Refresh the token const tokenRes = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET, refresh_token: doc.google.refreshToken, grant_type: 'refresh_token', }), }); if (!tokenRes.ok) { const err = await tokenRes.text(); throw new Error(`Google token refresh failed: ${err}`); } const tokenData = (await tokenRes.json()) as any; syncServer.changeDoc(docId, 'Auto-refresh Google token', (d) => { if (d.google) { d.google.accessToken = tokenData.access_token; d.google.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000; } }); return tokenData.access_token; } // ── API helpers ── async function gcalFetch(url: string, token: string, opts: RequestInit = {}): Promise { const res = await fetch(url, { ...opts, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...((opts.headers as Record) || {}), }, }); if (!res.ok) { const body = await res.text(); const err = new Error(`Google Calendar API error ${res.status}: ${body}`) as any; err.status = res.status; throw err; } return res.json(); } // ── Calendar listing ── /** List all calendars the user has access to. */ export async function listGoogleCalendars(token: string): Promise { const data = await gcalFetch(`${GCAL_BASE}/users/me/calendarList`, token); return (data.items || []) as GoogleCalendarEntry[]; } // ── Event fetching (full + incremental) ── /** * Fetch events from a Google Calendar. * * - Without syncToken: full fetch (uses timeMin/timeMax window) * - With syncToken: incremental fetch (only changes since last sync) * * Returns events, deleted event IDs, and the next sync token. */ export async function fetchGoogleEvents( token: string, calendarId: string, opts: { syncToken?: string | null; timeMin?: string; timeMax?: string; maxResults?: number; } = {}, ): Promise { const allEvents: GoogleEvent[] = []; const deleted: string[] = []; let pageToken: string | undefined; let nextSyncToken: string | null = null; do { const params = new URLSearchParams({ maxResults: String(opts.maxResults || 250), singleEvents: 'true', }); if (opts.syncToken) { // Incremental sync — only pass syncToken, no time bounds params.set('syncToken', opts.syncToken); } else { // Full sync — use time window if (opts.timeMin) params.set('timeMin', opts.timeMin); if (opts.timeMax) params.set('timeMax', opts.timeMax); params.set('orderBy', 'startTime'); } if (pageToken) params.set('pageToken', pageToken); const data = await gcalFetch( `${GCAL_BASE}/calendars/${encodeURIComponent(calendarId)}/events?${params}`, token, ); for (const item of data.items || []) { if (item.status === 'cancelled') { deleted.push(item.id); } else { allEvents.push(item as GoogleEvent); } } pageToken = data.nextPageToken; if (data.nextSyncToken) nextSyncToken = data.nextSyncToken; } while (pageToken); return { events: allEvents, nextSyncToken, deleted }; } // ── Event creation (manual push) ── /** Create an event on a Google Calendar. Returns the created event's Google ID. */ export async function createGoogleEvent( token: string, calendarId: string, event: GoogleEventInput, ): Promise { const data = await gcalFetch( `${GCAL_BASE}/calendars/${encodeURIComponent(calendarId)}/events`, token, { method: 'POST', body: JSON.stringify(event) }, ); return data.id; } // ── Event mapping ── /** Parse a Google Calendar date/dateTime field to epoch milliseconds. */ function parseGoogleDateTime(dt?: { dateTime?: string; date?: string }): { ms: number; allDay: boolean } { if (!dt) return { ms: 0, allDay: false }; if (dt.dateTime) return { ms: new Date(dt.dateTime).getTime(), allDay: false }; if (dt.date) return { ms: new Date(dt.date + 'T00:00:00').getTime(), allDay: true }; return { ms: 0, allDay: false }; } /** Extract virtual meeting URL from Google event. */ function extractVirtualUrl(gEvent: GoogleEvent): { url: string | null; platform: string | null } { if (gEvent.hangoutLink) return { url: gEvent.hangoutLink, platform: 'Google Meet' }; const ep = gEvent.conferenceData?.entryPoints?.find((e) => e.entryPointType === 'video'); if (ep?.uri) { let platform = 'Video Call'; if (ep.uri.includes('zoom.us')) platform = 'Zoom'; else if (ep.uri.includes('teams.microsoft')) platform = 'Microsoft Teams'; else if (ep.uri.includes('meet.jit.si')) platform = 'Jitsi'; return { url: ep.uri, platform }; } return { url: null, platform: null }; } /** Map a Google Calendar event to rCal CalendarEvent fields. */ export function mapGoogleEventToCalendar( gEvent: GoogleEvent, sourceId: string, sourceName: string, sourceColor: string | null, ): Partial { const start = parseGoogleDateTime(gEvent.start); const end = parseGoogleDateTime(gEvent.end); const virtual = extractVirtualUrl(gEvent); const attendees = (gEvent.attendees || []) .filter((a) => !a.self) .map((a) => ({ name: a.displayName || a.email, email: a.email, status: ( a.responseStatus === 'accepted' ? 'yes' : a.responseStatus === 'declined' ? 'no' : a.responseStatus === 'tentative' ? 'maybe' : 'pending' ) as 'yes' | 'no' | 'maybe' | 'pending', respondedAt: Date.now(), source: 'import' as const, })); return { title: gEvent.summary || '(No title)', description: gEvent.description || '', startTime: start.ms, endTime: end.ms || start.ms + 3600000, allDay: start.allDay, timezone: gEvent.start?.timeZone || null, rrule: gEvent.recurrence?.[0] || null, status: gEvent.status || null, likelihood: null, visibility: null, sourceId, sourceName, sourceType: 'GOOGLE', sourceColor, locationId: null, locationName: gEvent.location || null, coordinates: null, locationGranularity: null, locationLat: null, locationLng: null, locationBreadcrumb: null, bookingStatus: null, isVirtual: !!virtual.url, virtualUrl: virtual.url, virtualPlatform: virtual.platform, rToolSource: 'GOOGLE_CAL', rToolEntityId: gEvent.id, attendees, attendeeCount: attendees.length, tags: null, metadata: null, }; } /** Convert an rCal event to Google Calendar event input format. */ export function mapCalendarEventToGoogle(event: CalendarEvent): GoogleEventInput { if (event.allDay) { const startDate = new Date(event.startTime).toISOString().slice(0, 10); const endDate = new Date(event.endTime || event.startTime + 86400000).toISOString().slice(0, 10); return { summary: event.title, description: event.description || undefined, location: event.locationName || undefined, start: { date: startDate }, end: { date: endDate }, }; } return { summary: event.title, description: event.description || undefined, location: event.locationName || undefined, start: { dateTime: new Date(event.startTime).toISOString(), timeZone: event.timezone || undefined, }, end: { dateTime: new Date(event.endTime || event.startTime + 3600000).toISOString(), timeZone: event.timezone || undefined, }, }; }