feat(push): Web Push notifications for backgrounded/closed/mobile tabs

Page-level Notifications can't fire when a tab is frozen/closed (and never on
mobile), which is why recipients on another tab/app got nothing. Adds a
notification-only service worker (sw.js, no caching) + Web Push:

- push.js: optional web-push wrapper (no-op unless web-push installed AND
  VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY set -> app unaffected if unconfigured).
- push_subscriptions table + R.pushSubs repo (upsert by endpoint, prune dead).
- /api/push/vapid|subscribe|unsubscribe; DM + group message routes also send a
  Web Push to recipients.
- Client registers /sw.js, subscribes when permission granted; hidden-tab popups
  are left to push to avoid double-notifying (pushActive flag); SW suppresses the
  OS popup when a tab is visible. Removes the old code that unregistered SWs.

Requires (prod, once): npm install + VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 21:58:49 +05:30
parent d50d4bde47
commit 1272b81cee
8 changed files with 356 additions and 13 deletions
+42
View File
@@ -0,0 +1,42 @@
// BizGaze Connect service worker — NOTIFICATIONS ONLY.
// Intentionally has NO 'fetch' handler and NO caching, so it can never serve a stale
// version of the app. Its only job is to show push notifications when the page is in
// the background / frozen / closed, and to open the right chat when one is clicked.
self.addEventListener('install', (e) => { self.skipWaiting(); });
self.addEventListener('activate', (e) => { e.waitUntil(self.clients.claim()); });
self.addEventListener('push', (event) => {
let d = {};
try { d = event.data ? event.data.json() : {}; } catch (_) {}
const title = d.title || 'BizGaze Connect';
const options = {
body: d.body || '',
icon: '/logo.png',
badge: '/logo.png',
tag: d.tag || undefined, // collapse repeats from the same chat
renotify: !!d.tag,
data: { kind: d.kind || '', id: d.id || '' },
};
event.waitUntil((async () => {
// If a BizGaze tab is currently VISIBLE, the page itself alerts the user (ping / in-page
// popup) — skip the OS popup to avoid a double. Only show when no tab is visible
// (another tab/app, minimized, or closed) — exactly when the page can't alert.
const clientsArr = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const visible = clientsArr.some((c) => c.visibilityState === 'visible');
if (visible) return;
await self.registration.showNotification(title, options);
})());
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const { kind, id } = event.notification.data || {};
const url = '/home' + (id ? ('?openKind=' + encodeURIComponent(kind || 'dm') + '&openId=' + encodeURIComponent(id)) : '');
event.waitUntil((async () => {
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const c of all) {
if (c.url.includes('/home')) { try { await c.focus(); c.postMessage({ type: 'open-chat', kind, id }); return; } catch (_) {} }
}
try { await self.clients.openWindow(url); } catch (_) {}
})());
});