freezmo/scripts/send-reminders.mjs
Martin Dvorak 77819b5961 Push reminders: personal to the notes, generic without them
The daily reminder said one generic line to everyone, because the server
never sees a deck. Now the notification text is written in the browser
(lib/reminder.ts) and travels with the subscription:

- Personal: "3 cards are due in Sky deck", in the app's language, tapping
  through to /review. Without notes (or with nothing due) it is the plain
  nudge, translated in both languages — push.title / push.due.* / push.generic.
- POST /api/push accepts an optional message {title, body, url}; url must be
  a path on this app, so a stolen subscription cannot make the notification
  link elsewhere. The store keeps endpoint, keys, the message and createdAt
  (a refresh no longer resets the timestamp).
- components/PushReminderSync.tsx refreshes the stored text when the app
  opens and whenever the tab becomes visible (throttled to twice a day), so a
  count is only ever sent while it is true.
- scripts/send-reminders.mjs sends each device its own text and falls back to
  PUSH_REMINDER_TITLE/_BODY for a subscription that has none
  (scripts/reminder-payload.mjs, unit-checked).
- /api/push/test previews the real reminder with a "Test:" prefix.
- deploy/freezmo-reminders.{service,timer}: the daily send as a systemd user
  timer (18:00 local, Persistent, randomised), documented in the README.
- Settings copy, privacy page (en+cs) and .env.example say exactly what the
  server now holds.
- phase2-test grew the whole path with a faked browser subscription: personal
  text in en and cs, the generic fallback in both, the URL guard, and the
  sender's preference order (81 checks).

Version 0.8.2, SW v104.
2026-10-18 14:34:15 +00:00

76 lines
2.5 KiB
JavaScript

/**
* Sends the daily review reminder to every stored push subscription.
*
* Meant for cron or a systemd timer (deploy/freezmo-reminders.timer):
* 0 18 * * * cd /path/to/freezmo && node scripts/send-reminders.mjs
*
* Each subscription carries the notification text its own browser composed —
* personal (how many cards are due, in which deck) and in the user's language.
* Subscriptions without one (stored before that, or a browser that has not
* synced) get the generic fallback from PUSH_REMINDER_TITLE / _BODY.
*
* 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";
import { reminderPayload } from "./reminder-payload.mjs";
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 defaults = {
title: process.env.PUSH_REMINDER_TITLE,
body: process.env.PUSH_REMINDER_BODY,
url: process.env.PUSH_REMINDER_URL,
};
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 {
const payload = reminderPayload(entry, defaults);
await webpush.sendNotification(
{ endpoint: entry.endpoint, keys: entry.keys },
JSON.stringify(payload),
);
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}`);