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
+45
View File
@@ -0,0 +1,45 @@
// Web Push (background / closed-tab / mobile notifications). Fully optional:
// - if the `web-push` package isn't installed, or VAPID env keys aren't set,
// isEnabled() is false and every call is a silent no-op (the app is unaffected).
// Configure in production by setting:
// VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT (e.g. mailto:admin@bizgaze.com)
// Generate a key pair once with: npx web-push generate-vapid-keys
const R = require('./repos');
let webpush = null;
try { webpush = require('web-push'); } catch (_) { /* package not installed -> push disabled */ }
const PUBLIC = process.env.VAPID_PUBLIC_KEY || '';
const PRIVATE = process.env.VAPID_PRIVATE_KEY || '';
const SUBJECT = process.env.VAPID_SUBJECT || 'mailto:support@bizgaze.com';
let ready = false;
if (webpush && PUBLIC && PRIVATE) {
try { webpush.setVapidDetails(SUBJECT, PUBLIC, PRIVATE); ready = true; }
catch (e) { console.warn('[push] invalid VAPID config:', e.message); }
}
if (!ready) console.log('[push] Web Push disabled (set web-push + VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY to enable).');
function isEnabled() { return ready; }
function publicKey() { return ready ? PUBLIC : ''; }
// Fire-and-forget push to every device the user has subscribed. Dead subscriptions
// (410 Gone / 404) are pruned. Never throws.
async function sendToUser(userId, payload) {
if (!ready) return;
let subs = [];
try { subs = R.pushSubs.byUser(userId); } catch (_) { return; }
const data = JSON.stringify(payload || {});
for (const s of subs) {
const sub = { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } };
try {
await webpush.sendNotification(sub, data, { TTL: 600 });
} catch (err) {
const code = err && err.statusCode;
if (code === 404 || code === 410) { try { R.pushSubs.removeByEndpoint(s.endpoint); } catch (_) {} }
// other errors (network, 4xx) are ignored — push is best-effort
}
}
}
module.exports = { isEnabled, publicKey, sendToUser };