333 lines
9.6 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|