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 0000000..4694d29 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..42454fe Binary files /dev/null and b/public/icons/icon-512.png differ 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(); }