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

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

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