The SSE + in-page toast/sound system only works while the tab's JS is actually running — Chrome (and other browsers) freeze a backgrounded tab's JS after a stretch of inactivity to save power, so notifications silently stop regardless of how correct the SSE/toast code is. A Service Worker is the only mechanism that keeps receiving events independent of the tab's own lifecycle, so this adds a real Web Push pipeline: - New `push_subscriptions` table (one row per browser/device a user has subscribed from; endpoint is unique so re-subscribing overwrites rather than accumulating stale rows). - VAPID keypair config (lib/push/vapid.ts) — reads VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT from the environment; push is silently disabled (never throws) if they're unset, matching this codebase's existing "an optional integration outage must never break the core flow" pattern (see the LDAP auth comment). - public/sw.js: a minimal service worker — `push` -> showNotification, `notificationclick` -> focus or open the ticket. - API routes: GET /api/push/vapid-public-key (client needs it to call pushManager.subscribe), POST/DELETE /api/push/subscribe. - lib/push/client.ts: registers the service worker and subscribes, called from the notification bell after granting permission and again on mount for returning users who already granted it. - lib/tickets/service.ts: appendMessage() now also fires a push (fire- and-forget, never awaited by the caller) to every admin plus the ticket's assignee whenever a *customer* message arrives — same "who should know" rule as the in-page toast (lib/tickets/visibility.ts). Failed sends are inspected: a 404/410 (push service no longer recognizes the subscription) prunes the row; anything else is just logged, since it might be transient. Verified server-side end-to-end on this VM: saved a subscription via the API, created a customer ticket, and confirmed the push attempt actually fires (web-push validated and rejected a deliberately-malformed test key, proving the send path is wired correctly) without blocking or crashing ticket creation. Couldn't verify the full real-browser subscribe-and-receive path or an actual OS popup from here — this VM has no desktop/notification service, and headless Chromium's Notification permission can't be reliably granted in this sandbox (unrelated to the app code); that last mile needs verifying on a real machine. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
40 lines
1.3 KiB
JavaScript
40 lines
1.3 KiB
JavaScript
// Web Push service worker. Deliberately minimal — no offline caching, no
|
|
// asset interception. Its only job is to keep receiving push events even
|
|
// when the page's own tab is frozen or closed, and turn them into an OS
|
|
// notification.
|
|
|
|
self.addEventListener("push", (event) => {
|
|
let payload = { title: "top-tickets", body: "", url: "/dashboard" };
|
|
try {
|
|
if (event.data) payload = { ...payload, ...event.data.json() };
|
|
} catch {
|
|
// malformed payload — fall back to the generic notification above
|
|
}
|
|
|
|
event.waitUntil(
|
|
self.registration.showNotification(payload.title, {
|
|
body: payload.body,
|
|
icon: "/icon.svg",
|
|
data: { url: payload.url },
|
|
}),
|
|
);
|
|
});
|
|
|
|
self.addEventListener("notificationclick", (event) => {
|
|
event.notification.close();
|
|
const url = event.notification.data && event.notification.data.url ? event.notification.data.url : "/dashboard";
|
|
|
|
event.waitUntil(
|
|
(async () => {
|
|
const clientsList = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
|
const existing = clientsList.find((c) => "focus" in c);
|
|
if (existing) {
|
|
await existing.navigate(url);
|
|
await existing.focus();
|
|
return;
|
|
}
|
|
await self.clients.openWindow(url);
|
|
})(),
|
|
);
|
|
});
|