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>
73 lines
2.5 KiB
JavaScript
73 lines
2.5 KiB
JavaScript
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))
|
|
}
|