import type { Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { pollingIdempotency } from '@/lib/core/idempotency/service' import { readCanonicalTriggerValue } from '@/lib/webhooks/polling/canonical' import { getProviderConfig, type PollingProviderHandler, type PollWebhookContext, } from '@/lib/webhooks/polling/types' import { markWebhookFailed, markWebhookSuccess, resolveOAuthCredential, updateWebhookProviderConfig, } from '@/lib/webhooks/polling/utils' import { processPolledWebhookEvent } from '@/lib/webhooks/processor' const CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3' const MAX_EVENTS_PER_POLL = 50 const MAX_PAGES = 10 type CalendarEventTypeFilter = '' | 'created' | 'updated' | 'cancelled' interface GoogleCalendarWebhookConfig { calendarId?: string manualCalendarId?: string eventTypeFilter?: CalendarEventTypeFilter searchTerm?: string lastCheckedTimestamp?: string maxEventsPerPoll?: number } interface CalendarEventAttendee { email: string displayName?: string responseStatus?: string self?: boolean organizer?: boolean } interface CalendarEventPerson { email: string displayName?: string self?: boolean } interface CalendarEventTime { dateTime?: string date?: string timeZone?: string } interface CalendarEvent { id: string status: string htmlLink?: string created?: string updated?: string summary?: string description?: string location?: string start?: CalendarEventTime end?: CalendarEventTime attendees?: CalendarEventAttendee[] creator?: CalendarEventPerson organizer?: CalendarEventPerson recurringEventId?: string } interface SimplifiedCalendarEvent { id: string status: string eventType: 'created' | 'updated' | 'cancelled' summary: string | null eventDescription: string | null location: string | null htmlLink: string | null start: CalendarEventTime | null end: CalendarEventTime | null created: string | null updated: string | null attendees: CalendarEventAttendee[] | null creator: CalendarEventPerson | null organizer: CalendarEventPerson | null } interface GoogleCalendarWebhookPayload { event: SimplifiedCalendarEvent calendarId: string timestamp: string } export const googleCalendarPollingHandler: PollingProviderHandler = { provider: 'google-calendar', label: 'Google Calendar', async pollWebhook(ctx: PollWebhookContext): Promise<'success' | 'failure'> { const { webhookData, workflowData, requestId, logger } = ctx const webhookId = webhookData.id try { const accessToken = await resolveOAuthCredential(webhookData, 'google-calendar', requestId) const config = getProviderConfig(webhookData.providerConfig) // Canonical key `calendarId` first; `manualCalendarId` is a transitional basic-first // fallback. Defaults to the user's primary calendar when none is set. const calendarId = readCanonicalTriggerValue(config.calendarId, config.manualCalendarId) || 'primary' // First poll: seed timestamp, emit nothing if (!config.lastCheckedTimestamp) { await updateWebhookProviderConfig( webhookId, { lastCheckedTimestamp: new Date(Date.now() - 30_000).toISOString() }, logger ) await markWebhookSuccess(webhookId, logger) logger.info(`[${requestId}] First poll for webhook ${webhookId}, seeded timestamp`) return 'success' } // Fetch changed events since last poll const events = await fetchChangedEvents(accessToken, calendarId, config, requestId, logger) if (!events.length) { await markWebhookSuccess(webhookId, logger) logger.info(`[${requestId}] No changed events for webhook ${webhookId}`) return 'success' } logger.info(`[${requestId}] Found ${events.length} changed events for webhook ${webhookId}`) const { processedCount, failedCount, latestUpdated } = await processEvents( events, calendarId, config.eventTypeFilter, webhookData, workflowData, requestId, logger ) // Advance cursor to latestUpdated - 5s for clock-skew overlap, but never regress // below the previous cursor — this prevents an infinite re-fetch loop when all // returned events are filtered client-side and latestUpdated is within 5s of the cursor. const newTimestamp = failedCount > 0 ? config.lastCheckedTimestamp : latestUpdated ? new Date( Math.max( new Date(latestUpdated).getTime() - 5000, new Date(config.lastCheckedTimestamp).getTime() ) ).toISOString() : config.lastCheckedTimestamp await updateWebhookProviderConfig(webhookId, { lastCheckedTimestamp: newTimestamp }, logger) if (failedCount > 0 && processedCount === 0) { await markWebhookFailed(webhookId, logger) logger.warn( `[${requestId}] All ${failedCount} events failed to process for webhook ${webhookId}` ) return 'failure' } await markWebhookSuccess(webhookId, logger) logger.info( `[${requestId}] Successfully processed ${processedCount} events for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` ) return 'success' } catch (error) { logger.error(`[${requestId}] Error processing Google Calendar webhook ${webhookId}:`, error) await markWebhookFailed(webhookId, logger) return 'failure' } }, } async function fetchChangedEvents( accessToken: string, calendarId: string, config: GoogleCalendarWebhookConfig, requestId: string, logger: Logger ): Promise { const allEvents: CalendarEvent[] = [] const maxEvents = config.maxEventsPerPoll || MAX_EVENTS_PER_POLL let pageToken: string | undefined let pages = 0 do { pages++ const params = new URLSearchParams({ updatedMin: config.lastCheckedTimestamp!, singleEvents: 'true', showDeleted: 'true', maxResults: String(Math.min(maxEvents, 250)), }) if (pageToken) { params.set('pageToken', pageToken) } if (config.searchTerm) { params.set('q', config.searchTerm) } const encodedCalendarId = encodeURIComponent(calendarId) const url = `${CALENDAR_API_BASE}/calendars/${encodedCalendarId}/events?${params.toString()}` const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` }, }) if (!response.ok) { const status = response.status const errorData = await response.json().catch(() => ({})) if (status === 403 || status === 429) { throw new Error( `Calendar API rate limit (${status}) — skipping to retry next poll cycle: ${JSON.stringify(errorData)}` ) } throw new Error(`Failed to fetch calendar events: ${status} - ${JSON.stringify(errorData)}`) } const data = await response.json() const events = (data.items || []) as CalendarEvent[] allEvents.push(...events) pageToken = data.nextPageToken as string | undefined // Stop if we have enough events or hit the page limit if (allEvents.length >= maxEvents || pages >= MAX_PAGES) { break } } while (pageToken) return allEvents.slice(0, maxEvents) } function determineEventType(event: CalendarEvent): 'created' | 'updated' | 'cancelled' { if (event.status === 'cancelled') { return 'cancelled' } // If created and updated are within 5 seconds, treat as newly created if (event.created && event.updated) { const createdTime = new Date(event.created).getTime() const updatedTime = new Date(event.updated).getTime() if (Math.abs(updatedTime - createdTime) < 5000) { return 'created' } } return 'updated' } function simplifyEvent( event: CalendarEvent, eventType?: 'created' | 'updated' | 'cancelled' ): SimplifiedCalendarEvent { return { id: event.id, status: event.status, eventType: eventType ?? determineEventType(event), summary: event.summary ?? null, eventDescription: event.description ?? null, location: event.location ?? null, htmlLink: event.htmlLink ?? null, start: event.start ?? null, end: event.end ?? null, created: event.created ?? null, updated: event.updated ?? null, attendees: event.attendees ?? null, creator: event.creator ?? null, organizer: event.organizer ?? null, } } async function processEvents( events: CalendarEvent[], calendarId: string, eventTypeFilter: CalendarEventTypeFilter | undefined, webhookData: PollWebhookContext['webhookData'], workflowData: PollWebhookContext['workflowData'], requestId: string, logger: Logger ): Promise<{ processedCount: number; failedCount: number; latestUpdated: string | null }> { let processedCount = 0 let failedCount = 0 let latestUpdated: string | null = null for (const event of events) { // Track the latest `updated` timestamp for clock-skew-free state tracking if (event.updated) { if (!latestUpdated || event.updated > latestUpdated) { latestUpdated = event.updated } } // Client-side event type filter — skip before idempotency so filtered events aren't cached const computedEventType = determineEventType(event) if (eventTypeFilter && computedEventType !== eventTypeFilter) { continue } try { // Idempotency key includes `updated` so re-edits of the same event re-trigger const idempotencyKey = `${webhookData.id}:${event.id}:${event.updated || event.created || ''}` await pollingIdempotency.executeWithIdempotency( 'google-calendar', idempotencyKey, async () => { const simplified = simplifyEvent(event, computedEventType) const payload: GoogleCalendarWebhookPayload = { event: simplified, calendarId, timestamp: new Date().toISOString(), } const result = await processPolledWebhookEvent( webhookData, workflowData, payload, requestId ) if (!result.success) { logger.error( `[${requestId}] Failed to process webhook for event ${event.id}:`, result.statusCode, result.error ) throw new Error(`Webhook processing failed (${result.statusCode}): ${result.error}`) } return { eventId: event.id, processed: true } } ) logger.info( `[${requestId}] Successfully processed event ${event.id} for webhook ${webhookData.id}` ) processedCount++ } catch (error) { const errorMessage = getErrorMessage(error, 'Unknown error') logger.error(`[${requestId}] Error processing event ${event.id}:`, errorMessage) failedCount++ } } return { processedCount, failedCount, latestUpdated } }