From f9c78933419f807dc229838cdce2723badbe8b16 Mon Sep 17 00:00:00 2001 From: mARTin Date: Mon, 16 Mar 2026 21:38:15 +0100 Subject: [PATCH] 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 --- package-lock.json | 81 +++++++++--- package.json | 3 +- prisma/schema.prisma | 31 +++++ public/icons/icon-192.png | Bin 0 -> 3975 bytes public/icons/icon-512.png | Bin 0 -> 12674 bytes public/sw.js | 51 ++++++++ scripts/generate-vapid-keys.ts | 9 ++ scripts/push-scheduler.ts | 196 ++++++++++++++++++++++++++++ src/app/api/push/subscribe/route.ts | 78 +++++++++++ src/app/api/push/test/route.ts | 66 ++++++++++ src/app/api/user/profile/route.ts | 5 +- src/app/manifest.ts | 17 +++ src/app/providers.tsx | 9 ++ src/components/WeeklyView.tsx | 54 ++++++++ src/lib/calendar-cache.ts | 3 + src/lib/push-notifications.ts | 121 +++++++++++++++++ src/middleware.ts | 4 +- 17 files changed, 708 insertions(+), 20 deletions(-) create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/sw.js create mode 100644 scripts/generate-vapid-keys.ts create mode 100644 scripts/push-scheduler.ts create mode 100644 src/app/api/push/subscribe/route.ts create mode 100644 src/app/api/push/test/route.ts create mode 100644 src/app/manifest.ts create mode 100644 src/lib/push-notifications.ts diff --git a/package-lock.json b/package-lock.json index e823d92..4e72f04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2b7235d..a377237 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 479e9f4..c55ef5b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..4694d29636822a600e4d73fb22d6116f628ef999 GIT binary patch literal 3975 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 literal 0 HcmV?d00001 diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..42454fe7b43e34a0f37d85e5fa39aa7bfc89084b GIT binary patch literal 12674 zcmeHu_g7O}*KR;Pim0e4sI+rzh*U*ILJ7x)NR=vGMTpcWy$1^-f*>M6N`gp}ULqxw zphp3zp+jg_!GMJw!}!zukMDzwD6z#NXX>F!s-Oeg(%5pWSn%Vrbf~(pqVq#o>-=AFU+| z^&QeGYj4F#YZ?&#Fvv3_$K+2>oxZ-l_IgbE<_tzF<>_Bi*?(J}KY6o>*|clhajj){ z3ZifD&6%bzO-yPAX?k&DmkLAF%zKFB)-v&6yvekB!>d{o9BJ2u;C*1foe|K{58MWq zKaWFlxps{U{QB|?_;de(9pHNWBecJTt^mxYf4w1d&Ep`*jsaP zjM&hej8E;)A=xreQ@GU_OhA2dt7*FNV{L*gVfFQs6uubLF6@qDA>6R&yImBS-fs9Z z2fG@Y-to~pYbiJh;np{+iO>ne3iF7Z8tbl};Ezn~UJ}$C!b=L(h)=8`lQ=)WI*Q)1 zBG7YNZ|l1ZN=hOWDwgvxm7G5Ny&Ltbv9dGd$IL}ZMqlNE+T@v5wPWkD`#!yvrUz@+ zIM>r21hk8R4KtY@%5SPpy1#OF9O`qo94~ScY(`74TqmW9J34yi)TV2a4K6$#(5sv3 zwoNwZyh6+^$Sjj~-~I5yZkX27uiYu^F|N*!7u6 z_VAr=Y#kLp+FyzuDt`CL>TUglXrptl%+Koi0fEF*{29%dHr;B^#$OqYr7v3ac|Exk z10MD5jT1@6KaUckg_o>reScbKm-CSL!LKXFRQnsSuTAN$TmhnZ4@B{x zYA{%G#CzXt;X=QlExLE@(L!?^3&}e&Ny3%n`LOIY&BYwc&$?qSgd*oiKgPzC z-e#Ao$d^^D#I>4!I41#Wnzti367G)HD1muU+s(M1;j1Qz4+;vT&j5W#x+_UE}y+({BOy zTecsmo^s8^2g1E97l8g8{-U@nBDAD|uuW|}Z3Eh?(~1i|lqtAl%!s7Nq--4$#5l8D&+c>fb*c{!p(g!xHEdDFEVRvth zR;=Y83mrbNlo*LbU{^cU1C_nXe5@y>7G)HA3mev1oOgq*o!Egw+S?`nC1R`Tl%+C> zoK+Vn(tB>6JiiT`{e%H{4%1WcquIAU$RD!(`fVnu@|=yxMOx@syXWy?vOPa(|BhXn zTtO0r?3rZETancstVxsT>tJ(F<+*g6pzxtBsX9MG_$Hkn+@fy{oB{VLQOzUNP#Iq` z?+p24vVqzN^rm;lF@9$$K7q?$;%aK32XxWJJ%^YP6-Bw)73bZefjT;47t%? z6=PxOPxdBuE_RDP9q^fk638i5m;OmV5QhG61A0{mLqa;IsV|V!dMd5%DmX`?gaV_0 zwm`RbAvmvvw(;28}Egnds?!%@D%saD%oH@Z}K- @&}o^ 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 F{{Rw%E-3&2 literal 0 HcmV?d00001 diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..7fbe489 --- /dev/null +++ b/public/sw.js @@ -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()); +}); diff --git a/scripts/generate-vapid-keys.ts b/scripts/generate-vapid-keys.ts new file mode 100644 index 0000000..0b86e8e --- /dev/null +++ b/scripts/generate-vapid-keys.ts @@ -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`); diff --git a/scripts/push-scheduler.ts b/scripts/push-scheduler.ts new file mode 100644 index 0000000..1c2acf8 --- /dev/null +++ b/scripts/push-scheduler.ts @@ -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); +}); diff --git a/src/app/api/push/subscribe/route.ts b/src/app/api/push/subscribe/route.ts new file mode 100644 index 0000000..1860120 --- /dev/null +++ b/src/app/api/push/subscribe/route.ts @@ -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 }); + } +} diff --git a/src/app/api/push/test/route.ts b/src/app/api/push/test/route.ts new file mode 100644 index 0000000..630e36a --- /dev/null +++ b/src/app/api/push/test/route.ts @@ -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 }); + } +} diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index eda10f2..2c9b6dd 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -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, } }); diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..4cc4bd7 --- /dev/null +++ b/src/app/manifest.ts @@ -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' }, + ], + }; +} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index cc69565..1b709ce 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -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 {children}; } diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 23f14b1..c9e8a9f 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -10475,6 +10475,60 @@ function SettingsSidebar({ + {/* Push Notifications */} +
+ { + 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" }} + /> + +
+
diff --git a/src/lib/calendar-cache.ts b/src/lib/calendar-cache.ts index 8fe7808..8928639 100644 --- a/src/lib/calendar-cache.ts +++ b/src/lib/calendar-cache.ts @@ -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(), }, diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts new file mode 100644 index 0000000..b48d638 --- /dev/null +++ b/src/lib/push-notifications.ts @@ -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 { + return Notification.requestPermission(); +} + +export async function registerServiceWorker(): Promise { + 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 { + 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 { + 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 { + 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 { + if (!isNotificationSupported()) return false; + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + return !!subscription; + } catch { + return false; + } +} diff --git a/src/middleware.ts b/src/middleware.ts index 91ceb80..9200cc0 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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(); }