My-Weekly-ToDo-List/scripts/push-scheduler.ts
mARTin f9c7893341 feat: add push notifications for calendar event reminders
- Service worker + Web Push API for browser/mobile notifications
- PWA manifest with icons for home screen install (iOS 16.4+)
- Push subscription management (subscribe/unsubscribe API routes)
- Notification toggle in Settings > General
- Push scheduler script (cron) checks cached events and sends
  notifications at reminder times, with dedup tracking
- Test notification endpoint at /api/push/test
- Persists reminders in calendar event cache for scheduler access
- New DB models: PushSubscription, SentNotification
- New user field: notificationsEnabled

Setup: generate VAPID keys with `npx tsx scripts/generate-vapid-keys.ts`
and add to .env, then run scheduler via cron every minute.

v1.37.0
2026-03-16 21:38:15 +01:00

197 lines
7.0 KiB
TypeScript

#!/usr/bin/env npx tsx
/**
* Push Notification Scheduler
*
* Run this as a cron job every minute:
* * * * * * cd /path/to/app && npx tsx scripts/push-scheduler.ts
*
* Or via PM2:
* pm2 start scripts/push-scheduler.ts --interpreter="npx" --interpreter-args="tsx" --cron-restart="* * * * *" --no-autorestart
*
* What it does:
* 1. Finds all users with notificationsEnabled=true
* 2. For each user, checks cached calendar events with reminders
* 3. If an event's reminder time matches "now" (within 1-minute window), sends a push notification
* 4. Tracks sent notifications to avoid duplicates
*/
import { PrismaClient } from '@prisma/client';
import webpush from 'web-push';
const prisma = new PrismaClient();
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY;
const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:admin@todo.martin-bierschenk.de';
if (!vapidPublicKey || !vapidPrivateKey) {
console.error('[Push Scheduler] VAPID keys not configured. Set NEXT_PUBLIC_VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY in .env');
process.exit(1);
}
webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey);
interface Reminder {
method: string;
minutes: number;
}
async function run() {
const now = new Date();
console.log(`[Push Scheduler] Running at ${now.toISOString()}`);
// Find users with notifications enabled
const users = await prisma.user.findMany({
where: { notificationsEnabled: true },
select: {
id: true,
timezone: true,
pushSubscriptions: true,
},
});
if (users.length === 0) {
console.log('[Push Scheduler] No users with notifications enabled');
await prisma.$disconnect();
return;
}
console.log(`[Push Scheduler] Checking ${users.length} users`);
for (const user of users) {
if (user.pushSubscriptions.length === 0) continue;
// Get upcoming events with reminders (next 7 days)
const windowStart = new Date(now);
const windowEnd = new Date(now);
windowEnd.setDate(windowEnd.getDate() + 7);
const events = await prisma.cachedCalendarEvent.findMany({
where: {
userId: user.id,
startDateTime: { gte: now, lte: windowEnd },
reminders: { not: null },
},
select: {
externalId: true,
title: true,
startDateTime: true,
reminders: true,
calendarTitle: true,
},
});
for (const event of events) {
if (!event.startDateTime || !event.reminders) continue;
let reminders: Reminder[] = [];
try {
reminders = event.reminders as unknown as Reminder[];
if (!Array.isArray(reminders)) continue;
} catch {
continue;
}
for (const reminder of reminders) {
if (reminder.minutes < 0) continue;
// Calculate when notification should fire
const notifyAt = new Date(event.startDateTime.getTime() - reminder.minutes * 60 * 1000);
// Check if we're within the 1-minute window
const diffMs = Math.abs(now.getTime() - notifyAt.getTime());
if (diffMs > 60 * 1000) continue; // Outside window
// Check if already sent
const alreadySent = await prisma.sentNotification.findUnique({
where: {
userId_eventId_minutes: {
userId: user.id,
eventId: event.externalId,
minutes: reminder.minutes,
},
},
});
if (alreadySent) continue;
// Build notification payload
const timeStr = event.startDateTime.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
const body = reminder.minutes === 0
? `Starting now at ${timeStr}`
: reminder.minutes < 60
? `In ${reminder.minutes} minutes (${timeStr})`
: reminder.minutes < 1440
? `In ${Math.round(reminder.minutes / 60)} hour(s) (${timeStr})`
: `In ${Math.round(reminder.minutes / 1440)} day(s) (${timeStr})`;
const payload = JSON.stringify({
title: event.title || 'Calendar Event',
body,
icon: '/icons/icon-192.png',
url: '/tasks',
eventId: event.externalId,
});
// Send to all user's subscriptions
let sentOk = false;
for (const sub of user.pushSubscriptions) {
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: { p256dh: sub.p256dh, auth: sub.auth },
},
payload
);
sentOk = true;
console.log(`[Push Scheduler] Sent: "${event.title}" to user ${user.id}`);
} catch (err: any) {
if (err.statusCode === 410 || err.statusCode === 404) {
// Subscription expired, remove it
await prisma.pushSubscription.delete({ where: { id: sub.id } }).catch(() => { });
console.log(`[Push Scheduler] Removed stale subscription ${sub.id}`);
} else {
console.error(`[Push Scheduler] Send failed:`, err.message);
}
}
}
// Record as sent
if (sentOk) {
await prisma.sentNotification.create({
data: {
userId: user.id,
eventId: event.externalId,
minutes: reminder.minutes,
},
});
}
}
}
}
// Cleanup old sent notifications (older than 7 days)
const weekAgo = new Date(now);
weekAgo.setDate(weekAgo.getDate() - 7);
const deleted = await prisma.sentNotification.deleteMany({
where: { sentAt: { lt: weekAgo } },
});
if (deleted.count > 0) {
console.log(`[Push Scheduler] Cleaned up ${deleted.count} old sent notifications`);
}
await prisma.$disconnect();
console.log('[Push Scheduler] Done');
}
run().catch((err) => {
console.error('[Push Scheduler] Fatal error:', err);
prisma.$disconnect();
process.exit(1);
});