- 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
216 lines
8.0 KiB
TypeScript
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);
|
|
});
|