From 74ad2750b022e90ee6bffb3a651a8cd84df2e4b4 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 9 Oct 2025 12:22:23 -0400 Subject: [PATCH] add notifications --- .env.example | 4 +- package.json | 3 +- public/js/main.js | 64 +++++++++++++++++++++++++ server.js | 120 ++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 186 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 870b43f..25a67d1 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,6 @@ JWT_SECRET="" ##random number string NEXTCLOUD_URL= NEXTCLOUD_USER= NEXTCLOUD_APP_PASSWORD= -NEXTCLOUD_CALENDAR_URL= \ No newline at end of file +NEXTCLOUD_CALENDAR_URL= +publicVapidKey="YOUR_PUBLIC_VAPID_KEY" +privateVapidKey="YOUR_PRIVATE_VAPID_KEY" \ No newline at end of file diff --git a/package.json b/package.json index b84b3ef..7cc47d2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "pm2": "^6.0.8", "sqlite": "^5.1.1", "sqlite3": "^5.1.7", - "tailwindcss": "^4.1.11" + "tailwindcss": "^4.1.11", + "web-push": "^3.6.7" } } diff --git a/public/js/main.js b/public/js/main.js index 8e4440f..9773643 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -388,6 +388,70 @@ function setupTabbedInterface() { document.getElementById(`tab-content-${tabTarget}`).classList.remove('hidden'); }); } +// This converts the VAPID public key string to the format the browser needs. +function urlBase64ToUint8Array(base64String) { + 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; +} + +// --- Subscription Logic --- +async function subscribeToNotifications() { + // IMPORTANT: Replace with your actual public VAPID key + const publicVapidKey = 'YOUR_PUBLIC_VAPID_KEY'; + const token = localStorage.getItem('token'); + + // Check if user is logged in first + if (!token) { + console.error('User is not logged in.'); + alert('You must be logged in to enable notifications.'); + return; + } + + // 1. Register the service worker + const register = await navigator.serviceWorker.register('/sw.js', { + scope: '/' + }); + + // 2. Get the push subscription + const subscription = await register.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicVapidKey) + }); + + // 3. Send the subscription to your authenticated backend + await fetch('/subscribe', { + method: 'POST', + body: JSON.stringify(subscription), + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` // The crucial update! + } + }); + + alert('You are now subscribed to notifications!'); +} + +// --- Attach to a Button --- +// Assuming you have a button in your HTML with id="notifyBtn" +const notifyBtn = document.getElementById('notifyBtn'); +if (notifyBtn) { + notifyBtn.addEventListener('click', () => { + subscribeToNotifications().catch(error => { + console.error('Error subscribing to notifications:', error); + alert('Failed to subscribe. Please make sure notifications are allowed for this site.'); + }); + }); +} // --- START THE APP --- if ('serviceWorker' in navigator) { diff --git a/server.js b/server.js index 5679807..78b633b 100644 --- a/server.js +++ b/server.js @@ -8,12 +8,14 @@ const cors = require('cors'); const path = require('path'); const axios = require('axios'); const ics = require('ics'); - +const webpush = require('web-push'); +const bodyParser = require('body-parser'); const PORT = process.env.PORT || 3000; const JWT_SECRET = process.env.JWT_SECRET || 'default_secret_key'; const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin'; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'adminpassword'; - +const publicVapidKey = process.env.PUBLIC_VAPID_KEY; +const privateVapidKey = process.env.PRIVATE_VAPID_KEY; const app = express(); app.use(cors()); app.use(express.json()); @@ -35,6 +37,12 @@ async function startServer() { } } +webpush.setVapidDetails( + 'mailto:utility@beachpartyballoons.com', // A contact email + publicVapidKey, + privateVapidKey +); + async function initializeDatabase() { console.log("Initializing database schema..."); await db.exec(`CREATE TABLE IF NOT EXISTS users ( @@ -71,6 +79,12 @@ async function initializeDatabase() { status TEXT NOT NULL DEFAULT 'pending', FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE )`); + await db.exec(`CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + subscription_object TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +)`); await db.exec(`CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, admin_username TEXT NOT NULL, @@ -186,7 +200,28 @@ function setupRoutes() { next(); }); }; +app.post('/subscribe', authenticateToken, async (req, res) => { + try { + const subscription = req.body; + const userId = req.user.id; + // Save the subscription to the database, linked to the user + await db.run( + 'INSERT INTO subscriptions (user_id, subscription_object) VALUES (?, ?)', + [userId, JSON.stringify(subscription)] + ); + + res.status(201).json({ message: 'Subscribed successfully.' }); + + // Optional: Send a welcome notification + const payload = JSON.stringify({ title: 'Time Pulse', body: 'You are now subscribed to notifications!' }); + webpush.sendNotification(subscription, payload).catch(err => console.error("Error sending welcome notification:", err)); + + } catch (error) { + console.error('Failed to save subscription:', error); + res.status(500).json({ message: 'Failed to subscribe.' }); + } +}); const requireRole = (role) => (req, res, next) => { if (req.user && req.user.role === role) next(); else res.status(403).json({ message: "Access denied." }); @@ -255,6 +290,33 @@ function setupRoutes() { const { startDate, endDate, reason } = req.body; const { id, username } = req.user; await db.run(`INSERT INTO time_off_requests (user_id, username, start_date, end_date, reason) VALUES (?, ?, ?, ?, ?)`, [id, username, startDate, endDate, reason]); + + try { + const adminSubs = await db.all(` + SELECT s.subscription_object + FROM subscriptions s + JOIN users u ON s.user_id = u.id + WHERE u.role = 'admin' + `); + + const payload = JSON.stringify({ + title: 'New Time-Off Request', + body: `${username} has requested time off.` + }); + + const promises = adminSubs.map(s => { + const subscription = JSON.parse(s.subscription_object); + return webpush.sendNotification(subscription, payload).catch(err => { + if (err.statusCode === 410) db.run('DELETE FROM subscriptions WHERE subscription_object = ?', [s.subscription_object]); + else console.error('Error sending admin notification:', err); + }); + }); + + await Promise.all(promises); +} catch (notifyError) { + console.error('Failed to send admin notifications:', notifyError); +} + res.status(201).json({ message: "Time off request submitted." }); } catch (err) { res.status(500).json({ message: "Failed to submit request." }); @@ -282,6 +344,27 @@ function setupRoutes() { app.get('/api/user/notes', authenticateToken, async (req, res) => { try { const notes = await db.all("SELECT admin_username, note_text, created_at FROM notes WHERE employee_user_id = ? ORDER BY created_at DESC", [req.user.id]); + try { + const userSubs = await db.all('SELECT subscription_object FROM subscriptions WHERE user_id = ?', [userId]); + + const payload = JSON.stringify({ + title: 'You Have a New Note', + body: `A new note has been added by ${adminUsername}.` + }); + + const promises = userSubs.map(s => { + const subscription = JSON.parse(s.subscription_object); + return webpush.sendNotification(subscription, payload).catch(err => { + if (err.statusCode === 410) db.run('DELETE FROM subscriptions WHERE subscription_object = ?', [s.subscription_object]); + else console.error('Error sending employee notification:', err); + }); + }); + + await Promise.all(promises); +} catch (notifyError) { + console.error('Failed to send employee notification:', notifyError); +} + res.json(notes); } catch (err) { res.status(500).json({ message: 'Failed to fetch notes.' }); @@ -397,7 +480,38 @@ function setupRoutes() { res.status(500).json({ message: "Server error forcing clock out." }); } }); - + // server.js - Inside setupRoutes(), with other admin routes +app.post('/api/admin/notify', authenticateToken, requireRole('admin'), async (req, res) => { + try { + const { userId, title, message } = req.body; + + // Get all subscriptions for the target user + const subs = await db.all('SELECT subscription_object FROM subscriptions WHERE user_id = ?', [userId]); + if (subs.length === 0) { + return res.status(404).json({ message: 'User has no active notification subscriptions.' }); + } + + const payload = JSON.stringify({ title: title, body: message }); + + // Send a notification to each subscription + const promises = subs.map(s => { + const subscription = JSON.parse(s.subscription_object); + return webpush.sendNotification(subscription, payload).catch(err => { + // If a subscription is expired (410 Gone), delete it from the DB + if (err.statusCode === 410) { + db.run('DELETE FROM subscriptions WHERE subscription_object = ?', [s.subscription_object]); + } + console.error('Error sending notification, user ID:', userId); + }); + }); + + await Promise.all(promises); + res.json({ message: 'Notification sent successfully.' }); + + } catch (err) { + res.status(500).json({ message: 'Failed to send notification.' }); + } +}); app.post('/api/admin/add-punch', authenticateToken, requireRole('admin'), async (req, res) => { try { const { userId, username, punchInTime, punchOutTime } = req.body;