add notifications

This commit is contained in:
chris 2025-10-09 12:22:23 -04:00
parent 0f2bfbb6a0
commit 74ad2750b0
4 changed files with 186 additions and 5 deletions

View File

@ -4,4 +4,6 @@ JWT_SECRET="" ##random number string
NEXTCLOUD_URL= NEXTCLOUD_URL=
NEXTCLOUD_USER= NEXTCLOUD_USER=
NEXTCLOUD_APP_PASSWORD= NEXTCLOUD_APP_PASSWORD=
NEXTCLOUD_CALENDAR_URL= NEXTCLOUD_CALENDAR_URL=
publicVapidKey="YOUR_PUBLIC_VAPID_KEY"
privateVapidKey="YOUR_PRIVATE_VAPID_KEY"

View File

@ -11,6 +11,7 @@
"pm2": "^6.0.8", "pm2": "^6.0.8",
"sqlite": "^5.1.1", "sqlite": "^5.1.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"tailwindcss": "^4.1.11" "tailwindcss": "^4.1.11",
"web-push": "^3.6.7"
} }
} }

View File

@ -388,6 +388,70 @@ function setupTabbedInterface() {
document.getElementById(`tab-content-${tabTarget}`).classList.remove('hidden'); 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 --- // --- START THE APP ---
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {

120
server.js
View File

@ -8,12 +8,14 @@ const cors = require('cors');
const path = require('path'); const path = require('path');
const axios = require('axios'); const axios = require('axios');
const ics = require('ics'); const ics = require('ics');
const webpush = require('web-push');
const bodyParser = require('body-parser');
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const JWT_SECRET = process.env.JWT_SECRET || 'default_secret_key'; const JWT_SECRET = process.env.JWT_SECRET || 'default_secret_key';
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin'; const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'adminpassword'; 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(); const app = express();
app.use(cors()); app.use(cors());
app.use(express.json()); 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() { async function initializeDatabase() {
console.log("Initializing database schema..."); console.log("Initializing database schema...");
await db.exec(`CREATE TABLE IF NOT EXISTS users ( await db.exec(`CREATE TABLE IF NOT EXISTS users (
@ -71,6 +79,12 @@ async function initializeDatabase() {
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE 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 ( await db.exec(`CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_username TEXT NOT NULL, admin_username TEXT NOT NULL,
@ -186,7 +200,28 @@ function setupRoutes() {
next(); 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) => { const requireRole = (role) => (req, res, next) => {
if (req.user && req.user.role === role) next(); if (req.user && req.user.role === role) next();
else res.status(403).json({ message: "Access denied." }); else res.status(403).json({ message: "Access denied." });
@ -255,6 +290,33 @@ function setupRoutes() {
const { startDate, endDate, reason } = req.body; const { startDate, endDate, reason } = req.body;
const { id, username } = req.user; 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]); 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." }); res.status(201).json({ message: "Time off request submitted." });
} catch (err) { } catch (err) {
res.status(500).json({ message: "Failed to submit request." }); res.status(500).json({ message: "Failed to submit request." });
@ -282,6 +344,27 @@ function setupRoutes() {
app.get('/api/user/notes', authenticateToken, async (req, res) => { app.get('/api/user/notes', authenticateToken, async (req, res) => {
try { 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]); 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); res.json(notes);
} catch (err) { } catch (err) {
res.status(500).json({ message: 'Failed to fetch notes.' }); 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." }); 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) => { app.post('/api/admin/add-punch', authenticateToken, requireRole('admin'), async (req, res) => {
try { try {
const { userId, username, punchInTime, punchOutTime } = req.body; const { userId, username, punchInTime, punchOutTime } = req.body;