rspace-online/modules/rschedule/lib/gcal-sync.ts

271 lines
10 KiB
TypeScript

/**
* 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<ConnectionsDoc>(connectionsDocId(space));
const cfg = syncServer.getDoc<ScheduleConfigDoc>(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<ScheduleConfigDoc>(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<ScheduleConfigDoc>(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<ScheduleConfigDoc>(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<ScheduleConfigDoc>(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<ScheduleConfigDoc>(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<ScheduleConfigDoc>(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<ScheduleConfigDoc>(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<ScheduleConfigDoc>(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<ScheduleConfigDoc>(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) };
}
}