import { prisma } from "@capakraken/db"; import { logger } from "./logger.js"; import { emitReminderDue, emitNotificationCreated } from "../sse/event-bus.js"; const POLL_INTERVAL_MS = 60_000; // 60 seconds let isProcessing = false; function computeNextRemindAt(current: Date, recurrence: string): Date { const next = new Date(current); switch (recurrence) { case "daily": next.setDate(next.getDate() + 1); break; case "weekly": next.setDate(next.getDate() + 7); break; case "monthly": next.setMonth(next.getMonth() + 1); break; default: return current; } return next; } function computeNextRemindAtAfter(current: Date, recurrence: string, now: Date): Date { let next = computeNextRemindAt(current, recurrence); if (next.getTime() <= current.getTime()) { return current; } while (next.getTime() <= now.getTime()) { const advanced = computeNextRemindAt(next, recurrence); if (advanced.getTime() <= next.getTime()) { return next; } next = advanced; } return next; } async function processReminders() { if (isProcessing) { logger.warn("Skipping reminder scheduler tick while previous run is still processing"); return; } isProcessing = true; const now = new Date(); try { // Find all due reminders const dueReminders = await prisma.notification.findMany({ where: { category: "REMINDER", nextRemindAt: { lte: now }, }, take: 100, // process in batches }); for (const reminder of dueReminders) { try { if (reminder.recurrence) { // Recurring: create a new notification for this occurrence, advance nextRemindAt const notification = await prisma.notification.create({ data: { userId: reminder.userId, category: "NOTIFICATION", type: "REMINDER_DUE", priority: reminder.priority, title: reminder.title, body: reminder.body, entityId: reminder.entityId, entityType: reminder.entityType, link: reminder.link, sourceId: reminder.id, channel: "in_app", }, }); // Advance to next occurrence await prisma.notification.update({ where: { id: reminder.id }, data: { nextRemindAt: computeNextRemindAtAfter( reminder.nextRemindAt!, reminder.recurrence, now, ), }, }); emitNotificationCreated(reminder.userId, notification.id); emitReminderDue(reminder.userId, notification.id); } else { // One-shot: mark the reminder as "fired" by clearing nextRemindAt await prisma.notification.update({ where: { id: reminder.id }, data: { nextRemindAt: null }, }); emitReminderDue(reminder.userId, reminder.id); emitNotificationCreated(reminder.userId, reminder.id); } } catch (err) { logger.error( { err, reminderId: reminder.id, userId: reminder.userId }, "Failed to process reminder", ); } } } finally { isProcessing = false; } } let intervalId: ReturnType | null = null; export function startReminderScheduler(): void { if (intervalId) return; // already running logger.info({ pollIntervalMs: POLL_INTERVAL_MS }, "Starting reminder scheduler"); // Run immediately to catch up on overdue reminders void processReminders(); intervalId = setInterval(() => void processReminders(), POLL_INTERVAL_MS); } export function stopReminderScheduler(): void { if (intervalId) { clearInterval(intervalId); intervalId = null; isProcessing = false; logger.info("Stopped reminder scheduler"); } }