diff --git a/backend/package.json b/backend/package.json index 5f6a6c1..981a9ba 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "fastify": "^4.28.1", "jose": "^5.9.6", "nodemailer": "^6.9.16", - "pg": "^8.13.1" + "pg": "^8.13.1", + "web-push": "^3.6.7" } } diff --git a/backend/src/db.js b/backend/src/db.js index e8a122a..092d5fb 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -148,6 +148,18 @@ export async function initDb() { ); CREATE INDEX IF NOT EXISTS task_events_task_idx ON task_events (task_id, created_at); + + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id SERIAL PRIMARY KEY, + user_email TEXT NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS push_subs_email_idx ON push_subscriptions (user_email); `) await seedDefaults() diff --git a/backend/src/index.js b/backend/src/index.js index 46690ad..7273914 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -15,6 +15,7 @@ import { assetRoutes } from './routes/assets.js' import { contractorRoutes } from './routes/contractors.js' import { templateRoutes } from './routes/templates.js' import { configRoutes } from './routes/config.js' +import { pushRoutes } from './routes/push.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const UPLOADS_DIR = join(__dirname, '..', 'uploads') @@ -44,6 +45,7 @@ await app.register(assetRoutes) await app.register(contractorRoutes, { uploadsDir: UPLOADS_DIR }) await app.register(templateRoutes) await app.register(configRoutes) +await app.register(pushRoutes) try { await initDb() diff --git a/backend/src/lib/push.js b/backend/src/lib/push.js new file mode 100644 index 0000000..8d219e5 --- /dev/null +++ b/backend/src/lib/push.js @@ -0,0 +1,73 @@ +import webPush from 'web-push' +import { pool } from '../db.js' + +let _publicKey = null + +// VAPID keys: prefer env vars; fall back to auto-generated keys stored in config table. +// Keys are generated once on first boot and persist — regenerating would invalidate all +// existing subscriptions. +export async function ensureVapid() { + if (_publicKey) return _publicKey + + let publicKey = process.env.VAPID_PUBLIC_KEY + let privateKey = process.env.VAPID_PRIVATE_KEY + const subject = process.env.VAPID_SUBJECT || 'mailto:noreply@localhost' + + if (!publicKey || !privateKey) { + const { rows } = await pool.query("SELECT value FROM config WHERE key = 'vapid_keys'") + if (rows.length && rows[0].value?.publicKey) { + publicKey = rows[0].value.publicKey + privateKey = rows[0].value.privateKey + } else { + const keys = webPush.generateVAPIDKeys() + publicKey = keys.publicKey + privateKey = keys.privateKey + await pool.query( + "INSERT INTO config (key, value) VALUES ('vapid_keys', $1) ON CONFLICT (key) DO UPDATE SET value = $1", + [JSON.stringify({ publicKey, privateKey })] + ) + } + } + + webPush.setVapidDetails(subject, publicKey, privateKey) + _publicKey = publicKey + return publicKey +} + +export async function sendPushToUser(userEmail, payload) { + await ensureVapid() + const { rows } = await pool.query( + 'SELECT * FROM push_subscriptions WHERE user_email = $1', + [userEmail] + ) + if (!rows.length) return + + const results = await Promise.allSettled( + rows.map(sub => + webPush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + JSON.stringify(payload) + ) + ) + ) + + // Clean up expired or invalid subscriptions + for (let i = 0; i < results.length; i++) { + const r = results[i] + if (r.status === 'rejected' && [404, 410].includes(r.reason?.statusCode)) { + await pool.query('DELETE FROM push_subscriptions WHERE id = $1', [rows[i].id]).catch(() => {}) + } + } +} + +// targetEmail overrides task.assigned_to (used when reassigning via PATCH before DB update) +export function notifyAssignmentPush(task, locationName, targetEmail = null) { + const email = targetEmail || (task.assigned_type === 'staff' ? task.assigned_to : null) + if (!email) return Promise.resolve() + return sendPushToUser(email, { + title: `New task: ${task.title}`, + body: `${locationName} — ${task.priority} priority`, + url: '/maintenance/', + tag: `maint-task-${task.id}`, + }).catch(err => console.error('[push] notify failed:', err.message)) +} diff --git a/backend/src/lib/task-core.js b/backend/src/lib/task-core.js index a0efd20..5fce187 100644 --- a/backend/src/lib/task-core.js +++ b/backend/src/lib/task-core.js @@ -1,5 +1,6 @@ import { pool, getConfig } from '../db.js' import { notifyAssignment, notifyUrgent } from './mailer.js' +import { notifyAssignmentPush } from './push.js' export const PRIORITIES = ['low', 'medium', 'high', 'urgent'] export const STATUSES = ['submitted', 'in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed'] @@ -67,6 +68,7 @@ export async function createTask(input, actor) { if (config.notify_on_assign) { if (task.assigned_type === 'staff' && task.assigned_to && task.assigned_to !== actor.email) { notifyAssignment(task, locationName, task.assigned_to) + notifyAssignmentPush(task, locationName) } else if (task.assigned_type === 'contractor' && task.contractor_id) { const { rows: c } = await pool.query('SELECT email FROM contractors WHERE id = $1', [task.contractor_id]) if (c[0]?.email) notifyAssignment(task, locationName, c[0].email) diff --git a/backend/src/routes/push.js b/backend/src/routes/push.js new file mode 100644 index 0000000..21b2f33 --- /dev/null +++ b/backend/src/routes/push.js @@ -0,0 +1,36 @@ +import { requireAuth } from '../auth.js' +import { pool } from '../db.js' +import { ensureVapid } from '../lib/push.js' + +export async function pushRoutes(app) { + app.addHook('preHandler', requireAuth) + + app.get('/api/push/vapid-key', async () => { + const publicKey = await ensureVapid() + return { publicKey } + }) + + app.post('/api/push/subscribe', async (req, reply) => { + const { endpoint, keys } = req.body || {} + if (!endpoint || !keys?.p256dh || !keys?.auth) { + return reply.status(400).send({ error: 'endpoint and keys (p256dh, auth) required' }) + } + await pool.query( + `INSERT INTO push_subscriptions (user_email, endpoint, p256dh, auth) + VALUES ($1, $2, $3, $4) + ON CONFLICT (endpoint) DO UPDATE SET user_email = $1, p256dh = $3, auth = $4, updated_at = NOW()`, + [req.user.email, endpoint, keys.p256dh, keys.auth] + ) + return { ok: true } + }) + + app.delete('/api/push/unsubscribe', async (req, reply) => { + const { endpoint } = req.body || {} + if (endpoint) { + await pool.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [endpoint]) + } else { + await pool.query('DELETE FROM push_subscriptions WHERE user_email = $1', [req.user.email]) + } + return { ok: true } + }) +} diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js index 361d7c4..8e58381 100644 --- a/backend/src/routes/tasks.js +++ b/backend/src/routes/tasks.js @@ -3,6 +3,7 @@ import { pool, getConfig } from '../db.js' import { createTask, logEvent, TRANSITIONS, PRIORITIES, STATUSES } from '../lib/task-core.js' import { fetchBookings } from '../lib/newbook.js' import { notifyAssignment } from '../lib/mailer.js' +import { notifyAssignmentPush } from '../lib/push.js' const PRIORITY_ORDER = `CASE t.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END` @@ -187,6 +188,7 @@ export async function taskRoutes(app) { if (config.notify_on_assign && b.assigned_to && b.assigned_to !== task.assigned_to && b.assigned_to !== req.user.email) { const { rows: l } = await pool.query('SELECT name FROM locations WHERE id = $1', [task.location_id]) notifyAssignment(task, l[0]?.name || '', b.assigned_to) + notifyAssignmentPush(task, l[0]?.name || '', b.assigned_to) } } await logEvent(task.id, 'reassigned', { note, userName }) diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx index 7f0d9f2..78f94d6 100644 --- a/frontend/src/components/AuthGate.tsx +++ b/frontend/src/components/AuthGate.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, createContext, useContext } from 'react' import type { User } from '../types' +import { usePushSubscription } from '../hooks/usePushSubscription' function getInactivityMs(): number | null { if (window.matchMedia('(display-mode: standalone)').matches) return null @@ -19,6 +20,11 @@ export function useAuth() { return ctx } +function PushSubscriber({ user }: { user: User }) { + usePushSubscription(user) + return null +} + export default function AuthGate({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null) @@ -46,5 +52,10 @@ export default function AuthGate({ children }: { children: React.ReactNode }) { ) } - return {children} + return ( + + + {children} + + ) } diff --git a/frontend/src/hooks/usePushSubscription.ts b/frontend/src/hooks/usePushSubscription.ts new file mode 100644 index 0000000..1cec135 --- /dev/null +++ b/frontend/src/hooks/usePushSubscription.ts @@ -0,0 +1,57 @@ +import { useEffect } from 'react' +import type { User } from '../types' + +const BASE = '/maintenance/api' + +function urlBase64ToUint8Array(base64: string): ArrayBuffer { + const padding = '='.repeat((4 - (base64.length % 4)) % 4) + const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/') + const raw = atob(b64) + const buf = new Uint8Array(raw.length) + for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i) + return buf.buffer +} + +export function usePushSubscription(user: User) { + useEffect(() => { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) return + if (Notification.permission === 'denied') return + + async function subscribe() { + try { + const reg = await navigator.serviceWorker.ready + + const keyRes = await fetch(`${BASE}/push/vapid-key`, { credentials: 'include' }) + if (!keyRes.ok) return + const { publicKey } = await keyRes.json() as { publicKey: string } + + let sub = await reg.pushManager.getSubscription() + + if (!sub) { + if (Notification.permission === 'default') { + const permission = await Notification.requestPermission() + if (permission !== 'granted') return + } else if (Notification.permission !== 'granted') { + return + } + sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }) + } + + // Always re-POST so subscription is tied to current user (handles re-login) + await fetch(`${BASE}/push/subscribe`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sub.toJSON()), + }) + } catch (err) { + console.error('[push] subscription failed:', err) + } + } + + subscribe() + }, [user.email]) +} diff --git a/frontend/src/sw.js b/frontend/src/sw.js new file mode 100644 index 0000000..527cacf --- /dev/null +++ b/frontend/src/sw.js @@ -0,0 +1,35 @@ +import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching' +import { NavigationRoute, registerRoute } from 'workbox-routing' + +precacheAndRoute(self.__WB_MANIFEST) + +// SPA fallback: navigate requests that don't match a cached asset serve index.html +registerRoute( + new NavigationRoute(createHandlerBoundToURL('/maintenance/index.html'), { + denylist: [/\/api\//], + }) +) + +self.addEventListener('push', event => { + const data = event.data?.json() ?? {} + event.waitUntil( + self.registration.showNotification(data.title || 'Maintenance', { + body: data.body || '', + icon: '/maintenance/icons/icon-192.png', + badge: '/maintenance/icons/icon-192.png', + data: { url: data.url || '/maintenance/' }, + tag: data.tag || 'maintenance', + }) + ) +}) + +self.addEventListener('notificationclick', event => { + event.notification.close() + const url = event.notification.data?.url || '/maintenance/' + event.waitUntil( + clients.matchAll({ type: 'window' }).then(list => { + const existing = list.find(c => c.url.includes('/maintenance/') && 'focus' in c) + return existing ? existing.focus() : clients.openWindow(url) + }) + ) +}) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 814710e..6d1643a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -7,6 +7,9 @@ export default defineConfig({ plugins: [ react(), VitePWA({ + strategies: 'injectManifest', + srcDir: 'src', + filename: 'sw.js', registerType: 'autoUpdate', manifest: { name: 'Maintenance', @@ -21,9 +24,7 @@ export default defineConfig({ { src: '/maintenance/icons/icon-512.png', sizes: '512x512', type: 'image/png' }, ], }, - workbox: { - navigateFallback: '/maintenance/index.html', - navigateFallbackDenylist: [/\/api\//], + injectManifest: { globPatterns: ['**/*.{js,css,html,ico,png,svg}'], }, }),