import { and, eq, inArray, isNull, lte, sql } from "drizzle-orm"; import { db } from "@/lib/db"; import logger from "@/lib/logger"; import { fireAtForEventStart } from "@/lib/reminder-offsets"; import { reminders } from "./schema"; import { notify } from "./notify"; const REMINDER_LOCK_KEY = 7_777_777; type ReminderInput = { fireAt: Date; offsetMinutes?: number | null; title?: string; body?: string; }; export async function scheduleReminder(input: { householdId: string; entityType: string; entityId: string; fireAt: Date; createdBy: string; channel?: string; title?: string; body?: string; offsetMinutes?: number | null; }) { await db .delete(reminders) .where( and( eq(reminders.entityType, input.entityType), eq(reminders.entityId, input.entityId), isNull(reminders.firedAt), ), ); await db.insert(reminders).values({ householdId: input.householdId, entityType: input.entityType, entityId: input.entityId, fireAt: input.fireAt, offsetMinutes: input.offsetMinutes ?? null, channel: input.channel ?? "auto", title: input.title ?? null, body: input.body ?? null, createdBy: input.createdBy, firedAt: null, }); } export async function syncRemindersForEntity(input: { householdId: string; entityType: string; entityId: string; createdBy: string; channel?: string; reminders: ReminderInput[]; }) { await db .delete(reminders) .where( and( eq(reminders.entityType, input.entityType), eq(reminders.entityId, input.entityId), isNull(reminders.firedAt), ), ); const now = new Date(); const rows = input.reminders.filter((r) => r.fireAt > now); if (rows.length === 0) return; await db.insert(reminders).values( rows.map((row) => ({ householdId: input.householdId, entityType: input.entityType, entityId: input.entityId, fireAt: row.fireAt, offsetMinutes: row.offsetMinutes ?? null, channel: input.channel ?? "auto", title: row.title ?? null, body: row.body ?? null, createdBy: input.createdBy, firedAt: null, })), ); } export async function syncCalendarEventReminders(input: { householdId: string; eventId: string; eventTitle: string; startAt: Date; createdBy: string; offsetMinutes: number[]; }) { const remindersToSchedule = input.offsetMinutes.map((offset) => ({ fireAt: fireAtForEventStart(input.startAt, offset), offsetMinutes: offset, title: input.eventTitle, body: formatReminderOffsetBody(offset, input.eventTitle), })); await syncRemindersForEntity({ householdId: input.householdId, entityType: "calendar.event", entityId: input.eventId, createdBy: input.createdBy, reminders: remindersToSchedule, }); } export async function cancelReminder(entityType: string, entityId: string) { await db .delete(reminders) .where(and(eq(reminders.entityType, entityType), eq(reminders.entityId, entityId))); } export async function listReminders(entityType: string, entityId: string) { return db .select() .from(reminders) .where(and(eq(reminders.entityType, entityType), eq(reminders.entityId, entityId))); } export async function listReminderOffsets(entityType: string, entityId: string): Promise { const rows = await listReminders(entityType, entityId); return rows .filter((row) => row.offsetMinutes != null && row.firedAt == null) .map((row) => row.offsetMinutes!) .toSorted((a, b) => b - a); } function formatReminderOffsetBody(offsetMinutes: number, title: string): string { if (offsetMinutes === 0) return `${title} is starting now`; if (offsetMinutes % 1440 === 0) { const days = offsetMinutes / 1440; return days === 1 ? `${title} starts in 1 day` : `${title} starts in ${days} days`; } if (offsetMinutes % 60 === 0) { const hours = offsetMinutes / 60; return hours === 1 ? `${title} starts in 1 hour` : `${title} starts in ${hours} hours`; } return offsetMinutes === 1 ? `${title} starts in 1 minute` : `${title} starts in ${offsetMinutes} minutes`; } async function tickReminders() { let dueReminders: (typeof reminders.$inferSelect)[] = []; try { await db.transaction(async (tx) => { const lockRows = await tx.execute<{ acquired: boolean }>( sql`SELECT pg_try_advisory_xact_lock(${REMINDER_LOCK_KEY}) AS acquired`, ); if (!lockRows[0]?.acquired) return; const now = new Date(); dueReminders = await tx .select() .from(reminders) .where(and(lte(reminders.fireAt, now), isNull(reminders.firedAt))); if (dueReminders.length > 0) { await tx .update(reminders) .set({ firedAt: now }) .where( inArray( reminders.id, dueReminders.map((r) => r.id), ), ); } }); } catch (err) { logger.error({ err }, "reminder tick error"); return; } await Promise.allSettled( dueReminders.map(async (reminder) => { if (!reminder.createdBy) return; try { await notify(reminder.createdBy, { title: reminder.title ?? "Reminder", body: reminder.body ?? "You have a reminder", url: reminder.entityType === "notes.note" ? `/notes/${reminder.entityId}` : reminder.entityType === "calendar.event" ? "/calendar" : "/", channels: ["push", "inapp"], }); } catch (err) { logger.error({ reminderId: reminder.id, err }, "reminder delivery failed"); } }), ); } let workerTimer: ReturnType | null = null; export function startReminderWorker() { if (workerTimer) return; workerTimer = setInterval(() => { tickReminders().catch((err) => logger.error({ err }, "reminder worker uncaught error")); }, 30_000); logger.info("reminder worker started (30s tick)"); }