Add Web Push notifications for task assignment
When a task is created or reassigned to a staff member, a push notification is sent to any devices they have subscribed on. Keys are auto-generated on first boot and stored in the config table (no manual VAPID setup needed). - Backend: web-push dep, push_subscriptions table, lib/push.js (VAPID + send), routes/push.js (vapid-key / subscribe / unsubscribe endpoints), push notify wired into task-core createTask and tasks PATCH reassignment - Frontend: switched vite-plugin-pwa to injectManifest strategy, custom sw.js handles precache + push event + notificationclick, usePushSubscription hook requests permission and registers subscription on login Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fc993c63bc
commit
43809b7460
11 changed files with 237 additions and 5 deletions
|
|
@ -14,6 +14,7 @@
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"jose": "^5.9.6",
|
"jose": "^5.9.6",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"pg": "^8.13.1"
|
"pg": "^8.13.1",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 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()
|
await seedDefaults()
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { assetRoutes } from './routes/assets.js'
|
||||||
import { contractorRoutes } from './routes/contractors.js'
|
import { contractorRoutes } from './routes/contractors.js'
|
||||||
import { templateRoutes } from './routes/templates.js'
|
import { templateRoutes } from './routes/templates.js'
|
||||||
import { configRoutes } from './routes/config.js'
|
import { configRoutes } from './routes/config.js'
|
||||||
|
import { pushRoutes } from './routes/push.js'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const UPLOADS_DIR = join(__dirname, '..', 'uploads')
|
const UPLOADS_DIR = join(__dirname, '..', 'uploads')
|
||||||
|
|
@ -44,6 +45,7 @@ await app.register(assetRoutes)
|
||||||
await app.register(contractorRoutes, { uploadsDir: UPLOADS_DIR })
|
await app.register(contractorRoutes, { uploadsDir: UPLOADS_DIR })
|
||||||
await app.register(templateRoutes)
|
await app.register(templateRoutes)
|
||||||
await app.register(configRoutes)
|
await app.register(configRoutes)
|
||||||
|
await app.register(pushRoutes)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await initDb()
|
await initDb()
|
||||||
|
|
|
||||||
73
backend/src/lib/push.js
Normal file
73
backend/src/lib/push.js
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { pool, getConfig } from '../db.js'
|
import { pool, getConfig } from '../db.js'
|
||||||
import { notifyAssignment, notifyUrgent } from './mailer.js'
|
import { notifyAssignment, notifyUrgent } from './mailer.js'
|
||||||
|
import { notifyAssignmentPush } from './push.js'
|
||||||
|
|
||||||
export const PRIORITIES = ['low', 'medium', 'high', 'urgent']
|
export const PRIORITIES = ['low', 'medium', 'high', 'urgent']
|
||||||
export const STATUSES = ['submitted', 'in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed']
|
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 (config.notify_on_assign) {
|
||||||
if (task.assigned_type === 'staff' && task.assigned_to && task.assigned_to !== actor.email) {
|
if (task.assigned_type === 'staff' && task.assigned_to && task.assigned_to !== actor.email) {
|
||||||
notifyAssignment(task, locationName, task.assigned_to)
|
notifyAssignment(task, locationName, task.assigned_to)
|
||||||
|
notifyAssignmentPush(task, locationName)
|
||||||
} else if (task.assigned_type === 'contractor' && task.contractor_id) {
|
} 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])
|
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)
|
if (c[0]?.email) notifyAssignment(task, locationName, c[0].email)
|
||||||
|
|
|
||||||
36
backend/src/routes/push.js
Normal file
36
backend/src/routes/push.js
Normal file
|
|
@ -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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { pool, getConfig } from '../db.js'
|
||||||
import { createTask, logEvent, TRANSITIONS, PRIORITIES, STATUSES } from '../lib/task-core.js'
|
import { createTask, logEvent, TRANSITIONS, PRIORITIES, STATUSES } from '../lib/task-core.js'
|
||||||
import { fetchBookings } from '../lib/newbook.js'
|
import { fetchBookings } from '../lib/newbook.js'
|
||||||
import { notifyAssignment } from '../lib/mailer.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`
|
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) {
|
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])
|
const { rows: l } = await pool.query('SELECT name FROM locations WHERE id = $1', [task.location_id])
|
||||||
notifyAssignment(task, l[0]?.name || '', b.assigned_to)
|
notifyAssignment(task, l[0]?.name || '', b.assigned_to)
|
||||||
|
notifyAssignmentPush(task, l[0]?.name || '', b.assigned_to)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await logEvent(task.id, 'reassigned', { note, userName })
|
await logEvent(task.id, 'reassigned', { note, userName })
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useState, createContext, useContext } from 'react'
|
import { useEffect, useState, createContext, useContext } from 'react'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
|
import { usePushSubscription } from '../hooks/usePushSubscription'
|
||||||
|
|
||||||
function getInactivityMs(): number | null {
|
function getInactivityMs(): number | null {
|
||||||
if (window.matchMedia('(display-mode: standalone)').matches) return null
|
if (window.matchMedia('(display-mode: standalone)').matches) return null
|
||||||
|
|
@ -19,6 +20,11 @@ export function useAuth() {
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PushSubscriber({ user }: { user: User }) {
|
||||||
|
usePushSubscription(user)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
|
||||||
|
|
@ -46,5 +52,10 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Ctx.Provider value={{ user }}>{children}</Ctx.Provider>
|
return (
|
||||||
|
<Ctx.Provider value={{ user }}>
|
||||||
|
<PushSubscriber user={user} />
|
||||||
|
{children}
|
||||||
|
</Ctx.Provider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
frontend/src/hooks/usePushSubscription.ts
Normal file
57
frontend/src/hooks/usePushSubscription.ts
Normal file
|
|
@ -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])
|
||||||
|
}
|
||||||
35
frontend/src/sw.js
Normal file
35
frontend/src/sw.js
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -7,6 +7,9 @@ export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'src',
|
||||||
|
filename: 'sw.js',
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Maintenance',
|
name: 'Maintenance',
|
||||||
|
|
@ -21,9 +24,7 @@ export default defineConfig({
|
||||||
{ src: '/maintenance/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
{ src: '/maintenance/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
workbox: {
|
injectManifest: {
|
||||||
navigateFallback: '/maintenance/index.html',
|
|
||||||
navigateFallbackDenylist: [/\/api\//],
|
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue