/** * rSchedule cron — internal tick loops for reminders and Google Calendar sync. * * Runs in-process (no external scheduler); follows the same pattern as * rCal's `startGcalSyncLoop()`. The user's original intent was to drive these * from rMinders, but rMinders' generic action types don't yet cover our * internal needs — keeping them here keeps the cron coupled to the module * that owns the data. rMinders still handles user-authored automations. * * Tick cadence: * - reminder sweep: every 5 minutes (checks window ±30 min around 24h mark) * - gcal sweep: every 10 minutes per connected space */ import type { SyncServer } from "../../../server/local-first/sync-server"; import { scheduleBookingsDocId, scheduleConfigDocId, type ScheduleBookingsDoc, type ScheduleConfigDoc, } from "../schemas"; import { syncGcalBusy } from "./gcal-sync"; import { sendBookingReminder } from "./emails"; const REMINDER_TICK_MS = 5 * 60 * 1000; const GCAL_TICK_MS = 10 * 60 * 1000; const REMINDER_WINDOW_START = 23 * 3600 * 1000; // 23h ahead const REMINDER_WINDOW_END = 25 * 3600 * 1000; // 25h ahead let _reminderTimer: ReturnType | null = null; let _gcalTimer: ReturnType | null = null; /** Build "https://" if the env hints at it — best-effort for email links. */ function hostBaseUrl(): string { return process.env.RSPACE_BASE_URL || "https://rspace.online"; } export function startRScheduleCron(syncServer: SyncServer): void { if (_reminderTimer) return; _reminderTimer = setInterval(() => void reminderSweep(syncServer).catch((e) => console.error("[rSchedule cron] reminder:", e?.message)), REMINDER_TICK_MS); _gcalTimer = setInterval(() => void gcalSweep(syncServer).catch((e) => console.error("[rSchedule cron] gcal:", e?.message)), GCAL_TICK_MS); // Run once on boot (small delay so syncServer.loadAllDocs has finished) setTimeout(() => { void reminderSweep(syncServer).catch(() => {}); void gcalSweep(syncServer).catch(() => {}); }, 15000); console.log("[rSchedule cron] started — 5min reminder + 10min gcal sweeps"); } export function stopRScheduleCron(): void { if (_reminderTimer) { clearInterval(_reminderTimer); _reminderTimer = null; } if (_gcalTimer) { clearInterval(_gcalTimer); _gcalTimer = null; } } // ── Sweeps ── async function reminderSweep(syncServer: SyncServer): Promise { const now = Date.now(); for (const docId of syncServer.listDocs()) { if (!docId.endsWith(":schedule:bookings")) continue; const space = docId.split(":")[0]; const doc = syncServer.getDoc(docId); if (!doc) continue; const cfg = syncServer.getDoc(scheduleConfigDocId(space)); const hostName = cfg?.settings.displayName || space; for (const b of Object.values(doc.bookings)) { if (b.status !== "confirmed") continue; if (b.reminderSentAt) continue; const delta = b.startTime - now; if (delta < REMINDER_WINDOW_START || delta > REMINDER_WINDOW_END) continue; const cancelUrl = `${hostBaseUrl()}/${space}/rschedule/cancel/${b.id}?token=${encodeURIComponent(b.cancelToken)}`; const result = await sendBookingReminder({ booking: b as any, settings: cfg?.settings || ({} as any), hostDisplayName: hostName, cancelUrl, }).catch((e) => ({ ok: false, error: e?.message })); if (result.ok) { syncServer.changeDoc(docId, `reminder sent ${b.id}`, (d) => { if (d.bookings[b.id]) d.bookings[b.id].reminderSentAt = Date.now(); }); } } } } async function gcalSweep(syncServer: SyncServer): Promise { for (const docId of syncServer.listDocs()) { if (!docId.endsWith(":schedule:config")) continue; const space = docId.split(":")[0]; const cfg = syncServer.getDoc(docId); if (!cfg?.googleAuth?.connected) continue; await syncGcalBusy(space, syncServer).catch(() => { /* per-space failures logged inside */ }); } }