diff --git a/.gitignore b/.gitignore index 2902a54..13cf87a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ lerna-debug.log* **/public/sw.js.map **/public/workbox-*.js.map **/public/worker-*.js.map - +public/worker.js .eslintcache *.tsbuildinfo @@ -35,4 +35,4 @@ certificates content/output/* public/content/output/* public/content/output/shifts 2024.1.json -!public/content/uploads/* \ No newline at end of file +!public/content/uploads/* diff --git a/components/PwaManager.tsx b/components/PwaManager.tsx index 9d89667..e7fe746 100644 --- a/components/PwaManager.tsx +++ b/components/PwaManager.tsx @@ -1,5 +1,7 @@ import React, { useEffect, useState } from 'react'; import common from '../src/helpers/common'; // Ensure this path is correct +//use session to get user role +import { useSession } from "next-auth/react" function PwaManager() { const [deferredPrompt, setDeferredPrompt] = useState(null); @@ -10,6 +12,8 @@ function PwaManager() { const [registration, setRegistration] = useState(null); const [notificationPermission, setNotificationPermission] = useState(Notification.permission); + const { data: session } = useSession(); + // Handle PWA installation useEffect(() => { @@ -54,7 +58,7 @@ function PwaManager() { }, []); const installPWA = async (e) => { - + console.log('installing PWA'); e.preventDefault(); if (deferredPrompt) { deferredPrompt.prompt(); @@ -107,9 +111,25 @@ function PwaManager() { applicationServerKey: base64ToUint8Array(vapidPublicKey) }); // Call your API to save subscription data on server - setSubscription(sub); - setIsSubscribed(true); - console.log('Web push subscribed!'); + if (session.user?.id != null) { + await fetch(`/api/notify`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ subscription: sub, id: session.user.id }) + }).then(response => { + if (!response.ok) { + throw new Error('Failed to save subscription data on server.'); + } + else { + console.log('Subscription data saved on server.'); + setSubscription(sub); + setIsSubscribed(true); + console.log('Web push subscribed!'); + } + }); + } console.log(sub); } catch (error) { console.error('Error subscribing to notifications:', error); @@ -124,6 +144,24 @@ function PwaManager() { // Call your API to delete or invalidate subscription data on server setSubscription(null); setIsSubscribed(false); + if (session?.user?.id != null) { + await fetch(`/api/notify`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ id: session?.user.id }), + } + ).then(response => { + if (!response.ok) { + throw new Error('Failed to delete subscription data on server.'); + } + else { + console.log('Subscription data deleted on server.'); + } + }); + } console.log('Web push unsubscribed!'); } catch (error) { console.error('Error unsubscribing from notifications:', error); diff --git a/next.config.js b/next.config.js index 7e695a0..9f43873 100644 --- a/next.config.js +++ b/next.config.js @@ -1,15 +1,15 @@ const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); -const { InjectManifest } = require('workbox-webpack-plugin'); +const { InjectManifest, GenerateSW } = require('workbox-webpack-plugin'); const withPWA = require('next-pwa')({ dest: 'public', register: true, // ? publicExcludes: ["!_error*.js"], //? - - disable: process.env.NODE_ENV === 'development', + skipWaiting: true, + // disable: process.env.NODE_ENV === 'development', }) -module.exports = { +module.exports = withPWA({ typescript: { // !! WARN !! // Dangerously allow production builds to successfully complete even if @@ -20,19 +20,31 @@ module.exports = { compress: false, pageExtensions: ['ts', 'tsx', 'md', 'mdx'], // Replace `jsx?` with `tsx?` env: { - env: process.env.NODE_ENV, + env: process.env.APP_ENV, server: process.env.NEXT_PUBLIC_PUBLIC_URL }, - plugins: [ - // Other plugins... - new InjectManifest({ - // These are some common options, and not all are required. - // Consult the docs for more info. - //exclude: [/.../, '...'], - maximumFileSizeToCacheInBytes: 1 * 1024 * 1024, - swSrc: './worker/index.js', - }), - ], + // pwa: { + // dest: 'public', + // register: true, + // publicExcludes: ["!_error*.js"], + // disable: process.env.NODE_ENV === 'development', + // // sw: './worker/index.js', // Custom service worker file name + // }, + + // plugins: [ + // // new InjectManifest({ + // // // These are some common options, and not all are required. + // // // Consult the docs for more info. + // // //exclude: [/.../, '...'], + // // maximumFileSizeToCacheInBytes: 1 * 1024 * 1024, + // // swSrc: './worker.js', + // // }), + // // new GenerateSW({ + // // //disable all files + // // maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, + // // // swSrc: './worker.js', + // // }), + // ], webpack: (config, { isServer, buildId, dev }) => { // Configure optimization and source maps config.optimization.minimize = !dev; @@ -46,18 +58,18 @@ module.exports = { // InjectManifest configuration if (!isServer) { - config.plugins.push(new InjectManifest({ - swSrc: './worker/index.js', // Path to source service worker file - swDest: '/worker/index.js', // Destination filename in the build output - maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // Adjust as needed - exclude: [/\.map$/, /_error.js$/, /favicon.ico$/] // Customize exclusion patterns - }) - ); + // config.plugins.push(new InjectManifest({ + // // swSrc: './worker.js', // Path to source service worker file + // // swDest: '/worker.js', // Destination filename in the build output + // maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // Adjust as needed + // exclude: [/\.map$/, /_error.js$/, /favicon.ico$/] // Customize exclusion patterns + // }) + // ); } // Bundle Analyzer Configuration if (process.env.ANALYZE && !isServer) { - const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); + //const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); config.plugins.push( new BundleAnalyzerPlugin({ analyzerMode: 'static', @@ -77,4 +89,4 @@ module.exports = { defaultLocale: 'bg', localeDetection: false, }, -} \ No newline at end of file +}) \ No newline at end of file diff --git a/pages/_app.tsx b/pages/_app.tsx index 2093f0a..1f33946 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -26,26 +26,47 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' // appleWebApp: true, // } +// (custom) Service worker registration and push notification logic +// function registerServiceWorkerAndPushNotifications() { +// useEffect(() => { +// const registerServiceWorker = async () => { +// if ('serviceWorker' in navigator) { +// try { +// const registration = await navigator.serviceWorker.register('/worker/index.js') +// .then((registration) => console.log('reg: ', registration)); +// } catch (error) { +// console.log('Service Worker registration failed:', error); +// } +// } +// }; + +// const askForNotificationPermission = async () => { +// if ('serviceWorker' in navigator && 'PushManager' in window) { +// try { +// const permission = await Notification.requestPermission(); +// if (permission === 'granted') { +// console.log('Notification permission granted.'); +// // TODO: Subscribe the user to push notifications here +// } else { +// console.log('Notification permission not granted.'); +// } +// } catch (error) { +// console.error('Error during service worker registration:', error); +// } +// } else { +// console.log('Service Worker or Push notifications not supported in this browser.'); +// } +// }; + +// registerServiceWorker(); +// askForNotificationPermission(); +// }, []); +// } //function SmwsApp({ Component, pageProps: { locale, messages, session, ...pageProps }, }: AppProps<{ session: Session }>) { function SmwsApp({ Component, pageProps, session, locale, messages }) { - // dynamic locale loading using our API endpoint - // const [locale, setLocale] = useState(_locale); - // const [messages, setMessages] = useState(_messages); - // useEffect(() => { - // async function loadLocaleData() { - // const res = await fetch(`/api/translations/${locale}`); - // if (res.ok) { - // const localeMessages = await res.json(); - // console.log("Loaded messages for locale:", locale, localeMessages); - // setMessages(localeMessages); - // } else { - // const localeMessages = await import(`../content/i18n/${locale}.json`); setMessages(localeMessages.default); - // } - // console.log("locale set to'", locale, "' ",); - // } - // loadLocaleData(); - // }, [locale]); + + //registerServiceWorkerAndPushNotifications(); useEffect(() => { const use = async () => { diff --git a/pages/api/notify.ts b/pages/api/notify.ts index 94dccc1..09ba2cb 100644 --- a/pages/api/notify.ts +++ b/pages/api/notify.ts @@ -1,6 +1,8 @@ const webPush = require('web-push') +import common from '../../src/helpers/common'; + //generate and store VAPID keys in .env.local if not already done if (!process.env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY || !process.env.WEB_PUSH_PRIVATE_KEY) { const { publicKey, privateKey } = webPush.generateVAPIDKeys() @@ -28,28 +30,66 @@ const Notification = async (req, res) => { res.end(process.env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY) res.end() } - // on PUT store the subscription object in the database + if (req.method == 'PUT') { + // store the subscription object in the database + // publisher.pushSubscription = subscription + const prisma = common.getPrismaClient(); + const { subscription, id } = req.body + const publisher = await prisma.publisher.update({ + where: { id }, + data: { pushSubscription: subscription } + }) + console.log('Subscription for publisher', id, 'updated:', subscription) + res.statusCode = 200 + res.end() + } + if (req.method == 'DELETE') { + // remove the subscription object from the database + // publisher.pushSubscription = null + const prisma = common.getPrismaClient(); + const { id } = req.body + const publisher = await prisma.publisher.update({ + where: { id }, + data: { pushSubscription: null } + }) + console.log('Subscription for publisher', id, 'deleted') + res.statusCode = 200 + res.end() + } + if (req.method == 'POST') { - const { subscription } = req.body - - await webPush - .sendNotification( - subscription, - JSON.stringify({ title: 'Hello Web Push', message: 'Your web push notification is here!' }) - ) - .then(response => { - res.writeHead(response.statusCode, response.headers).end(response.body) - }) - .catch(err => { - if ('statusCode' in err) { - res.writeHead(err.statusCode, err.headers).end(err.body) - } else { - console.error(err) - res.statusCode = 500 - res.end() - } - }) + const { subscription, id, broadcast, title = 'Hello Web Push', message = 'Your web push notification is here!' } = req.body + if (broadcast) { + await broadcastPush(title, message) + res.statusCode = 200 + res.end() + return + } + else if (id) { + await sendPush(id, title, message) + res.statusCode = 200 + res.end() + return + } else if (subscription) { + await webPush + .sendNotification( + subscription, + JSON.stringify({ title, message }) + ) + .then(response => { + res.writeHead(response.statusCode, response.headers).end(response.body) + }) + .catch(err => { + if ('statusCode' in err) { + res.writeHead(err.statusCode, err.headers).end(err.body) + } else { + console.error(err) + res.statusCode = 500 + res.end() + } + }) + } } else { res.statusCode = 405 res.end() @@ -57,3 +97,48 @@ const Notification = async (req, res) => { } export default Notification + +//export pushNotification(userId or email) for use in other files +export const sendPush = async (id, title, message) => { + const prisma = common.getPrismaClient(); + const publisher = await prisma.publisher.findUnique({ + where: { id } + }) + if (!publisher.pushSubscription) { + console.log('No push subscription found for publisher', id) + return + } + + await webPush + .sendNotification( + publisher.pushSubscription, + JSON.stringify({ title, message }) + ) + .then(response => { + console.log('Push notification sent to publisher', id) + }) + .catch(err => { + console.error('Error sending push notification to publisher', id, ':', err) + }) +} +//export breoadcastNotification for use in other files +export const broadcastPush = async (title, message) => { + const prisma = common.getPrismaClient(); + const publishers = await prisma.publisher.findMany({ + where: { pushSubscription: { not: null } } + }) + + for (const publisher of publishers) { + await webPush + .sendNotification( + publisher.pushSubscription, + JSON.stringify({ title, message }) + ) + .then(response => { + console.log('Push notification sent to publisher', publisher.id) + }) + .catch(err => { + console.error('Error sending push notification to publisher', publisher.id, ':', err) + }) + } +} diff --git a/pages/cart/calendar/index.tsx b/pages/cart/calendar/index.tsx index 5a4aa7d..e54d4f8 100644 --- a/pages/cart/calendar/index.tsx +++ b/pages/cart/calendar/index.tsx @@ -15,6 +15,8 @@ import { toast } from 'react-toastify'; import ProtectedRoute from '../../../components/protectedRoute'; import ConfirmationModal from '../../../components/ConfirmationModal'; import LocalShippingIcon from '@mui/icons-material/LocalShipping'; +// import notify api +import { sendPush, broadcastPush } from '../../api/notify'; const { DateTime } = require('luxon'); // import { FaPlus, FaCogs, FaTrashAlt, FaSpinner } from 'react-icons/fa'; // Import FontAwesome icons @@ -734,7 +736,19 @@ export default function CalendarPage({ initialEvents, initialShifts }) { {pub.currentWeekAssignments || 0} {pub.currentMonthAssignments || 0} {pub.previousMonthAssignments || 0} - + + + ); diff --git a/pages/dash.tsx b/pages/dash.tsx index fa8beb1..dc5d560 100644 --- a/pages/dash.tsx +++ b/pages/dash.tsx @@ -226,8 +226,8 @@ export const getServerSideProps = async (context) => { // log first availability startTime to verify timezone and UTC conversion - console.log("First availability startTime: " + items[0].startTime); - console.log("First availability startTime: " + items[0].startTime.toLocaleString()); + console.log("First availability startTime: " + items[0]?.startTime); + console.log("First availability startTime: " + items[0]?.startTime.toLocaleString()); const prisma = common.getPrismaClient(); let cartEvents = await prisma.cartEvent.findMany({ diff --git a/prisma/migrations/20240506162944_add_publiher_push_subsctiption/migration.sql b/prisma/migrations/20240506162944_add_publiher_push_subsctiption/migration.sql new file mode 100644 index 0000000..def0cde --- /dev/null +++ b/prisma/migrations/20240506162944_add_publiher_push_subsctiption/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `publisher` ADD COLUMN `pushSubscription` JSON NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6fcc599..391079f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -123,6 +123,7 @@ model Publisher { Message Message[] EventLog EventLog[] lastLogin DateTime? + pushSubscription Json? } model Availability { diff --git a/src/axiosServer.js b/src/axiosServer.js index 071e7e1..77faaeb 100644 --- a/src/axiosServer.js +++ b/src/axiosServer.js @@ -31,7 +31,7 @@ const axiosServer = async (context) => { } else { //redirect to next-auth login page - context.res.writeHead(302, { Location: '/api/auth/signin' }); + context.res.writeHead(302, { Location: encodeURIComponent('/api/auth/signin') }); context.res.end(); return { props: {} }; } diff --git a/workbox-config.js b/workbox-config.js index 480eeff..a3d8487 100644 --- a/workbox-config.js +++ b/workbox-config.js @@ -1,34 +1,37 @@ -importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js'); +// importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js'); -// Only import the modules you need; skip precaching and routing if not needed -workbox.core.skipWaiting(); -workbox.core.clientsClaim(); +// // ToDo: probably not used now as we use next-pwa( check config) +// // Only import the modules you need; skip precaching and routing if not needed -//workbox.precaching.cleanupOutdatedCaches(); -//disable precaching -workbox.precaching.precacheAndRoute(self.__WB_MANIFEST); +// workbox.core.skipWaiting(); +// workbox.core.clientsClaim(); -module.exports = { - // Other webpack config... - plugins: [ - // Other plugins... - new InjectManifest({ - // These are some common options, and not all are required. - // Consult the docs for more info. - exclude: [/.../, '...'], - maximumFileSizeToCacheInBytes: 1 * 1024 * 1024, - swSrc: './worker/index.js', - }), - ], -}; +// //workbox.precaching.cleanupOutdatedCaches(); +// //disable precaching +// workbox.precaching.precacheAndRoute(self.__WB_MANIFEST); -// Example: Set up push notification handling -self.addEventListener('push', event => { - const data = event.data.json(); - event.waitUntil( - self.registration.showNotification(data.title, { - body: data.message, - icon: '/path/to/icon.png' - }) - ); -}); +// module.exports = { +// // Other webpack config... +// plugins: [ +// // Other plugins... +// new InjectManifest({ +// // These are some common options, and not all are required. +// // Consult the docs for more info. +// exclude: [/.../, '...'], +// maximumFileSizeToCacheInBytes: 1 * 1024 * 1024, +// // swSrc: './worker.js', +// }), +// ], +// }; + +// // Example: Set up push notification handling +// self.addEventListener('push', event => { +// console.log('Push event received at workbox.config: ', event); +// const data = event.data.json(); +// event.waitUntil( +// self.registration.showNotification(data.title, { +// body: data.message, +// icon: '/path/to/icon.png' +// }) +// ); +// }); diff --git a/worker.js b/worker.js new file mode 100644 index 0000000..8ef95ff --- /dev/null +++ b/worker.js @@ -0,0 +1,71 @@ +// 'use strict' +// // currently not used as we ise next-pwa and in next.config.js we have withPWA. +// // maybe we can have withPWA({sw: './worker.js'}) ? +// console.log('Service Worker worker/index.js Loaded...') +// workbox.precaching.precacheAndRoute(self.__WB_MANIFEST); + + +// self.addEventListener('install', () => { +// console.log('service worker installed') +// }); + +// self.addEventListener('activate', () => { +// console.log('service worker activated') +// }); + +// self.addEventListener('fetch', (event) => { +// try { +// console.log('Fetch event for ', event.request.url); +// if (event.request.url.includes('/api/auth/callback/')) { +// // Use network only strategy for auth routes, or bypass SW completely +// event.respondWith(fetch(event.request)); +// return; +// } +// // other caching strategies... +// } catch (error) { +// console.error(error) +// } +// }); + +// self.addEventListener('push', function (event) { +// console.log('Push message', event) +// if (!(self.Notification && self.Notification.permission === 'granted')) { +// return +// } +// const data = JSON.parse(event.data.text()) +// event.waitUntil( +// registration.showNotification(data.title, { +// body: data.message, +// icon: '/icons/android-chrome-192x192.png' +// }) +// ) +// }) + +// self.addEventListener('notificationclick', function (event) { +// console.log('Notification click: tag', event.notification.tag) +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) { +// if (clientList.length > 0) { +// let client = clientList[0] +// for (let i = 0; i < clientList.length; i++) { +// if (clientList[i].focused) { +// client = clientList[i] +// } +// } +// return client.focus() +// } +// return clients.openWindow('/') +// }) +// ) +// }) + +// // self.addEventListener('pushsubscriptionchange', function(event) { +// // event.waitUntil( +// // Promise.all([ +// // Promise.resolve(event.oldSubscription ? deleteSubscription(event.oldSubscription) : true), +// // Promise.resolve(event.newSubscription ? event.newSubscription : subscribePush(registration)) +// // .then(function(sub) { return saveSubscription(sub) }) +// // ]) +// // ) +// // }) \ No newline at end of file diff --git a/worker/index.js b/worker/index.js deleted file mode 100644 index c3efe3d..0000000 --- a/worker/index.js +++ /dev/null @@ -1,61 +0,0 @@ -'use strict' - -console.log('Service Worker worker/index.js Loaded...') -workbox.precaching.precacheAndRoute(self.__WB_MANIFEST); - -self.addEventListener('fetch', (event) => { - try { - console.log('Fetch event for ', event.request.url); - if (event.request.url.includes('/api/auth/callback/')) { - // Use network only strategy for auth routes, or bypass SW completely - event.respondWith(fetch(event.request)); - return; - } - // other caching strategies... - } catch (error) { - console.error(error) - } -}); - -self.addEventListener('push', function (event) { - console.log('Push message', event) - if (!(self.Notification && self.Notification.permission === 'granted')) { - return - } - const data = JSON.parse(event.data.text()) - event.waitUntil( - registration.showNotification(data.title, { - body: data.message, - icon: '/icons/android-chrome-192x192.png' - }) - ) -}) - -self.addEventListener('notificationclick', function (event) { - console.log('Notification click: tag', event.notification.tag) - event.notification.close() - event.waitUntil( - clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) { - if (clientList.length > 0) { - let client = clientList[0] - for (let i = 0; i < clientList.length; i++) { - if (clientList[i].focused) { - client = clientList[i] - } - } - return client.focus() - } - return clients.openWindow('/') - }) - ) -}) - -// self.addEventListener('pushsubscriptionchange', function(event) { -// event.waitUntil( -// Promise.all([ -// Promise.resolve(event.oldSubscription ? deleteSubscription(event.oldSubscription) : true), -// Promise.resolve(event.newSubscription ? event.newSubscription : subscribePush(registration)) -// .then(function(sub) { return saveSubscription(sub) }) -// ]) -// ) -// }) \ No newline at end of file