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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
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)
|
||||
})
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue