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
+29 -1
View File
@@ -7,6 +7,7 @@ const A = require('./auth');
const BZ = require('./bizgaze');
const W = require('./webhooks');
const CHAT = require('./chat');
const PUSH = require('./push');
const MSG_MAX = 4000;
const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } };
const SYSTEM_SENDER = '__system__';
@@ -267,6 +268,26 @@ route('GET', '/api/me', async (req, res) => {
json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null, avatarUrl: u.avatar_url || null });
});
// --- Web Push: background/closed-tab notifications (no-op unless VAPID is configured) ---
route('GET', '/api/push/vapid', async (req, res) => {
json(res, 200, { enabled: PUSH.isEnabled(), key: PUSH.publicKey() });
});
route('POST', '/api/push/subscribe', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const sub = await readBody(req);
if (!sub || !sub.endpoint || !sub.keys || !sub.keys.p256dh || !sub.keys.auth) return json(res, 400, { error: 'invalid subscription' });
try { R.pushSubs.add({ id: A.id(), userId: u.id, endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth }); } catch (_) {}
json(res, 200, { ok: true });
});
route('POST', '/api/push/unsubscribe', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { endpoint } = await readBody(req);
if (endpoint) { try { R.pushSubs.removeByEndpoint(endpoint); } catch (_) {} }
json(res, 200, { ok: true });
});
// ---------- BizGaze SSO: agent arrives already logged in ----------
route('GET', '/sso', async (req, res) => {
if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); }
@@ -1147,7 +1168,12 @@ route('POST', '/api/messages', async (req, res) => {
const dto = buildMsgDTO(R.messages.byId(id), namesFor(u.team_id), u.id);
dto.fromName = u.name || u.email;
const push = { type: 'chat-message', message: dto };
for (const mid of R.conversations.members(group)) { try { CHAT.pushToUser(mid, push); } catch (_) {} } // includes sender's other tabs
const conv = R.conversations.byId(group); const gname = (conv && conv.name) || 'Group';
const pushBody = (u.name || u.email) + ': ' + (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment');
for (const mid of R.conversations.members(group)) {
try { CHAT.pushToUser(mid, push); } catch (_) {} // includes sender's other tabs
if (mid !== u.id) PUSH.sendToUser(mid, { title: gname, body: pushBody, kind: 'group', id: group, tag: 'group:' + group });
}
return json(res, 200, dto);
}
if (!to) return json(res, 400, { error: 'to or group required' });
@@ -1157,6 +1183,8 @@ route('POST', '/api/messages', async (req, res) => {
const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } };
try { CHAT.pushToUser(to, push); } catch (_) {}
try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices
// Background/closed-tab push to the recipient (opens the DM with this sender).
PUSH.sendToUser(to, { title: (u.name || u.email), body: (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment'), kind: 'dm', id: u.id, tag: 'dm:' + u.id });
json(res, 200, dto);
});