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
+10 -1
View File
@@ -266,4 +266,13 @@ const pollVotes = {
clearUser: (pollId, userId) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=?').run(pollId, userId),
};
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes };
const pushSubs = {
// Upsert by endpoint: re-subscribing the same browser updates its keys/owner.
add: ({ id, userId, endpoint, p256dh, auth }) =>
db.prepare('INSERT INTO push_subscriptions (id,user_id,endpoint,p256dh,auth,created_at) VALUES (?,?,?,?,?,?) ON CONFLICT(endpoint) DO UPDATE SET user_id=excluded.user_id, p256dh=excluded.p256dh, auth=excluded.auth')
.run(id, userId, endpoint, p256dh, auth, now()),
byUser: (userId) => db.prepare('SELECT * FROM push_subscriptions WHERE user_id=?').all(userId),
removeByEndpoint: (endpoint) => db.prepare('DELETE FROM push_subscriptions WHERE endpoint=?').run(endpoint),
};
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes, pushSubs };