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.
76 lines
2.5 KiB
JavaScript
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}`);
|