/** * Google Calendar sync + event creation for rSchedule. * * Reuses rspace's global OAuth infra (server/oauth/google.ts, server/google-calendar.ts) * and the ConnectionsDoc stored by rdocs. rSchedule owns the *busy-time cache* * (config.googleEvents) and *sync metadata* (config.googleAuth.syncToken, etc.) * in its own config doc; tokens live in ConnectionsDoc (shared across modules). */ import { getValidGoogleToken, listGoogleCalendars, fetchGoogleEvents, createGoogleEvent, type GoogleEvent, type GoogleEventInput, } from "../../../server/google-calendar"; import { connectionsDocId } from "../../rdocs/schemas"; import type { ConnectionsDoc } from "../../rdocs/schemas"; import type { SyncServer } from "../../../server/local-first/sync-server"; import { scheduleConfigDocId, type ScheduleConfigDoc, type CachedGcalEvent, type Booking, MAX_GCAL_EVENTS_CACHED, } from "../schemas"; // ── Status + helpers ── export interface GcalStatus { connected: boolean; email: string | null; connectedAt: number | null; calendarIds: string[]; syncToken: string | null; lastSyncAt: number | null; lastSyncStatus: "ok" | "error" | null; lastSyncError: string | null; } export function getGcalStatus(space: string, syncServer: SyncServer): GcalStatus { const conn = syncServer.getDoc(connectionsDocId(space)); const cfg = syncServer.getDoc(scheduleConfigDocId(space)); const connected = Boolean(conn?.google?.refreshToken); return { connected, email: conn?.google?.email ?? null, connectedAt: conn?.google?.connectedAt ?? null, calendarIds: cfg?.googleAuth?.calendarIds ?? [], syncToken: cfg?.googleAuth?.syncToken ?? null, lastSyncAt: cfg?.googleAuth?.lastSyncAt ?? null, lastSyncStatus: cfg?.googleAuth?.lastSyncStatus ?? null, lastSyncError: cfg?.googleAuth?.lastSyncError ?? null, }; } // ── Busy-time sync ── 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 }; } function toCached(ev: GoogleEvent, calendarId: string): CachedGcalEvent | null { const start = parseGoogleDateTime(ev.start); const end = parseGoogleDateTime(ev.end); if (!start.ms || !end.ms) return null; return { id: ev.id, googleEventId: ev.id, calendarId, title: ev.summary || "(busy)", startTime: start.ms, endTime: end.ms, allDay: start.allDay, status: (ev.status === "cancelled" || ev.status === "tentative" ? ev.status : "confirmed") as CachedGcalEvent["status"], // Treat untagged events as opaque (they block). Google's `transparency` // field would ideally come through but our lightweight GoogleEvent type // doesn't include it — default to opaque which matches source app behavior. transparency: "opaque", }; } /** * Sync primary calendar busy times into config.googleEvents. * * Uses incremental sync via syncToken when possible. Falls back to a 90-day * window on first sync or if Google returns 410 (token expired). */ export async function syncGcalBusy(space: string, syncServer: SyncServer): Promise<{ ok: boolean; eventsUpdated: number; deleted: number; error?: string }> { const cfgId = scheduleConfigDocId(space); const cfg = syncServer.getDoc(cfgId); if (!cfg) return { ok: false, eventsUpdated: 0, deleted: 0, error: "No rSchedule config" }; const token = await getValidGoogleToken(space, syncServer).catch(() => null); if (!token) { syncServer.changeDoc(cfgId, "gcal sync: no token", (d) => { d.googleAuth.lastSyncAt = Date.now(); d.googleAuth.lastSyncStatus = "error"; d.googleAuth.lastSyncError = "Not connected to Google"; }); return { ok: false, eventsUpdated: 0, deleted: 0, error: "Not connected to Google" }; } // Pick calendar: explicit config, else primary let calendarId = cfg.googleAuth.calendarIds[0] || "primary"; if (calendarId === "primary") { try { const cals = await listGoogleCalendars(token); const primary = cals.find((c) => c.primary) || cals[0]; if (primary) { calendarId = primary.id; syncServer.changeDoc(cfgId, "gcal sync: save primary cal id", (d) => { d.googleAuth.calendarIds = [primary.id]; if (!d.googleAuth.email && cfg.googleAuth.email === null) { // best-effort } }); } } catch (e: any) { syncServer.changeDoc(cfgId, "gcal sync: list cals error", (d) => { d.googleAuth.lastSyncAt = Date.now(); d.googleAuth.lastSyncStatus = "error"; d.googleAuth.lastSyncError = `listCalendars: ${e?.message || String(e)}`; }); return { ok: false, eventsUpdated: 0, deleted: 0, error: `listCalendars: ${e?.message}` }; } } let syncToken = cfg.googleAuth.syncToken; let fetchResult; try { if (syncToken) { fetchResult = await fetchGoogleEvents(token, calendarId, { syncToken }); } else { const now = Date.now(); fetchResult = await fetchGoogleEvents(token, calendarId, { timeMin: new Date(now).toISOString(), timeMax: new Date(now + 90 * 86400_000).toISOString(), }); } } catch (e: any) { if (e?.status === 410) { // Sync token invalidated — retry with full window try { const now = Date.now(); fetchResult = await fetchGoogleEvents(token, calendarId, { timeMin: new Date(now).toISOString(), timeMax: new Date(now + 90 * 86400_000).toISOString(), }); } catch (e2: any) { syncServer.changeDoc(cfgId, "gcal sync: fetch err", (d) => { d.googleAuth.lastSyncAt = Date.now(); d.googleAuth.lastSyncStatus = "error"; d.googleAuth.lastSyncError = `fetchEvents(after 410): ${e2?.message || String(e2)}`; }); return { ok: false, eventsUpdated: 0, deleted: 0, error: e2?.message }; } } else { syncServer.changeDoc(cfgId, "gcal sync: fetch err", (d) => { d.googleAuth.lastSyncAt = Date.now(); d.googleAuth.lastSyncStatus = "error"; d.googleAuth.lastSyncError = `fetchEvents: ${e?.message || String(e)}`; }); return { ok: false, eventsUpdated: 0, deleted: 0, error: e?.message }; } } let updated = 0; syncServer.changeDoc(cfgId, "gcal sync: apply", (d) => { for (const ev of fetchResult.events) { const cached = toCached(ev, calendarId); if (!cached) continue; d.googleEvents[cached.id] = cached; updated++; } for (const delId of fetchResult.deleted) { delete d.googleEvents[delId]; } // Trim past + oversized cache const now = Date.now(); const keys = Object.keys(d.googleEvents); if (keys.length > MAX_GCAL_EVENTS_CACHED) { const sorted = keys .map((k) => ({ k, t: d.googleEvents[k].endTime })) .sort((a, b) => a.t - b.t); const toDrop = sorted.slice(0, keys.length - MAX_GCAL_EVENTS_CACHED); for (const { k } of toDrop) delete d.googleEvents[k]; } // Drop cancelled events older than a day for (const [k, v] of Object.entries(d.googleEvents)) { if (v.endTime < now - 86400_000) delete d.googleEvents[k]; } d.googleAuth.syncToken = fetchResult.nextSyncToken; d.googleAuth.lastSyncAt = Date.now(); d.googleAuth.lastSyncStatus = "ok"; d.googleAuth.lastSyncError = null; d.googleAuth.connected = true; }); return { ok: true, eventsUpdated: updated, deleted: fetchResult.deleted.length }; } // ── Create booking event on Google Calendar ── export async function createGcalBookingEvent( space: string, syncServer: SyncServer, booking: Booking, opts: { hostEmail?: string; attendeeEmails?: string[] } = {}, ): Promise<{ ok: true; googleEventId: string } | { ok: false; error: string }> { const token = await getValidGoogleToken(space, syncServer).catch(() => null); if (!token) return { ok: false, error: "Not connected to Google" }; const cfg = syncServer.getDoc(scheduleConfigDocId(space)); const calendarId = cfg?.googleAuth.calendarIds[0] || "primary"; const attendees: Array<{ email: string; displayName?: string }> = []; if (booking.guestEmail) attendees.push({ email: booking.guestEmail, displayName: booking.guestName }); for (const email of opts.attendeeEmails || []) attendees.push({ email }); const input: GoogleEventInput = { summary: `${booking.guestName} ↔ ${booking.host.label || booking.host.id}`, description: booking.guestNote ? `${booking.guestNote}\n\n—\nBooked via rSchedule.` : "Booked via rSchedule.", start: { dateTime: new Date(booking.startTime).toISOString(), timeZone: booking.timezone }, end: { dateTime: new Date(booking.endTime).toISOString(), timeZone: booking.timezone }, }; try { // Note: our thin createGoogleEvent doesn't currently pass attendees + // conference request. That's fine for Phase D: event lands on host's // calendar and guests get invite via the confirmation email (Phase F). const id = await createGoogleEvent(token, calendarId, input); return { ok: true, googleEventId: id }; } catch (e: any) { return { ok: false, error: e?.message || String(e) }; } } // ── Delete booking event on Google Calendar ── export async function deleteGcalBookingEvent( space: string, syncServer: SyncServer, googleEventId: string, ): Promise<{ ok: boolean; error?: string }> { const token = await getValidGoogleToken(space, syncServer).catch(() => null); if (!token) return { ok: false, error: "Not connected to Google" }; const cfg = syncServer.getDoc(scheduleConfigDocId(space)); const calendarId = cfg?.googleAuth.calendarIds[0] || "primary"; try { const res = await fetch( `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(googleEventId)}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` } }, ); if (!res.ok && res.status !== 410 && res.status !== 404) { return { ok: false, error: `delete ${res.status}` }; } return { ok: true }; } catch (e: any) { return { ok: false, error: e?.message || String(e) }; } }