rspace-online/modules/rschedule/lib/cron.ts

100 lines
3.9 KiB
TypeScript

/**
* 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<typeof setInterval> | null = null;
let _gcalTimer: ReturnType<typeof setInterval> | null = null;
/** Build "https://<host>" 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<void> {
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<ScheduleBookingsDoc>(docId);
if (!doc) continue;
const cfg = syncServer.getDoc<ScheduleConfigDoc>(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<ScheduleBookingsDoc>(docId, `reminder sent ${b.id}`, (d) => {
if (d.bookings[b.id]) d.bookings[b.id].reminderSentAt = Date.now();
});
}
}
}
}
async function gcalSweep(syncServer: SyncServer): Promise<void> {
for (const docId of syncServer.listDocs()) {
if (!docId.endsWith(":schedule:config")) continue;
const space = docId.split(":")[0];
const cfg = syncServer.getDoc<ScheduleConfigDoc>(docId);
if (!cfg?.googleAuth?.connected) continue;
await syncGcalBusy(space, syncServer).catch(() => { /* per-space failures logged inside */ });
}
}