#!/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); // Find events with reminders: both timed and all-day const events = await prisma.cachedCalendarEvent.findMany({ where: { userId: user.id, reminders: { not: null }, OR: [ { startDateTime: { gte: now, lte: windowEnd } }, { startDate: { not: null } }, ], }, select: { externalId: true, title: true, startDateTime: true, startDate: true, reminders: true, calendarTitle: true, }, }); console.log(`[Push Scheduler] Found ${events.length} events with reminders for user ${user.id}`); for (const event of events) { // Determine the event start time let eventStart: Date | null = event.startDateTime; if (!eventStart && event.startDate) { // All-day events: treat as starting at 09:00 local time (user timezone) // Parse the date string and set to 09:00 UTC as approximation // (for CET/CEST this means ~10:00/11:00 local, close enough for day-before reminders) eventStart = new Date(event.startDate + 'T07:00:00.000Z'); } if (!eventStart || !event.reminders) continue; // Skip events that already passed const maxReminderMinutes = 10080; // 1 week if (eventStart.getTime() + maxReminderMinutes * 60 * 1000 < now.getTime()) 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(eventStart.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 = eventStart.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); });