From bfcd9c3da03d4478d58f5f9d5df7a90e7c9819f2 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Feb 2026 12:16:27 +0500 Subject: [PATCH] PWA and push notifications --- package-lock.json | 115 +++++++++++++++++- package.json | 4 +- prisma/schema.prisma | 16 +++ public/sw.js | 25 ++++ src/app/api/chat/messages/route.ts | 12 ++ src/app/api/notifications/subscribe/route.ts | 66 ++++++++++ .../api/supplier/invitations/accept/route.ts | 4 +- .../suppliers/[supplierId]/invite/route.ts | 25 +--- src/app/layout.tsx | 12 +- src/app/manifest.ts | 30 +++++ src/components/PWARegistration.tsx | 22 ++++ src/hooks/usePushNotifications.ts | 104 ++++++++++++++++ src/lib/push.ts | 80 ++++++++++++ 13 files changed, 488 insertions(+), 27 deletions(-) create mode 100644 public/sw.js create mode 100644 src/app/api/notifications/subscribe/route.ts create mode 100644 src/app/manifest.ts create mode 100644 src/components/PWARegistration.tsx create mode 100644 src/hooks/usePushNotifications.ts create mode 100644 src/lib/push.ts diff --git a/package-lock.json b/package-lock.json index a3567ff..7354978 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "react-hot-toast": "^2.6.0", "recharts": "^3.7.0", "tailwind-merge": "^3.4.0", + "web-push": "^3.6.7", "zod": "^3.23.8" }, "devDependencies": { @@ -48,6 +49,7 @@ "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", + "@types/web-push": "^3.6.4", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", @@ -3860,6 +3862,15 @@ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.55.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", @@ -4424,6 +4435,14 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4629,6 +4648,17 @@ "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==", + "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", @@ -4766,6 +4796,11 @@ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==" }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==" + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -4826,6 +4861,11 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/c12": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", @@ -5265,7 +5305,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "dependencies": { "ms": "^2.1.3" }, @@ -5420,6 +5459,14 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/effect": { "version": "3.18.4", "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", @@ -6676,11 +6723,31 @@ "node": ">=16.9.0" } }, + "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==", + "engines": { + "node": ">=16" + } + }, "node_modules/http-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==" }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -7302,6 +7369,25 @@ "setimmediate": "^1.0.5" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7782,6 +7868,11 @@ "node": ">=8.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==" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -7798,7 +7889,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -7819,8 +7909,7 @@ "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/mysql2": { "version": "3.15.3", @@ -10038,6 +10127,24 @@ "d3-timer": "^3.0.1" } }, + "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==", + "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/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index aec8834..57bc159 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "react-hot-toast": "^2.6.0", "recharts": "^3.7.0", "tailwind-merge": "^3.4.0", + "web-push": "^3.6.7", "zod": "^3.23.8" }, "devDependencies": { @@ -50,6 +51,7 @@ "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", + "@types/web-push": "^3.6.4", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", @@ -63,4 +65,4 @@ "> 0.5%", "not ie 11" ] -} \ No newline at end of file +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3084c1d..e8d4324 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,6 +39,7 @@ model User { notifications Notification[] gmailIntegration GmailIntegration? emailLogs EmailLog[] + pushSubscriptions PushSubscription[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -527,3 +528,18 @@ model ChatMessage { @@index([senderId, receiverId]) @@index([receiverId, senderId]) } +model PushSubscription { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + endpoint String @unique + p256dh String // Public key for encryption + auth String // Authentication secret + + userAgent String? // Optional: device/browser info + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..04defb9 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,25 @@ +self.addEventListener('push', function (event) { + if (event.data) { + const data = event.data.json() + const options = { + body: data.body, + icon: '/icon-192.png', + badge: '/badge.png', + data: { + url: data.url || '/' + }, + actions: data.actions || [] + } + + event.waitUntil( + self.registration.showNotification(data.title, options) + ) + } +}) + +self.addEventListener('notificationclick', function (event) { + event.notification.close() + event.waitUntil( + clients.openWindow(event.notification.data.url) + ) +}) diff --git a/src/app/api/chat/messages/route.ts b/src/app/api/chat/messages/route.ts index 0eed265..c37ced0 100644 --- a/src/app/api/chat/messages/route.ts +++ b/src/app/api/chat/messages/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" +import { sendPushNotification } from "@/lib/push" export async function POST(req: Request) { try { @@ -26,6 +27,17 @@ export async function POST(req: Request) { } }) + // Send push notification to receiver + try { + await sendPushNotification(receiverId, { + title: "New Encrypted Message", + body: `${session.user.name || "A user"} sent you a secure message.`, + url: "/dashboard/chat" // Correct to your chat dashboard path + }) + } catch (pushError) { + console.error("Failed to send push notification", pushError) + } + return NextResponse.json(message) } catch (error) { console.error("[CHAT_MESSAGES_POST]", error) diff --git a/src/app/api/notifications/subscribe/route.ts b/src/app/api/notifications/subscribe/route.ts new file mode 100644 index 0000000..1c36a98 --- /dev/null +++ b/src/app/api/notifications/subscribe/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { auth } from '@/auth' +import { prisma } from '@/lib/prisma' +import { handleApiError } from '@/lib/errors' + +export async function POST(req: Request) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }) + } + + const subscription = await req.json() + + if (!subscription || !subscription.endpoint || !subscription.keys) { + return NextResponse.json({ message: 'Invalid subscription data' }, { status: 400 }) + } + + // Upsert the subscription + await prisma.pushSubscription.upsert({ + where: { + endpoint: subscription.endpoint + }, + update: { + userId: session.user.id, + p256dh: subscription.keys.p256dh, + auth: subscription.keys.auth, + userAgent: req.headers.get('user-agent') || null + }, + create: { + userId: session.user.id, + endpoint: subscription.endpoint, + p256dh: subscription.keys.p256dh, + auth: subscription.keys.auth, + userAgent: req.headers.get('user-agent') || null + } + }) + + return NextResponse.json({ success: true }) + } catch (error) { + return handleApiError(error, 'push-subscribe') + } +} + +export async function DELETE(req: Request) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }) + } + + const { endpoint } = await req.json() + + if (!endpoint) { + return NextResponse.json({ message: 'Endpoint is required' }, { status: 400 }) + } + + await prisma.pushSubscription.delete({ + where: { endpoint } + }) + + return NextResponse.json({ success: true }) + } catch (error) { + return handleApiError(error, 'push-unsubscribe') + } +} diff --git a/src/app/api/supplier/invitations/accept/route.ts b/src/app/api/supplier/invitations/accept/route.ts index 0c44dd3..dab1ab7 100644 --- a/src/app/api/supplier/invitations/accept/route.ts +++ b/src/app/api/supplier/invitations/accept/route.ts @@ -35,7 +35,7 @@ export async function POST(req: Request) { // Find invitation by token // TypeScript may not recognize invitation model due to cache, but it exists at runtime - const invitation = await (prisma as any).invitation.findUnique({ + const invitation = await prisma.invitation.findUnique({ where: { token: token.toUpperCase() }, include: { store: { @@ -90,7 +90,7 @@ export async function POST(req: Request) { if (existingConnection) { // Mark invitation as used even if connection exists // TypeScript may not recognize invitation model due to cache, but it exists at runtime - await (prisma as any).invitation.update({ + await prisma.invitation.update({ where: { id: invitation.id }, data: { used: true, usedAt: new Date() } }) diff --git a/src/app/api/vendors/suppliers/[supplierId]/invite/route.ts b/src/app/api/vendors/suppliers/[supplierId]/invite/route.ts index b308735..4682164 100644 --- a/src/app/api/vendors/suppliers/[supplierId]/invite/route.ts +++ b/src/app/api/vendors/suppliers/[supplierId]/invite/route.ts @@ -45,7 +45,7 @@ export async function DELETE( : vendorStores.map(s => s.id) // Find and delete pending invitations - const pendingInvitations = await (prisma as any).invitation.findMany({ + const pendingInvitations = await prisma.invitation.findMany({ where: { storeId: { in: storeIdsToCheck }, supplierId: supplierId, @@ -64,9 +64,9 @@ export async function DELETE( } // Delete all pending invitations - await (prisma as any).invitation.deleteMany({ + await prisma.invitation.deleteMany({ where: { - id: { in: pendingInvitations.map((inv: any) => inv.id) } + id: { in: pendingInvitations.map((inv) => inv.id) } } }) @@ -148,19 +148,6 @@ export async function POST( } }) - // Get vendor stores with user info for notifications - const vendorStoresWithUser = await prisma.store.findMany({ - where: { - userId: session.user.id, - onboardingComplete: true - }, - select: { - id: true, - businessName: true, - userId: true - } - }) - if (!supplierProfile) { return NextResponse.json({ message: "Supplier not found" }, { status: 404 }) } @@ -182,7 +169,7 @@ export async function POST( } }) - const existingInvitations = await (prisma as any).invitation.findMany({ + const existingInvitations = await prisma.invitation.findMany({ where: { storeId: { in: storeIds }, supplierId: supplierProfile.id, @@ -196,7 +183,7 @@ export async function POST( // Filter out stores that already have connections or pending invitations const connectedStoreIds = new Set([ ...existingConnections.map(c => c.storeId), - ...existingInvitations.map((i: any) => i.storeId) + ...existingInvitations.map((i) => i.storeId) ]) const storesToInvite = storesToConnect.filter(store => !connectedStoreIds.has(store.id)) @@ -224,7 +211,7 @@ export async function POST( const invitationToken = crypto.randomBytes(32).toString("hex").toUpperCase().slice(0, 16) try { - const invitation = await (prisma as any).invitation.create({ + const invitation = await prisma.invitation.create({ data: { token: invitationToken, storeId: store.id, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e19907f..5308e67 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -18,7 +18,15 @@ export const metadata: Metadata = { title: "JUNOHUB | Modern Commerce Infrastructure", description: "The world's most advanced encrypted ecosystem for vendors and suppliers.", viewport: "width=device-width, initial-scale=1, maximum-scale=5", -}; + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "JUNOHUB", + }, + formatDetection: { + telephone: false, + }, +} export const viewport = { width: "device-width", @@ -28,6 +36,7 @@ export const viewport = { import { Providers } from "@/components/providers"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { SmoothScroll } from "@/components/SmoothScroll"; +import { PWARegistration } from "@/components/PWARegistration"; export default function RootLayout({ children, @@ -41,6 +50,7 @@ export default function RootLayout({ > + {children} diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..35d4217 --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,30 @@ +import { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + return { + name: 'JUNOHUB', + short_name: 'JUNOHUB', + description: 'The Unified Supply Chain Hub', + start_url: '/', + display: 'standalone', + background_color: '#0a0e1a', + theme_color: '#0a0e1a', + icons: [ + { + src: '/icon-192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: '/icon-512.png', + sizes: '512x512', + type: 'image/png', + }, + { + src: '/apple-icon.png', + sizes: '180x180', + type: 'image/png', + } + ], + } +} diff --git a/src/components/PWARegistration.tsx b/src/components/PWARegistration.tsx new file mode 100644 index 0000000..cfc8482 --- /dev/null +++ b/src/components/PWARegistration.tsx @@ -0,0 +1,22 @@ +'use client' + +import { useEffect } from 'react' + +export function PWARegistration() { + useEffect(() => { + if ('serviceWorker' in navigator && window.self === window.top) { + window.addEventListener('load', () => { + navigator.serviceWorker + .register('/sw.js') + .then((registration) => { + console.log('PWA Service Worker registered with scope:', registration.scope) + }) + .catch((error) => { + console.error('PWA Service Worker registration failed:', error) + }) + }) + } + }, []) + + return null +} diff --git a/src/hooks/usePushNotifications.ts b/src/hooks/usePushNotifications.ts new file mode 100644 index 0000000..30157a6 --- /dev/null +++ b/src/hooks/usePushNotifications.ts @@ -0,0 +1,104 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' + +const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY + +export function usePushNotifications() { + const [permission, setPermission] = useState('default') + const [isSubscribed, setIsSubscribed] = useState(false) + const [loading, setLoading] = useState(true) + + const urlBase64ToUint8Array = (base64String: string) => { + 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 + } + + const checkSubscription = useCallback(async () => { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + setLoading(false) + return + } + + try { + const registration = await navigator.serviceWorker.ready + const subscription = await registration.pushManager.getSubscription() + setIsSubscribed(!!subscription) + setPermission(Notification.permission) + } catch (error) { + console.error('Error checking push subscription:', error) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + checkSubscription() + }, [checkSubscription]) + + const subscribe = async () => { + if (!VAPID_PUBLIC_KEY) { + console.error('VAPID public key not found') + return false + } + + try { + const registration = await navigator.serviceWorker.ready + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY) + }) + + const response = await fetch('/api/notifications/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(subscription) + }) + + if (response.ok) { + setIsSubscribed(true) + setPermission(Notification.permission) + return true + } + return false + } catch (error) { + console.error('Error subscribing to push notifications:', error) + return false + } + } + + const unsubscribe = async () => { + try { + const registration = await navigator.serviceWorker.ready + const subscription = await registration.pushManager.getSubscription() + + if (subscription) { + await fetch('/api/notifications/subscribe', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpoint: subscription.endpoint }) + }) + await subscription.unsubscribe() + setIsSubscribed(false) + } + return true + } catch (error) { + console.error('Error unsubscribing from push notifications:', error) + return false + } + } + + return { + permission, + isSubscribed, + loading, + subscribe, + unsubscribe + } +} diff --git a/src/lib/push.ts b/src/lib/push.ts new file mode 100644 index 0000000..1dc9105 --- /dev/null +++ b/src/lib/push.ts @@ -0,0 +1,80 @@ +import webpush from 'web-push' +import { prisma } from './prisma' +import { logger } from './logger' + +// Initialize VAPID keys +if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY && process.env.VAPID_SUBJECT) { + webpush.setVapidDetails( + process.env.VAPID_SUBJECT, + process.env.VAPID_PUBLIC_KEY, + process.env.VAPID_PRIVATE_KEY + ) +} else if (process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY && process.env.VAPID_SUBJECT) { + webpush.setVapidDetails( + process.env.VAPID_SUBJECT, + process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY, + process.env.VAPID_PRIVATE_KEY + ) +} + +export interface PushPayload { + title: string + body: string + url?: string + icon?: string + tag?: string + data?: Record +} + +export async function sendPushNotification(userId: string, payload: PushPayload) { + try { + // Get all subscriptions for this user + const subscriptions = await prisma.pushSubscription.findMany({ + where: { userId } + }) + + if (subscriptions.length === 0) { + return { success: false, reason: 'No subscriptions found' } + } + + const results = await Promise.all( + subscriptions.map(async (sub) => { + try { + await webpush.sendNotification( + { + endpoint: sub.endpoint, + keys: { + p256dh: sub.p256dh, + auth: sub.auth + } + }, + JSON.stringify(payload) + ) + return { endpoint: sub.endpoint, success: true } + } catch (error: unknown) { + logger.error('Failed to send push notification to endpoint', { + error, + endpoint: sub.endpoint + }) + + const statusCode = error && typeof error === 'object' && 'statusCode' in error + ? (error as { statusCode?: number }).statusCode + : undefined + // If subscription is expired or invalid, remove it + if (statusCode === 410 || statusCode === 404) { + await prisma.pushSubscription.delete({ + where: { endpoint: sub.endpoint } + }) + } + + return { endpoint: sub.endpoint, success: false, error } + } + }) + ) + + return { success: true, results } + } catch (error) { + logger.error('Error in sendPushNotification', error) + return { success: false, error } + } +}