import { getDbExec } from "@agent-native/core/db"; import { saveOAuthTokens, deleteOAuthTokens, listOAuthAccountsByOwner, } from "@agent-native/core/oauth-tokens"; import { isOAuthConnected, getOAuthAccounts, getRequestOrgId, resolveSecret, runWithRequestContext, resolveGoogleProviderCredentialCandidatesWithReader, } from "@agent-native/core/server"; import type { CalendarEvent, GoogleAuthStatus, UpdateEventScope, } from "../../shared/api.js"; import { getGoogleEventColorHex } from "../../shared/google-event-colors.js"; import { createOAuth2Client, oauth2GetUserInfo, calendarListEvents, calendarFreeBusy, calendarGetEvent, calendarInsertEvent, calendarDeleteEvent, calendarPatchEvent, calendarUpdateEvent, peopleGetProfile, } from "./google-api.js"; import { alignSeriesRecurrenceToStart, shiftSeriesDateValue, } from "./series-recurrence.js"; const SCOPES = [ "https://www.googleapis.com/auth/calendar.readonly", "https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/directory.readonly", "https://www.googleapis.com/auth/contacts.readonly", "https://www.googleapis.com/auth/contacts.other.readonly", ]; interface GoogleTokens { access_token: string; refresh_token?: string; expiry_date?: number; token_type?: string; scope?: string; photoUrl?: string; } function optionalString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } async function getOAuth2Credentials(owner?: string, orgId?: string) { const credentials = ( await resolveGoogleProviderCredentialCandidates(owner, orgId) )[0]; if (!credentials) { throw new Error( "GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be saved in settings", ); } return credentials; } async function getOAuth2RefreshCredentials(owner?: string, orgId?: string) { const candidates = await resolveGoogleProviderCredentialCandidates( owner, orgId, ); if (!candidates.length) { throw new Error( "GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be saved in settings", ); } return candidates; } async function resolveGoogleProviderCredentialCandidates( owner?: string, orgId?: string, ) { const resolve = () => resolveGoogleProviderCredentialCandidatesWithReader({ readCredential: resolveSecret, fallbackReadCredential: (key) => process.env[key], }); const resolvedOrgId = orgId ?? getRequestOrgId(); return owner ? runWithRequestContext({ userEmail: owner, orgId: resolvedOrgId }, resolve) : await resolve(); } /** * Permanent OAuth refresh failures Google can return. When we hit one of * these, the refresh_token is dead — keeping the row around makes * `getAuthStatus` lie ("connected": true) and event fetches return an * empty list (no clients, no surfaced errors). Drop the row so the UI * shows the "Connect Google" banner instead of an empty calendar. * * Causes we've seen: * - `invalid_grant`: user revoked access, password changed, or token aged out * - `unauthorized_client`: the app's GOOGLE_CLIENT_ID was rotated in env; * tokens issued by the old client cannot be refreshed by the new one * - `invalid_client`: client_id/secret mismatch */ const PERMANENT_REFRESH_ERRORS = [ "invalid_grant", "unauthorized_client", "invalid_client", ]; function createGoogleMeetRequest() { return { createRequest: { requestId: `meet-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, conferenceSolutionKey: { type: "hangoutsMeet" }, }, }; } function mapConferenceData(data: any): CalendarEvent["conferenceData"] { if (!data) return undefined; return { entryPoints: data.entryPoints?.map((ep: any) => ({ entryPointType: ep.entryPointType, uri: ep.uri, label: ep.label || undefined, pin: ep.pin || undefined, passcode: ep.passcode || undefined, })), conferenceSolution: data.conferenceSolution ? { name: data.conferenceSolution.name, iconUri: data.conferenceSolution.iconUri || undefined, } : undefined, }; } const LIST_EVENT_TYPES = [ "default", "focusTime", "outOfOffice", "workingLocation", ]; const GOOGLE_READ_CONCURRENCY = 4; async function mapWithConcurrency( values: T[], map: (value: T) => Promise, ): Promise { const results: R[] = []; for (let index = 0; index < values.length; index += GOOGLE_READ_CONCURRENCY) { results.push( ...(await Promise.all( values.slice(index, index + GOOGLE_READ_CONCURRENCY).map(map), )), ); } return results; } function mapReminders( event: any, ): Pick { return { remindersUseDefault: event.reminders?.useDefault ?? true, reminders: event.reminders?.overrides?.map((r: any) => ({ method: r.method, minutes: r.minutes, })), }; } function mapAttachments(event: any): CalendarEvent["attachments"] { return event.attachments?.map((attachment: any) => ({ fileUrl: attachment.fileUrl, title: attachment.title || "Untitled", mimeType: attachment.mimeType || undefined, iconLink: attachment.iconLink || undefined, fileId: attachment.fileId || undefined, })); } function mapColor(event: any): Pick { return { colorId: event.colorId || undefined, color: getGoogleEventColorHex(event.colorId), }; } function mapAttendees(event: any): CalendarEvent["attendees"] { return event.attendees?.map((attendee: any) => ({ email: attendee.email, displayName: attendee.displayName || undefined, photoUrl: attendee.photoUrl || undefined, comment: attendee.comment || undefined, responseStatus: attendee.responseStatus || undefined, organizer: attendee.organizer || undefined, self: attendee.self || undefined, optional: attendee.optional === true ? true : undefined, })); } function mapOrganizer(event: any): CalendarEvent["organizer"] { return event.organizer ? { email: event.organizer.email, displayName: event.organizer.displayName || undefined, self: event.organizer.self || undefined, } : undefined; } function buildDateRange(event: CalendarEvent | Partial) { return { start: event.allDay ? { date: event.start?.split("T")[0] } : { dateTime: event.start, ...(event.startTimeZone ? { timeZone: event.startTimeZone } : {}), }, end: event.allDay ? { date: event.end?.split("T")[0] } : { dateTime: event.end, ...(event.endTimeZone ? { timeZone: event.endTimeZone } : {}), }, }; } function googleEventStartValue(event: any): string | undefined { return event.start?.dateTime || event.start?.date || undefined; } function googleEventEndValue(event: any): string | undefined { return event.end?.dateTime || event.end?.date || undefined; } function alignSeriesUpdateToMaster( event: Partial, instance: any, master: any, ): Partial { const aligned: Partial = { ...event }; if (event.start !== undefined) { aligned.start = shiftSeriesDateValue( event.start, googleEventStartValue(instance), googleEventStartValue(master), ); if (event.startTimeZone === undefined && master.start?.timeZone) { aligned.startTimeZone = master.start.timeZone; } } if (event.end !== undefined) { aligned.end = shiftSeriesDateValue( event.end, googleEventEndValue(instance), googleEventEndValue(master), ); if (event.endTimeZone === undefined && master.end?.timeZone) { aligned.endTimeZone = master.end.timeZone; } } return aligned; } function applyEventOptions(body: any, event: CalendarEvent): void { if (event.eventType && event.eventType !== "default") { body.eventType = event.eventType; } if (event.transparency !== undefined) body.transparency = event.transparency; if (event.visibility !== undefined) body.visibility = event.visibility; if (event.status !== undefined) body.status = event.status; if (event.colorId !== undefined) body.colorId = event.colorId; if (event.remindersUseDefault !== undefined) { body.reminders = event.remindersUseDefault ? { useDefault: true } : { useDefault: false, overrides: event.reminders ?? [] }; } else if (event.reminders !== undefined) { body.reminders = { useDefault: false, overrides: event.reminders }; } if (event.eventType === "outOfOffice") { body.outOfOfficeProperties = event.outOfOfficeProperties ?? { autoDeclineMode: "declineNone", }; body.transparency = "opaque"; } if (event.eventType === "focusTime") { body.focusTimeProperties = event.focusTimeProperties ?? { autoDeclineMode: "declineNone", chatStatus: "doNotDisturb", }; body.transparency = "opaque"; } if (event.eventType === "workingLocation") { body.workingLocationProperties = event.workingLocationProperties ?? { type: "customLocation", customLocation: { label: event.location || event.title || "Working location", }, }; body.visibility = "public"; body.transparency = "transparent"; } } function applyEventPatchOptions( body: any, event: Partial, ): void { if (event.transparency !== undefined) body.transparency = event.transparency; if (event.visibility !== undefined) body.visibility = event.visibility; if (event.status !== undefined) body.status = event.status; if (event.colorId !== undefined) body.colorId = event.colorId; if (event.remindersUseDefault !== undefined) { body.reminders = event.remindersUseDefault ? { useDefault: true } : { useDefault: false, overrides: event.reminders ?? [] }; } else if (event.reminders !== undefined) { body.reminders = { useDefault: false, overrides: event.reminders }; } if (event.outOfOfficeProperties !== undefined) { body.outOfOfficeProperties = event.outOfOfficeProperties; } if (event.focusTimeProperties !== undefined) { body.focusTimeProperties = event.focusTimeProperties; } if (event.workingLocationProperties !== undefined) { body.workingLocationProperties = event.workingLocationProperties; } } function isPermanentRefreshError(message: string): boolean { const m = message.toLowerCase(); return PERMANENT_REFRESH_ERRORS.some((code) => m.includes(code)); } /** * Get a valid access token for a Google account, refreshing if expired. * * Throws on refresh failure rather than returning a stale token. Callers * that aggregate across accounts should catch and translate to a per- * account error so UIs can prompt a reconnect instead of silently * showing empty results. */ async function getValidAccessToken( accountId: string, tokens: GoogleTokens, owner?: string, orgId?: string, ): Promise { if (!tokens.access_token && !tokens.refresh_token) { // The stored record has no usable credentials at all. The most common // cause is a row that failed to decrypt after a SECRETS_ENCRYPTION_KEY / // BETTER_AUTH_SECRET rotation — core's parseStoredTokens returns `{}` // instead of throwing. Without this guard the expiry check below is // skipped (no expiry_date) and we fall through to returning // `tokens.access_token === undefined`, so every Google call goes out as // "Authorization: Bearer undefined" and 401s instead of prompting a // reconnect. // // Deliberately do NOT delete the row here (unlike the provider-confirmed // dead paths below): a failed decrypt can also mean THIS process has the // wrong key — e.g. a dev server pointed at a prod DB with a different // secret, or key material missing at boot. Deleting would irreversibly // destroy tokens a correctly configured deployment can still decrypt. // Throwing is enough: getAuthStatus excludes accounts whose token fetch // throws, so the UI still flips to the reconnect banner. throw new Error( `No usable OAuth tokens for ${accountId} — please reconnect.`, ); } // Refresh when the token is expired (with a 5-minute buffer) or when the // record has a refresh token but no access token at all. if ( !tokens.access_token || (tokens.expiry_date && tokens.expiry_date < Date.now() + 5 * 60 * 1000) ) { if (!tokens.refresh_token) { // No refresh token means we can never recover this account; drop it // so the UI prompts a reconnect instead of using an expired token. await deleteOAuthTokens("google", accountId); throw new Error( `No refresh token available for ${accountId} — please reconnect.`, ); } try { let lastRefreshError: any; for (const { clientId, clientSecret, } of await getOAuth2RefreshCredentials(owner, orgId)) { try { const oauth2 = createOAuth2Client(clientId, clientSecret, ""); const newTokens = await oauth2.refreshToken(tokens.refresh_token); const merged = { ...tokens, ...newTokens }; await saveOAuthTokens( "google", accountId, merged as unknown as Record, owner ?? accountId, ); return merged.access_token; } catch (err: any) { lastRefreshError = err; if (!isPermanentRefreshError(err?.message || "")) { throw err; } } } throw lastRefreshError; } catch (err: any) { if (isPermanentRefreshError(err?.message || "")) { // Drop the dead row so isOAuthConnected returns false and the UI // surfaces the connect banner instead of a stale-token illusion. await deleteOAuthTokens("google", accountId); throw err; } // Transient failure (network hiccup, 5xx, timeout). If the existing // token hasn't actually expired yet — we only entered this path // because we're inside the 5-minute pre-expiry buffer — fall back to // it so a flaky moment doesn't 502 the calendar. if ( tokens.access_token && tokens.expiry_date != null && tokens.expiry_date > Date.now() ) { return tokens.access_token; } throw err; } } return tokens.access_token; } export async function getAuthUrl( origin?: string, redirectUri?: string, state?: string, owner?: string, orgId?: string, ): Promise { const { clientId, clientSecret } = await getOAuth2Credentials(owner, orgId); const uri = redirectUri || (origin ? `${origin}/_agent-native/google/callback` : undefined); const oauth2 = createOAuth2Client(clientId, clientSecret, uri ?? ""); return oauth2.generateAuthUrl({ access_type: "offline", scope: SCOPES, prompt: "consent", state, }); } export async function exchangeCode( code: string, origin?: string, redirectUri?: string, owner?: string, orgId?: string, ): Promise { const { clientId, clientSecret } = await getOAuth2Credentials(owner, orgId); const uri = redirectUri || (origin ? `${origin}/_agent-native/google/callback` : undefined); const oauth2 = createOAuth2Client(clientId, clientSecret, uri ?? ""); const tokens = await oauth2.getToken(code); // Get user email const userInfo = await oauth2GetUserInfo(tokens.access_token); const email = userInfo.email; if (!email) throw new Error("Google returned no email address"); const photoUrl = optionalString(userInfo.picture); await saveOAuthTokens( "google", email, { ...tokens, ...(photoUrl ? { photoUrl } : {}) } as Record, owner ?? email, ); return email; } async function resolveAccountPhotoUrl( accessToken: string, cachedPhotoUrl?: string, ): Promise { if (cachedPhotoUrl) return cachedPhotoUrl; try { const userInfo = await oauth2GetUserInfo(accessToken); const picture = optionalString(userInfo.picture); if (picture) return picture; } catch { // Fall back to People API below; some older tokens only carry product scopes. } try { const profile = await peopleGetProfile(accessToken, "photos"); const photo = profile.photos?.find((p: any) => p?.url && !p.default)?.url ?? profile.photos?.[0]?.url; return optionalString(photo); } catch { return undefined; } } async function getBetterAuthUserImage( email: string | undefined, ): Promise { if (!email) return undefined; try { const { rows } = await getDbExec().execute({ sql: 'SELECT image FROM "user" WHERE email = ? LIMIT 1', args: [email], }); return optionalString(rows[0]?.image); } catch { return undefined; } } export async function getClient( email: string | undefined, ): Promise<{ accessToken: string } | null> { if (!email) return null; const accounts = await listOAuthAccountsByOwner("google", email); if (accounts.length === 0) return null; const account = accounts.find((a) => a.accountId === email) ?? accounts[0]; const tokens = account.tokens as unknown as GoogleTokens; const accessToken = await getValidAccessToken( account.accountId, tokens, email, ); return { accessToken }; } export interface GoogleAccountSelection { ownerEmail: string; accountEmail: string; } export async function getDefaultAccountSelection( ownerEmail: string, ): Promise { const accounts = await listOAuthAccountsByOwner("google", ownerEmail); const account = accounts.find( (candidate) => candidate.accountId.trim().toLowerCase() === ownerEmail.trim().toLowerCase(), ) ?? accounts[0]; if (!account) { throw new Error( "Google Calendar not connected. Connect via Settings first.", ); } return { ownerEmail, accountEmail: account.accountId }; } /** Resolve one connected Google account beneath its signed-in owner. */ export async function getClientForAccount({ ownerEmail, accountEmail, }: GoogleAccountSelection): Promise<{ accessToken: string }> { const normalizedAccountEmail = accountEmail.trim().toLowerCase(); const accounts = await listOAuthAccountsByOwner("google", ownerEmail); const account = accounts.find( (candidate) => candidate.accountId.trim().toLowerCase() === normalizedAccountEmail, ); if (!account) { throw new Error( `Google Calendar account not connected for this user: ${accountEmail}`, ); } const accessToken = await getValidAccessToken( account.accountId, account.tokens as unknown as GoogleTokens, ownerEmail, ); return { accessToken }; } /** * Get OAuth credentials. When `forEmail` is provided, returns only that * user's credentials (multi-user mode). Otherwise returns an empty array. * * Refresh failures are swallowed per-account — the signature preserves * the "empty array means no usable client" contract that existing * callers rely on for graceful "no Google account connected" fallbacks. * Callers that need to surface "all your tokens are dead" to the UI * should use `getClientsWithErrors` directly (already wired into * `listEvents` and `listOverlayEvents`). */ export async function getClients( forEmail?: string, ): Promise> { const { clients } = await getClientsWithErrors(forEmail); return clients; } /** * Same as `getClients`, but also returns per-account refresh errors so * callers can distinguish "no accounts connected" (empty errors) from * "all accounts failed to refresh" (errors populated). Event fetches use * this to return a 502 with the underlying reason instead of silently * rendering an empty calendar. */ export async function getClientsWithErrors(forEmail?: string): Promise<{ clients: Array<{ email: string; accessToken: string }>; errors: Array<{ email: string; error: string }>; }> { if (!forEmail) return { clients: [], errors: [] }; const accounts = await listOAuthAccountsByOwner("google", forEmail); const clients: Array<{ email: string; accessToken: string }> = []; const errors: Array<{ email: string; error: string }> = []; for (const account of accounts) { const tokens = account.tokens as unknown as GoogleTokens; const owner = forEmail ?? ("owner" in account && typeof account.owner === "string" ? account.owner : undefined) ?? account.accountId; try { const accessToken = await getValidAccessToken( account.accountId, tokens, owner, ); clients.push({ email: account.accountId, accessToken }); } catch (err: any) { errors.push({ email: account.accountId, error: err?.message || "Unknown refresh error", }); } } return { clients, errors }; } /** * Resolve a caller's connected Google accounts without refreshing tokens. This * is intentionally separate from `getClientsWithErrors`: callers that accept * an account filter must reject an unowned requested account before they do * provider work for any account. */ export async function getOwnedAccountEmails( forEmail?: string, ): Promise { if (!forEmail) return []; const accounts = await listOAuthAccountsByOwner("google", forEmail); return accounts.map((account) => account.accountId); } export async function getClientsForAccountsWithErrors( forEmail: string | undefined, accountEmails?: string[], ): Promise<{ clients: Array<{ email: string; accessToken: string }>; errors: Array<{ email: string; error: string }>; requestedAccounts: string[]; resolvedAccounts: string[]; }> { if (!forEmail) { return { clients: [], errors: [], requestedAccounts: [], resolvedAccounts: [], }; } const accounts = await listOAuthAccountsByOwner("google", forEmail); const byNormalized = new Map( accounts.map((account) => [ account.accountId.trim().toLowerCase(), account, ]), ); const requestedAccounts = Array.from( new Set( (accountEmails ?? accounts.map((account) => account.accountId)) .map((email) => email.trim().toLowerCase()) .filter(Boolean), ), ); const unowned = requestedAccounts.filter((email) => !byNormalized.has(email)); if (unowned.length > 0) { throw new Error( `Google Calendar account not connected for this user: ${unowned.join(", ")}`, ); } const selected = requestedAccounts.map((email) => byNormalized.get(email)!); const clients: Array<{ email: string; accessToken: string }> = []; const errors: Array<{ email: string; error: string }> = []; await mapWithConcurrency(selected, async (account) => { try { const accessToken = await getValidAccessToken( account.accountId, account.tokens as unknown as GoogleTokens, forEmail, ); clients.push({ email: account.accountId, accessToken }); } catch (err: any) { errors.push({ email: account.accountId, error: err?.message || "Unknown refresh error", }); } }); clients.sort((a, b) => a.email.localeCompare(b.email)); errors.sort((a, b) => a.email.localeCompare(b.email)); return { clients, errors, requestedAccounts, resolvedAccounts: selected.map((account) => account.accountId), }; } export async function isConnected(forEmail?: string): Promise { return isOAuthConnected("google", forEmail ?? ""); } export async function getConnectedAccounts( forEmail?: string, ): Promise { if (!forEmail) return []; const accounts = await listOAuthAccountsByOwner("google", forEmail); return accounts.map((a) => a.accountId); } export async function getPrimaryAccountPhotoUrl( forEmail?: string, ): Promise { if (!forEmail) return undefined; const fallbackUserImage = await getBetterAuthUserImage(forEmail); const accounts = await listOAuthAccountsByOwner("google", forEmail); const account = accounts.find((a) => a.accountId === forEmail) ?? accounts[0]; if (!account) return fallbackUserImage; const tokens = account.tokens as unknown as GoogleTokens; const cachedPhotoUrl = optionalString(tokens.photoUrl); if (cachedPhotoUrl) return cachedPhotoUrl; try { const accessToken = await getValidAccessToken( account.accountId, tokens, forEmail, ); return (await resolveAccountPhotoUrl(accessToken)) ?? fallbackUserImage; } catch { return fallbackUserImage; } } export async function getAuthStatus( forEmail?: string, orgId?: string, ): Promise { const oauthAccounts = await getOAuthAccounts("google", forEmail); if (oauthAccounts.length === 0) { return { connected: false, accounts: [] }; } const result: Array<{ email: string; expiresAt?: string; photoUrl?: string; }> = []; for (const account of oauthAccounts) { const tokens = account.tokens as unknown as GoogleTokens; let photoUrl = optionalString(tokens.photoUrl); let tokenValid = false; try { const accessToken = await getValidAccessToken( account.accountId, tokens, forEmail, orgId, ); tokenValid = true; photoUrl = await resolveAccountPhotoUrl(accessToken, photoUrl); } catch { // getValidAccessToken throws when the refresh token is permanently // revoked (after deleting the broken row). Excluding the account here // ensures `connected` flips to false instead of reporting a dead // account as still connected. } if (!tokenValid) continue; result.push({ email: account.accountId, expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date).toISOString() : undefined, photoUrl, }); } return { connected: result.length > 0, accounts: result, }; } export async function disconnect(email?: string): Promise { await deleteOAuthTokens("google", email); } export async function listEvents( timeMin: string, timeMax: string, forEmail?: string, options: { accountEmails?: string[]; maxResults?: number } = {}, ): Promise<{ events: CalendarEvent[]; errors: Array<{ email: string; error: string }>; }> { const { clients, errors: refreshErrors } = await getClientsForAccountsWithErrors(forEmail, options.accountEmails); // Seed with refresh failures so a fully-dead connection (every account's // refresh_token revoked or invalidated by a GOOGLE_CLIENT_ID rotation) // reaches the caller — otherwise the result is indistinguishable from // "calendar is empty" and the user sees no error. const errors: Array<{ email: string; error: string }> = [...refreshErrors]; if (clients.length === 0) return { events: [], errors }; const allResults = await mapWithConcurrency( clients, async ({ email, accessToken }) => { try { const events: any[] = []; let pageToken: string | undefined; do { const response = await calendarListEvents(accessToken, "primary", { timeMin, timeMax, singleEvents: true, orderBy: "startTime", maxResults: options.maxResults ?? 2500, pageToken, eventTypes: LIST_EVENT_TYPES, }); events.push(...(response.items || [])); pageToken = typeof response.nextPageToken === "string" ? response.nextPageToken : undefined; } while (pageToken); return events.map((event: any) => { // Find the current user's RSVP status from attendees const selfAttendee = event.attendees?.find( (a: any) => a.self === true, ); return { id: `google-${event.id}`, title: event.summary || "Untitled", description: event.description || "", start: event.start?.dateTime || event.start?.date || "", end: event.end?.dateTime || event.end?.date || "", startTimeZone: event.start?.timeZone || undefined, endTimeZone: event.end?.timeZone || undefined, location: event.location || "", allDay: !event.start?.dateTime, source: "google" as const, googleEventId: event.id || undefined, htmlLink: event.htmlLink || undefined, accountEmail: email, responseStatus: selfAttendee?.responseStatus, transparency: event.transparency || undefined, ...mapColor(event), eventType: event.eventType || "default", attendees: mapAttendees(event), ...mapReminders(event), recurrence: event.recurrence || undefined, recurringEventId: event.recurringEventId || undefined, hangoutLink: event.hangoutLink || undefined, conferenceData: event.conferenceData ? { entryPoints: event.conferenceData.entryPoints?.map( (ep: any) => ({ entryPointType: ep.entryPointType, uri: ep.uri, label: ep.label || undefined, pin: ep.pin || undefined, passcode: ep.passcode || undefined, }), ), conferenceSolution: event.conferenceData.conferenceSolution ? { name: event.conferenceData.conferenceSolution.name, iconUri: event.conferenceData.conferenceSolution.iconUri || undefined, } : undefined, } : undefined, attachments: mapAttachments(event), visibility: event.visibility || undefined, status: event.status || undefined, outOfOfficeProperties: event.outOfOfficeProperties || undefined, focusTimeProperties: event.focusTimeProperties || undefined, workingLocationProperties: event.workingLocationProperties || undefined, organizer: mapOrganizer(event), createdAt: event.created || new Date().toISOString(), updatedAt: event.updated || new Date().toISOString(), }; }); } catch (error: any) { console.error( `[listEvents] Error fetching from ${email}:`, error.message, ); errors.push({ email, error: error.message }); return []; } }, ); return { events: allResults.flat(), errors }; } export async function getFreeBusy( timeMin: string, timeMax: string, calendarIds: string[], forEmail?: string, timeZone?: string, accountEmail?: string, ): Promise<{ calendars: Record< string, { busy: Array<{ start: string; end: string }>; errors?: Array<{ domain?: string; reason?: string }>; } >; errors: Array<{ email: string; error: string }>; }> { const ids = Array.from( new Set( calendarIds .map((id) => id.trim()) .filter((id) => id.length > 0) .map((id) => id.toLowerCase()), ), ); if (ids.length === 0) return { calendars: {}, errors: [] }; const { clients, errors } = await getClientsWithErrors(forEmail); if (clients.length === 0) return { calendars: {}, errors }; const selectedAccount = accountEmail?.trim().toLowerCase(); const client = selectedAccount ? clients.find((entry) => entry.email.toLowerCase() === selectedAccount) : clients[0]; if (!client) { return { calendars: {}, errors: [ ...errors, { email: accountEmail ?? "google", error: "Selected Google account is not connected.", }, ], }; } try { const response = await calendarFreeBusy(client.accessToken, { timeMin, timeMax, timeZone, items: ids.map((id) => ({ id })), }); const calendars = (response.calendars ?? {}) as Record< string, { busy?: Array<{ start: string; end: string }>; errors?: Array<{ domain?: string; reason?: string }>; } >; const normalized: Record< string, { busy: Array<{ start: string; end: string }>; errors?: Array<{ domain?: string; reason?: string }>; } > = {}; const calendarErrors: Array<{ email: string; error: string }> = []; for (const id of ids) { const calendar = calendars[id] ?? calendars[id.toLowerCase()]; const calendarError = calendar?.errors ?.map((error) => error.reason || error.domain) .filter(Boolean) .join(", "); normalized[id] = { busy: calendar?.busy ?? [], errors: calendar?.errors, }; if (calendarError) { calendarErrors.push({ email: id, error: calendarError }); } } return { calendars: normalized, errors: [...errors, ...calendarErrors] }; } catch (error: any) { return { calendars: {}, errors: [ ...errors, { email: accountEmail ?? forEmail ?? "google", error: error?.message || "Unable to load Google free/busy data", }, ], }; } } export async function listOverlayEvents( timeMin: string, timeMax: string, overlayEmails: string[], forEmail?: string, options: { accountEmails?: string[] } = {}, ): Promise<{ events: CalendarEvent[]; errors: Array<{ email: string; error: string }>; accountErrors: Array<{ email: string; error: string }>; }> { const { clients, errors: refreshErrors } = await getClientsForAccountsWithErrors(forEmail, options.accountEmails); const errors: Array<{ email: string; error: string }> = []; if (clients.length === 0) { const message = refreshErrors[0]?.error ?? "Google Calendar is not connected"; return { events: [], errors: overlayEmails.map((email) => ({ email, error: message })), accountErrors: refreshErrors, }; } const allResults = await Promise.all( overlayEmails.map(async (overlayEmail) => { const accessErrors: string[] = []; for (const client of clients) { try { const events: any[] = []; let pageToken: string | undefined; do { const response = await calendarListEvents( client.accessToken, overlayEmail, { timeMin, timeMax, singleEvents: true, orderBy: "startTime", eventTypes: LIST_EVENT_TYPES, pageToken, }, ); events.push(...(response.items || [])); pageToken = response.nextPageToken; } while (pageToken); return events.map((event: any) => ({ id: `overlay-${overlayEmail}-${event.id}`, title: event.summary || "Busy", description: event.description || "", start: event.start?.dateTime || event.start?.date || "", end: event.end?.dateTime || event.end?.date || "", startTimeZone: event.start?.timeZone || undefined, endTimeZone: event.end?.timeZone || undefined, location: event.location || "", allDay: !event.start?.dateTime, source: "google" as const, googleEventId: event.id || undefined, htmlLink: event.htmlLink || undefined, eventType: event.eventType || "default", accountEmail: client.email, overlayEmail, ...mapColor(event), attendees: mapAttendees(event), organizer: mapOrganizer(event), createdAt: event.created || new Date().toISOString(), updatedAt: event.updated || new Date().toISOString(), })); } catch (error: any) { const message = error?.message || "Unable to read overlay calendar"; console.error( `[listOverlayEvents] Error fetching ${overlayEmail} via ${client.email}:`, message, ); accessErrors.push(`${client.email}: ${message}`); } } errors.push({ email: overlayEmail, error: `No selected Google account could read this overlay (${accessErrors.join("; ")})`, }); return []; }), ); return { events: allResults.flat(), errors, accountErrors: refreshErrors }; } export async function getEvent( googleEventId: string, account: GoogleAccountSelection, ): Promise { const client = await getClientForAccount(account); const event = await calendarGetEvent( client.accessToken, "primary", googleEventId, ); const selfAttendee = event.attendees?.find((a: any) => a.self === true); return { id: `google-${event.id}`, title: event.summary || "Untitled", description: event.description || "", start: event.start?.dateTime || event.start?.date || "", end: event.end?.dateTime || event.end?.date || "", startTimeZone: event.start?.timeZone || undefined, endTimeZone: event.end?.timeZone || undefined, location: event.location || "", allDay: !event.start?.dateTime, source: "google", googleEventId: event.id || undefined, htmlLink: event.htmlLink || undefined, accountEmail: account.accountEmail, responseStatus: selfAttendee?.responseStatus || undefined, transparency: event.transparency || undefined, ...mapColor(event), eventType: event.eventType || "default", attendees: mapAttendees(event), ...mapReminders(event), recurrence: event.recurrence || undefined, recurringEventId: event.recurringEventId || undefined, hangoutLink: event.hangoutLink || undefined, conferenceData: mapConferenceData(event.conferenceData), attachments: mapAttachments(event), visibility: event.visibility || undefined, status: event.status || undefined, outOfOfficeProperties: event.outOfOfficeProperties || undefined, focusTimeProperties: event.focusTimeProperties || undefined, workingLocationProperties: event.workingLocationProperties || undefined, organizer: mapOrganizer(event), createdAt: event.created || new Date().toISOString(), updatedAt: event.updated || new Date().toISOString(), }; } export async function createEvent( event: CalendarEvent, opts: { account: GoogleAccountSelection; addGoogleMeet?: boolean; sendUpdates?: "all" | "externalOnly" | "none"; }, ): Promise<{ id?: string; htmlLink?: string; meetLink?: string; conferenceData?: CalendarEvent["conferenceData"]; }> { const client = await getClientForAccount(opts.account); if ( (event.eventType === "outOfOffice" || event.eventType === "focusTime") && event.allDay ) { throw new Error("Out of office and focus time events must be timed."); } const body: any = event.eventType === "workingLocation" ? buildDateRange(event) : { summary: event.title, description: event.description, location: event.location, ...buildDateRange(event), }; applyEventOptions(body, event); if (event.attachments !== undefined) { body.attachments = event.attachments; } if (event.attendees && event.attendees.length > 0) { body.attendees = event.attendees.map((a) => ({ email: a.email, ...(a.displayName ? { displayName: a.displayName } : {}), ...(a.comment ? { comment: a.comment } : {}), ...(a.responseStatus ? { responseStatus: a.responseStatus } : {}), ...(a.optional === true ? { optional: true } : {}), })); } if (opts?.addGoogleMeet) { body.conferenceData = createGoogleMeetRequest(); } const insertOpts: { conferenceDataVersion?: number; sendUpdates?: string; supportsAttachments?: boolean; } = {}; if (opts?.addGoogleMeet) insertOpts.conferenceDataVersion = 1; if (opts?.sendUpdates) insertOpts.sendUpdates = opts.sendUpdates; if (event.attachments !== undefined) insertOpts.supportsAttachments = true; const response = await calendarInsertEvent( client.accessToken, "primary", body, Object.keys(insertOpts).length > 0 ? insertOpts : undefined, ); return { id: response.id || undefined, htmlLink: response.htmlLink || undefined, meetLink: response.hangoutLink || undefined, conferenceData: mapConferenceData(response.conferenceData), }; } export async function updateEvent( googleEventId: string, event: Partial, options: { account: GoogleAccountSelection; sendUpdates?: "all" | "none"; addGoogleMeet?: boolean; scope?: UpdateEventScope; }, ): Promise<{ htmlLink?: string; meetLink?: string; conferenceData?: CalendarEvent["conferenceData"]; attendees?: CalendarEvent["attendees"]; }> { const client = await getClientForAccount(options.account); let targetEventId = googleEventId; let eventPatch = event; if (options?.scope === "all") { const instance = await calendarGetEvent( client.accessToken, "primary", googleEventId, ); const recurringEventId = instance.recurringEventId || googleEventId; targetEventId = recurringEventId; let master = instance; if (recurringEventId !== googleEventId) { master = await calendarGetEvent( client.accessToken, "primary", recurringEventId, ); eventPatch = alignSeriesUpdateToMaster(event, instance, master); } eventPatch = alignSeriesRecurrenceToStart(eventPatch, { startValue: googleEventStartValue(master), startTimeZone: master.start?.timeZone, recurrence: master.recurrence, }); } const requestBody: any = {}; if (eventPatch.title !== undefined) requestBody.summary = eventPatch.title; if (eventPatch.description !== undefined) requestBody.description = eventPatch.description; if ( eventPatch.location !== undefined && eventPatch.workingLocationProperties === undefined ) requestBody.location = eventPatch.location; if (eventPatch.start !== undefined) { requestBody.start = eventPatch.allDay ? { date: eventPatch.start.split("T")[0] } : { dateTime: eventPatch.start, ...(eventPatch.startTimeZone ? { timeZone: eventPatch.startTimeZone } : {}), }; } if (eventPatch.end !== undefined) { requestBody.end = eventPatch.allDay ? { date: eventPatch.end.split("T")[0] } : { dateTime: eventPatch.end, ...(eventPatch.endTimeZone ? { timeZone: eventPatch.endTimeZone } : {}), }; } if (eventPatch.attendees !== undefined) { requestBody.attendees = eventPatch.attendees.map((a) => ({ email: a.email, ...(a.displayName ? { displayName: a.displayName } : {}), ...(a.comment ? { comment: a.comment } : {}), ...(a.responseStatus ? { responseStatus: a.responseStatus } : {}), ...(a.optional === true ? { optional: true } : { optional: false }), })); } if (eventPatch.recurrence !== undefined) { requestBody.recurrence = eventPatch.recurrence; } if (eventPatch.attachments !== undefined) { requestBody.attachments = eventPatch.attachments; } applyEventPatchOptions(requestBody, eventPatch); if (options?.addGoogleMeet) { requestBody.conferenceData = createGoogleMeetRequest(); } // Google validates status events as complete resources during updates. A // partial PATCH can reject otherwise valid working-location changes because // required eventType/start/end fields are absent from the request body. const response = eventPatch.workingLocationProperties ? await calendarUpdateEvent( client.accessToken, "primary", targetEventId, { ...(await calendarGetEvent( client.accessToken, "primary", targetEventId, )), ...requestBody, }, { sendUpdates: options?.sendUpdates, conferenceDataVersion: options?.addGoogleMeet ? 1 : undefined, supportsAttachments: eventPatch.attachments !== undefined ? true : undefined, }, ) : await calendarPatchEvent( client.accessToken, "primary", targetEventId, requestBody, { sendUpdates: options?.sendUpdates, conferenceDataVersion: options?.addGoogleMeet ? 1 : undefined, supportsAttachments: eventPatch.attachments !== undefined ? true : undefined, }, ); return { htmlLink: response?.htmlLink || undefined, meetLink: response?.hangoutLink || undefined, conferenceData: mapConferenceData(response?.conferenceData), attendees: response?.attendees?.map((a: any) => ({ email: a.email, displayName: a.displayName || undefined, comment: a.comment || undefined, responseStatus: a.responseStatus || undefined, organizer: a.organizer || undefined, self: a.self || undefined, optional: a.optional === true ? true : undefined, })), }; } export async function deleteEvent( googleEventId: string, account: GoogleAccountSelection, options?: { scope?: "single" | "all" | "thisAndFollowing"; sendUpdates?: "all" | "none"; }, ): Promise { const client = await getClientForAccount(account); const scope = options?.scope || "single"; const sendUpdates = options?.sendUpdates; if (scope === "single") { await calendarDeleteEvent( client.accessToken, "primary", googleEventId, sendUpdates, ); return; } // For "all" or "thisAndFollowing", find the master recurring event const instance = await calendarGetEvent( client.accessToken, "primary", googleEventId, ); const recurringEventId = instance.recurringEventId || googleEventId; if (scope === "all") { await calendarDeleteEvent( client.accessToken, "primary", recurringEventId, sendUpdates, ); return; } // "thisAndFollowing" — truncate the recurrence rule on the master event if (recurringEventId === googleEventId) { // This IS the master event, just delete the whole series await calendarDeleteEvent( client.accessToken, "primary", googleEventId, sendUpdates, ); return; } const instanceStart = instance.start?.dateTime || instance.start?.date || ""; const isAllDay = !instance.start?.dateTime; // Compute UNTIL value (day before this instance) const cutoff = new Date(instanceStart); cutoff.setDate(cutoff.getDate() - 1); let untilStr: string; if (isAllDay) { // All-day: UNTIL=YYYYMMDD untilStr = cutoff.toISOString().slice(0, 10).replace(/-/g, ""); } else { // Timed: UNTIL=YYYYMMDDTHHMMSSZ (end of the cutoff day in UTC) cutoff.setUTCHours(23, 59, 59, 0); untilStr = cutoff.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; } // Get the master event's recurrence rules and truncate const master = await calendarGetEvent( client.accessToken, "primary", recurringEventId, ); const recurrence: string[] = master.recurrence || []; const updatedRecurrence = recurrence.map((rule: string) => { if (rule.startsWith("RRULE:")) { // Remove any existing UNTIL or COUNT let updated = rule.replace(/;(UNTIL|COUNT)=[^;]*/g, ""); updated += `;UNTIL=${untilStr}`; return updated; } return rule; }); await calendarPatchEvent( client.accessToken, "primary", recurringEventId, { recurrence: updatedRecurrence }, { sendUpdates }, ); } /** * Remove an event from the current user's calendar without deleting it for others. * Calls the DELETE API endpoint which removes it from this user's calendar view * without cancelling or affecting other attendees. */ export async function removeEventFromCalendar( googleEventId: string, account: GoogleAccountSelection, options?: { scope?: "single" | "all" | "thisAndFollowing"; sendUpdates?: "all" | "none"; }, ): Promise { const client = await getClientForAccount(account); const scope = options?.scope || "single"; const sendUpdates = options?.sendUpdates; if (scope === "single") { await calendarDeleteEvent( client.accessToken, "primary", googleEventId, sendUpdates, ); return; } // For "all" or "thisAndFollowing", find the base recurring event const instance = await calendarGetEvent( client.accessToken, "primary", googleEventId, ); const recurringEventId = instance.recurringEventId || googleEventId; if (scope === "all") { await calendarDeleteEvent( client.accessToken, "primary", recurringEventId, sendUpdates, ); return; } // "thisAndFollowing" — delete each instance from this one onward // For non-organizers we can only delete instance by instance; delete this one await calendarDeleteEvent( client.accessToken, "primary", googleEventId, sendUpdates, ); } /** RSVP a single event instance without overwriting the full attendee list. */ async function rsvpSingleEvent( accessToken: string, eventId: string, responseStatus: string, accountEmail: string, comment?: string, sendUpdates?: string, ): Promise { await calendarPatchEvent( accessToken, "primary", eventId, { attendees: [ { email: accountEmail, responseStatus, ...(comment !== undefined ? { comment } : {}), }, ], attendeesOmitted: true, }, { sendUpdates: sendUpdates ?? "none" }, ); } /** * Update the current user's RSVP status for an event. * Supports recurring event scopes: "single", "all", or "thisAndFollowing". */ export async function rsvpEvent( googleEventId: string, responseStatus: "accepted" | "declined" | "tentative", account: GoogleAccountSelection, scope: "single" | "all" | "thisAndFollowing" = "single", comment?: string, sendUpdates?: string, ): Promise { const client = await getClientForAccount(account); if (scope === "single") { await rsvpSingleEvent( client.accessToken, googleEventId, responseStatus, account.accountEmail, comment, sendUpdates, ); return; } // For "all" or "thisAndFollowing", we need the base recurring event ID. const instance = await calendarGetEvent( client.accessToken, "primary", googleEventId, ); const recurringEventId = instance.recurringEventId || googleEventId; if (scope === "all") { // RSVP the base recurring event — Google propagates to all instances // that don't have individual overrides. await rsvpSingleEvent( client.accessToken, recurringEventId, responseStatus, account.accountEmail, comment, sendUpdates, ); return; } // "thisAndFollowing": RSVP this instance and all future instances. // Get the start time of the current instance to use as the cutoff. const instanceStart = instance.start?.dateTime || instance.start?.date || new Date().toISOString(); // Fetch all future instances of this recurring event const futureEvents = await calendarListEvents(client.accessToken, "primary", { timeMin: instanceStart, singleEvents: true, orderBy: "startTime", maxResults: 250, }); // Filter to only instances of the same recurring series const futureInstances = (futureEvents.items || []).filter( (e: any) => e.recurringEventId === recurringEventId || e.id === recurringEventId, ); // RSVP each instance (including the current one) await Promise.all( futureInstances.map((e: any) => rsvpSingleEvent( client.accessToken, e.id, responseStatus, account.accountEmail, comment, sendUpdates, ), ), ); }