rspace-online/server/google-calendar.ts

333 lines
9.6 KiB
TypeScript

/**
* 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<string | null> {
const docId = connectionsDocId(space);
const doc = syncServer.getDoc<ConnectionsDoc>(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<ConnectionsDoc>(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<any> {
const res = await fetch(url, {
...opts,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
...((opts.headers as Record<string, string>) || {}),
},
});
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<GoogleCalendarEntry[]> {
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<FetchEventsResult> {
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<string> {
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<CalendarEvent> {
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,
},
};
}