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",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.15.8",
|
"version": "1.36.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.15.8",
|
"version": "1.36.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
@ -15,7 +15,7 @@
|
|||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.2.0",
|
"@fortawesome/react-fontawesome": "^3.2.0",
|
||||||
"@next-auth/prisma-adapter": "^1.0.7",
|
"@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-link": "^3.20.0",
|
||||||
"@tiptap/extension-placeholder": "^3.20.0",
|
"@tiptap/extension-placeholder": "^3.20.0",
|
||||||
"@tiptap/extension-underline": "^3.20.0",
|
"@tiptap/extension-underline": "^3.20.0",
|
||||||
@ -28,17 +28,17 @@
|
|||||||
"googleapis": "^170.1.0",
|
"googleapis": "^170.1.0",
|
||||||
"ical.js": "^2.2.1",
|
"ical.js": "^2.2.1",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "^14.0.0",
|
"next": "14.2.35",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^7.0.13",
|
||||||
"postcss-cli": "^11.0.1",
|
"postcss-cli": "^11.0.1",
|
||||||
"prisma": "^5.0.0",
|
"prisma": "5.22.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"tsdav": "^2.1.8",
|
"tsdav": "^2.1.8",
|
||||||
"undici": "^7.19.1",
|
"web-push": "^3.6.7",
|
||||||
"zustand": "^4.4.0"
|
"zustand": "^4.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -3978,6 +3978,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/ast-types-flow": {
|
||||||
"version": "0.0.8",
|
"version": "0.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
"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"
|
"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": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
@ -6618,6 +6636,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/https-proxy-agent": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
@ -6720,7 +6747,6 @@
|
|||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
@ -9172,6 +9198,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@ -9189,7 +9221,6 @@
|
|||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@ -11066,6 +11097,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/sax": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
|
||||||
@ -12180,15 +12217,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
@ -12342,6 +12370,25 @@
|
|||||||
"makeerror": "1.0.12"
|
"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": {
|
"node_modules/web-streams-polyfill": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"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",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -50,6 +50,7 @@
|
|||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"tsdav": "^2.1.8",
|
"tsdav": "^2.1.8",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zustand": "^4.4.0"
|
"zustand": "^4.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -109,9 +109,12 @@ model User {
|
|||||||
calendarConnections CalendarConnection[]
|
calendarConnections CalendarConnection[]
|
||||||
projects Project[]
|
projects Project[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
notificationsEnabled Boolean @default(false)
|
||||||
somedayLists SomedayList[]
|
somedayLists SomedayList[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
weeklyGoals WeeklyGoal[]
|
weeklyGoals WeeklyGoal[]
|
||||||
|
pushSubscriptions PushSubscription[]
|
||||||
|
sentNotifications SentNotification[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
@ -253,6 +256,8 @@ model CachedCalendarEvent {
|
|||||||
connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
reminders Json?
|
||||||
|
|
||||||
@@unique([userId, externalId, provider])
|
@@unique([userId, externalId, provider])
|
||||||
@@index([userId, startDateTime])
|
@@index([userId, startDateTime])
|
||||||
@@index([userId, startDate])
|
@@index([userId, startDate])
|
||||||
@ -287,3 +292,29 @@ model Project {
|
|||||||
|
|
||||||
@@index([userId])
|
@@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,
|
accountNumber: true,
|
||||||
lightTheme: true,
|
lightTheme: true,
|
||||||
darkTheme: true,
|
darkTheme: true,
|
||||||
|
notificationsEnabled: true,
|
||||||
createdAt: true
|
createdAt: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -137,7 +138,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
|
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
|
||||||
showTaskCheckboxes, dayHeaderGap,
|
showTaskCheckboxes, dayHeaderGap,
|
||||||
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages,
|
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages,
|
||||||
kanbanStages, lightTheme, darkTheme
|
kanbanStages, lightTheme, darkTheme, notificationsEnabled
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
const updateData: any = {
|
const updateData: any = {
|
||||||
@ -221,6 +222,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
...(kanbanStages !== undefined && { kanbanStages }),
|
...(kanbanStages !== undefined && { kanbanStages }),
|
||||||
...(lightTheme !== undefined && { lightTheme }),
|
...(lightTheme !== undefined && { lightTheme }),
|
||||||
...(darkTheme !== undefined && { darkTheme }),
|
...(darkTheme !== undefined && { darkTheme }),
|
||||||
|
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
||||||
};
|
};
|
||||||
if (password && password.trim() !== "") {
|
if (password && password.trim() !== "") {
|
||||||
updateData.passwordHash = await bcrypt.hash(password, 10);
|
updateData.passwordHash = await bcrypt.hash(password, 10);
|
||||||
@ -313,6 +315,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
accountNumber: true,
|
accountNumber: true,
|
||||||
lightTheme: true,
|
lightTheme: true,
|
||||||
darkTheme: 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';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { SessionProvider } from 'next-auth/react';
|
import { SessionProvider } from 'next-auth/react';
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
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>;
|
return <SessionProvider refetchOnWindowFocus={false}>{children}</SessionProvider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10475,6 +10475,60 @@ function SettingsSidebar({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{ display: "flex", alignItems: "center", gap: "8px" }}
|
style={{ display: "flex", alignItems: "center", gap: "8px" }}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -127,6 +127,7 @@ export async function refreshConnectionCache(
|
|||||||
startDate: ev.start.date ?? null,
|
startDate: ev.start.date ?? null,
|
||||||
endDateTime: ev.end.dateTime ? new Date(ev.end.dateTime) : null,
|
endDateTime: ev.end.dateTime ? new Date(ev.end.dateTime) : null,
|
||||||
endDate: ev.end.date ?? null,
|
endDate: ev.end.date ?? null,
|
||||||
|
reminders: ev.reminders ? JSON.parse(JSON.stringify(ev.reminders)) : null,
|
||||||
weekStart,
|
weekStart,
|
||||||
syncedAt: now,
|
syncedAt: now,
|
||||||
}));
|
}));
|
||||||
@ -181,6 +182,7 @@ export async function upsertCachedEvent(
|
|||||||
startDate: event.start.date ?? null,
|
startDate: event.start.date ?? null,
|
||||||
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
|
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
|
||||||
endDate: event.end.date ?? null,
|
endDate: event.end.date ?? null,
|
||||||
|
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
|
||||||
weekStart,
|
weekStart,
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
@ -197,6 +199,7 @@ export async function upsertCachedEvent(
|
|||||||
startDate: event.start.date ?? null,
|
startDate: event.start.date ?? null,
|
||||||
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
|
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
|
||||||
endDate: event.end.date ?? null,
|
endDate: event.end.date ?? null,
|
||||||
|
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
|
||||||
weekStart,
|
weekStart,
|
||||||
syncedAt: new Date(),
|
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('/auth') ||
|
||||||
request.nextUrl.pathname.startsWith('/api/auth') || // CRITICAL: Allow NextAuth API routes
|
request.nextUrl.pathname.startsWith('/api/auth') || // CRITICAL: Allow NextAuth API routes
|
||||||
request.nextUrl.pathname.startsWith('/_next') ||
|
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();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user