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:
jtricerolph 2026-07-20 22:59:16 +00:00
parent fc993c63bc
commit 43809b7460
11 changed files with 237 additions and 5 deletions

View file

@ -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"
}
}

View file

@ -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()

View file

@ -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()

73
backend/src/lib/push.js Normal file
View 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))
}

View file

@ -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)

View 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 }
})
}

View file

@ -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 })