My-Weekly-ToDo-List/scripts/push-scheduler.ts
mARTin a32e5601d0 fix: push scheduler handles all-day events and adds debug logging
- Query both timed (startDateTime) and all-day (startDate) events
- All-day events treated as starting at 07:00 UTC (~09:00 CET)
- Log number of events found per user for debugging
- Skip already-passed events to avoid unnecessary checks

v1.37.2
2026-03-16 22:22:58 +01:00

216 lines
8.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);
// 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);
});