271 lines
10 KiB
TypeScript
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) };
|
|
}
|
|
}
|