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
This commit is contained in:
parent
d991e7a65b
commit
f9c7893341
81
package-lock.json
generated
81
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.15.8",
|
||||
"version": "1.36.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.15.8",
|
||||
"version": "1.36.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
@ -15,7 +15,7 @@
|
||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||
"@fortawesome/react-fontawesome": "^3.2.0",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@prisma/client": "5.22.0",
|
||||
"@tiptap/extension-link": "^3.20.0",
|
||||
"@tiptap/extension-placeholder": "^3.20.0",
|
||||
"@tiptap/extension-underline": "^3.20.0",
|
||||
@ -28,17 +28,17 @@
|
||||
"googleapis": "^170.1.0",
|
||||
"ical.js": "^2.2.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "^14.0.0",
|
||||
"next": "14.2.35",
|
||||
"next-auth": "^4.24.13",
|
||||
"nodemailer": "^7.0.13",
|
||||
"postcss-cli": "^11.0.1",
|
||||
"prisma": "^5.0.0",
|
||||
"prisma": "5.22.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"tsdav": "^2.1.8",
|
||||
"undici": "^7.19.1",
|
||||
"web-push": "^3.6.7",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -3978,6 +3978,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types-flow": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||
@ -4270,6 +4282,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.3",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
@ -6618,6 +6636,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
@ -6720,7 +6747,6 @@
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
@ -9172,6 +9198,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@ -9189,7 +9221,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@ -11066,6 +11097,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
|
||||
@ -12180,15 +12217,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
||||
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
@ -12342,6 +12370,25 @@
|
||||
"makeerror": "1.0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.36.1",
|
||||
"version": "1.37.0",
|
||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@ -50,6 +50,7 @@
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"tsdav": "^2.1.8",
|
||||
"web-push": "^3.6.7",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -109,9 +109,12 @@ model User {
|
||||
calendarConnections CalendarConnection[]
|
||||
projects Project[]
|
||||
sessions Session[]
|
||||
notificationsEnabled Boolean @default(false)
|
||||
somedayLists SomedayList[]
|
||||
tasks Task[]
|
||||
weeklyGoals WeeklyGoal[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
sentNotifications SentNotification[]
|
||||
}
|
||||
|
||||
model Account {
|
||||
@ -253,6 +256,8 @@ model CachedCalendarEvent {
|
||||
connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
reminders Json?
|
||||
|
||||
@@unique([userId, externalId, provider])
|
||||
@@index([userId, startDateTime])
|
||||
@@index([userId, startDate])
|
||||
@ -287,3 +292,29 @@ model Project {
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model PushSubscription {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
endpoint String @unique
|
||||
p256dh String
|
||||
auth String
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model SentNotification {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
eventId String
|
||||
minutes Int
|
||||
sentAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, eventId, minutes])
|
||||
@@index([sentAt])
|
||||
}
|
||||
|
||||
BIN
public/icons/icon-192.png
Normal file
BIN
public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/icons/icon-512.png
Normal file
BIN
public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
51
public/sw.js
Normal file
51
public/sw.js
Normal file
@ -0,0 +1,51 @@
|
||||
// Push notification service worker
|
||||
self.addEventListener('push', function(event) {
|
||||
if (!event.data) return;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = event.data.json();
|
||||
} catch (e) {
|
||||
data = { title: 'Calendar Reminder', body: event.data.text() };
|
||||
}
|
||||
|
||||
const options = {
|
||||
body: data.body || '',
|
||||
icon: data.icon || '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192.png',
|
||||
data: { url: data.url || '/tasks' },
|
||||
vibrate: [200, 100, 200],
|
||||
tag: data.eventId || 'calendar-reminder',
|
||||
renotify: true,
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'Calendar Reminder', options)
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', function(event) {
|
||||
event.notification.close();
|
||||
|
||||
const url = event.notification.data?.url || '/tasks';
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(clientList) {
|
||||
// Focus existing window if available
|
||||
for (var i = 0; i < clientList.length; i++) {
|
||||
var client = clientList[i];
|
||||
if (client.url.includes('/tasks') && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Open new window
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(url);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function(event) {
|
||||
event.waitUntil(clients.claim());
|
||||
});
|
||||
9
scripts/generate-vapid-keys.ts
Normal file
9
scripts/generate-vapid-keys.ts
Normal file
@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
import webpush from 'web-push';
|
||||
|
||||
const vapidKeys = webpush.generateVAPIDKeys();
|
||||
|
||||
console.log('Add these to your .env file:\n');
|
||||
console.log(`NEXT_PUBLIC_VAPID_PUBLIC_KEY=${vapidKeys.publicKey}`);
|
||||
console.log(`VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);
|
||||
console.log(`VAPID_SUBJECT=mailto:admin@todo.martin-bierschenk.de`);
|
||||
196
scripts/push-scheduler.ts
Normal file
196
scripts/push-scheduler.ts
Normal file
@ -0,0 +1,196 @@
|
||||
#!/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);
|
||||
});
|
||||
78
src/app/api/push/subscribe/route.ts
Normal file
78
src/app/api/push/subscribe/route.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
// Subscribe to push notifications
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
try {
|
||||
const { endpoint, keys } = await request.json();
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||
return NextResponse.json({ error: 'Invalid subscription data' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: session.user.email } });
|
||||
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
|
||||
// Upsert subscription (endpoint is unique)
|
||||
await prisma.pushSubscription.upsert({
|
||||
where: { endpoint },
|
||||
create: {
|
||||
userId: user.id,
|
||||
endpoint,
|
||||
p256dh: keys.p256dh,
|
||||
auth: keys.auth,
|
||||
userAgent: request.headers.get('user-agent') || undefined,
|
||||
},
|
||||
update: {
|
||||
p256dh: keys.p256dh,
|
||||
auth: keys.auth,
|
||||
userAgent: request.headers.get('user-agent') || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Enable notifications for user
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { notificationsEnabled: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Push] Subscribe error:', error);
|
||||
return NextResponse.json({ error: 'Failed to subscribe' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe from push notifications
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
try {
|
||||
const { endpoint } = await request.json();
|
||||
if (!endpoint) return NextResponse.json({ error: 'Endpoint required' }, { status: 400 });
|
||||
|
||||
await prisma.pushSubscription.deleteMany({ where: { endpoint } });
|
||||
|
||||
// Check if user has any remaining subscriptions
|
||||
const user = await prisma.user.findUnique({ where: { email: session.user.email } });
|
||||
if (user) {
|
||||
const count = await prisma.pushSubscription.count({ where: { userId: user.id } });
|
||||
if (count === 0) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { notificationsEnabled: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Push] Unsubscribe error:', error);
|
||||
return NextResponse.json({ error: 'Failed to unsubscribe' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
66
src/app/api/push/test/route.ts
Normal file
66
src/app/api/push/test/route.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import webpush from 'web-push';
|
||||
|
||||
// Send a test notification to the current user
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
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) {
|
||||
return NextResponse.json({ error: 'VAPID keys not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey);
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { email: session.user.email } });
|
||||
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return NextResponse.json({ error: 'No push subscriptions found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
title: 'Test Notification',
|
||||
body: 'Push notifications are working!',
|
||||
icon: '/icons/icon-192.png',
|
||||
url: '/tasks',
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
for (const sub of subscriptions) {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
keys: { p256dh: sub.p256dh, auth: sub.auth },
|
||||
},
|
||||
payload
|
||||
);
|
||||
sent++;
|
||||
} catch (err: any) {
|
||||
// Remove stale subscriptions (410 Gone or 404)
|
||||
if (err.statusCode === 410 || err.statusCode === 404) {
|
||||
await prisma.pushSubscription.delete({ where: { id: sub.id } });
|
||||
}
|
||||
console.error('[Push] Test send failed for subscription:', sub.id, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, sent });
|
||||
} catch (error) {
|
||||
console.error('[Push] Test notification error:', error);
|
||||
return NextResponse.json({ error: 'Failed to send test notification' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -95,6 +95,7 @@ export async function GET(request: NextRequest) {
|
||||
accountNumber: true,
|
||||
lightTheme: true,
|
||||
darkTheme: true,
|
||||
notificationsEnabled: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
@ -137,7 +138,7 @@ export async function PATCH(request: NextRequest) {
|
||||
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
|
||||
showTaskCheckboxes, dayHeaderGap,
|
||||
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages,
|
||||
kanbanStages, lightTheme, darkTheme
|
||||
kanbanStages, lightTheme, darkTheme, notificationsEnabled
|
||||
} = body;
|
||||
|
||||
const updateData: any = {
|
||||
@ -221,6 +222,7 @@ export async function PATCH(request: NextRequest) {
|
||||
...(kanbanStages !== undefined && { kanbanStages }),
|
||||
...(lightTheme !== undefined && { lightTheme }),
|
||||
...(darkTheme !== undefined && { darkTheme }),
|
||||
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
||||
};
|
||||
if (password && password.trim() !== "") {
|
||||
updateData.passwordHash = await bcrypt.hash(password, 10);
|
||||
@ -313,6 +315,7 @@ export async function PATCH(request: NextRequest) {
|
||||
accountNumber: true,
|
||||
lightTheme: true,
|
||||
darkTheme: true,
|
||||
notificationsEnabled: true,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
17
src/app/manifest.ts
Normal file
17
src/app/manifest.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'My Weekly To Do List',
|
||||
short_name: 'Weekly ToDo',
|
||||
description: 'A weekly task management app with calendar integration',
|
||||
start_url: '/tasks',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#0ea5e9',
|
||||
icons: [
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
};
|
||||
}
|
||||
@ -1,7 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch((err) => {
|
||||
console.error('[SW] Registration failed:', err);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <SessionProvider refetchOnWindowFocus={false}>{children}</SessionProvider>;
|
||||
}
|
||||
|
||||
@ -10475,6 +10475,60 @@ function SettingsSidebar({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Push Notifications */}
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: "8px" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="notificationsEnabled"
|
||||
checked={profile.notificationsEnabled || false}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked;
|
||||
if (enabled) {
|
||||
try {
|
||||
const { isNotificationSupported, requestNotificationPermission, registerServiceWorker, subscribeToPush, sendSubscriptionToServer } = await import('@/lib/push-notifications');
|
||||
if (!isNotificationSupported()) {
|
||||
alert('Push notifications are not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
const permission = await requestNotificationPermission();
|
||||
if (permission !== 'granted') {
|
||||
alert('Notification permission was denied.');
|
||||
return;
|
||||
}
|
||||
const registration = await registerServiceWorker();
|
||||
if (!registration) { alert('Failed to register service worker.'); return; }
|
||||
const subscription = await subscribeToPush(registration);
|
||||
if (!subscription) { alert('Failed to subscribe to push notifications.'); return; }
|
||||
const sent = await sendSubscriptionToServer(subscription);
|
||||
if (!sent) { alert('Failed to save subscription.'); return; }
|
||||
setProfile({ ...profile, notificationsEnabled: true });
|
||||
} catch (err) {
|
||||
console.error('Push notification setup failed:', err);
|
||||
alert('Failed to enable notifications.');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const { unsubscribeFromPush } = await import('@/lib/push-notifications');
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
await unsubscribeFromPush(registration);
|
||||
} catch (err) {
|
||||
console.error('Unsubscribe failed:', err);
|
||||
}
|
||||
setProfile({ ...profile, notificationsEnabled: false });
|
||||
}
|
||||
}}
|
||||
style={{ width: "16px", height: "16px" }}
|
||||
/>
|
||||
<label
|
||||
htmlFor="notificationsEnabled"
|
||||
style={{ fontSize: "0.9rem", fontWeight: 600 }}
|
||||
>
|
||||
{(profile.language || "en") === "de" ? "Push-Benachrichtigungen" : "Push Notifications"}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: "8px" }}
|
||||
>
|
||||
|
||||
@ -127,6 +127,7 @@ export async function refreshConnectionCache(
|
||||
startDate: ev.start.date ?? null,
|
||||
endDateTime: ev.end.dateTime ? new Date(ev.end.dateTime) : null,
|
||||
endDate: ev.end.date ?? null,
|
||||
reminders: ev.reminders ? JSON.parse(JSON.stringify(ev.reminders)) : null,
|
||||
weekStart,
|
||||
syncedAt: now,
|
||||
}));
|
||||
@ -181,6 +182,7 @@ export async function upsertCachedEvent(
|
||||
startDate: event.start.date ?? null,
|
||||
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
|
||||
endDate: event.end.date ?? null,
|
||||
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
|
||||
weekStart,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
@ -197,6 +199,7 @@ export async function upsertCachedEvent(
|
||||
startDate: event.start.date ?? null,
|
||||
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
|
||||
endDate: event.end.date ?? null,
|
||||
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
|
||||
weekStart,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
|
||||
121
src/lib/push-notifications.ts
Normal file
121
src/lib/push-notifications.ts
Normal file
@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Client-side push notification helpers.
|
||||
* All functions are safe to call in the browser only.
|
||||
*/
|
||||
|
||||
export function isNotificationSupported(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
);
|
||||
}
|
||||
|
||||
export function getNotificationPermission(): NotificationPermission | 'unsupported' {
|
||||
if (!isNotificationSupported()) return 'unsupported';
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
export async function requestNotificationPermission(): Promise<NotificationPermission> {
|
||||
return Notification.requestPermission();
|
||||
}
|
||||
|
||||
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!('serviceWorker' in navigator)) return null;
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js');
|
||||
return registration;
|
||||
} catch (err) {
|
||||
console.error('[Push] Service worker registration failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
export async function subscribeToPush(
|
||||
registration: ServiceWorkerRegistration
|
||||
): Promise<PushSubscription | null> {
|
||||
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
||||
if (!vapidPublicKey) {
|
||||
console.error('[Push] VAPID public key not configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
});
|
||||
return subscription;
|
||||
} catch (err) {
|
||||
console.error('[Push] Subscribe failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendSubscriptionToServer(
|
||||
subscription: PushSubscription
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: btoa(String.fromCharCode(...new Uint8Array(subscription.getKey('p256dh')!))),
|
||||
auth: btoa(String.fromCharCode(...new Uint8Array(subscription.getKey('auth')!))),
|
||||
},
|
||||
}),
|
||||
});
|
||||
return res.ok;
|
||||
} catch (err) {
|
||||
console.error('[Push] Failed to send subscription to server:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unsubscribeFromPush(
|
||||
registration: ServiceWorkerRegistration
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) return true;
|
||||
|
||||
// Delete from server
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: subscription.endpoint }),
|
||||
});
|
||||
|
||||
// Unsubscribe locally
|
||||
await subscription.unsubscribe();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Push] Unsubscribe failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isCurrentlySubscribed(): Promise<boolean> {
|
||||
if (!isNotificationSupported()) return false;
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
return !!subscription;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,9 @@ export async function middleware(request: NextRequest) {
|
||||
request.nextUrl.pathname.startsWith('/auth') ||
|
||||
request.nextUrl.pathname.startsWith('/api/auth') || // CRITICAL: Allow NextAuth API routes
|
||||
request.nextUrl.pathname.startsWith('/_next') ||
|
||||
request.nextUrl.pathname === '/favicon.ico'
|
||||
request.nextUrl.pathname === '/favicon.ico' ||
|
||||
request.nextUrl.pathname === '/sw.js' ||
|
||||
request.nextUrl.pathname === '/manifest.webmanifest'
|
||||
) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user