/** * Sends the daily review reminder to every stored push subscription. * * Meant for cron or a systemd timer, e.g.: * 0 18 * * * cd /path/to/freezmo && node scripts/send-reminders.mjs * * Silently does nothing when VAPID keys are not configured (the whole * reminder feature stays hidden in the UI in that case). */ import { readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; import webpush from "web-push"; const dir = process.env.PUSH_DIR?.trim() || path.join(process.cwd(), ".freezmo", "push"); const publicKey = process.env.VAPID_PUBLIC_KEY?.trim(); const privateKey = process.env.VAPID_PRIVATE_KEY?.trim(); const subject = process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost"; const title = process.env.PUSH_REMINDER_TITLE?.trim() || "Time to review"; const body = process.env.PUSH_REMINDER_BODY?.trim() || "Your decks are waiting — a few due cards keep the streak alive."; if (!publicKey || !privateKey) { console.log("[freezmo] VAPID keys are not configured — nothing to send."); process.exit(0); } const file = path.join(dir, "subscriptions.json"); let subscriptions = []; try { subscriptions = JSON.parse(await readFile(file, "utf8")); } catch { subscriptions = []; } if (!Array.isArray(subscriptions) || subscriptions.length === 0) { console.log("[freezmo] no push subscriptions stored."); process.exit(0); } webpush.setVapidDetails(subject, publicKey, privateKey); let sent = 0; let removed = 0; const remaining = []; for (const entry of subscriptions) { try { await webpush.sendNotification( { endpoint: entry.endpoint, keys: entry.keys }, JSON.stringify({ title, body, url: "/" }), ); sent += 1; remaining.push(entry); } catch (error) { const status = error?.statusCode; if (status === 404 || status === 410) removed += 1; else remaining.push(entry); } } if (removed > 0) { const temporary = `${file}.${Date.now()}.tmp`; await writeFile(temporary, JSON.stringify(remaining, null, 2), "utf8"); await rename(temporary, file); } console.log(`[freezmo] reminders sent: ${sent}, dead subscriptions removed: ${removed}`);