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:
@@ -287,4 +287,18 @@ CREATE INDEX IF NOT EXISTS idx_rec_team ON recordings(team_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_room ON recordings(room);
|
||||
`);
|
||||
|
||||
// Web Push subscriptions (one per browser/device per user) for background/closed-tab
|
||||
// notifications. endpoint is unique; p256dh+auth are the encryption keys from the browser.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id);
|
||||
`);
|
||||
|
||||
module.exports = db;
|
||||
|
||||
Generated
+175
@@ -8,6 +8,7 @@
|
||||
"name": "bizgaze-support-server",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -17,6 +18,135 @@
|
||||
"nodemailer": "^6.9.14"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.3",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
@@ -27,6 +157,51 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"license": "MIT",
|
||||
|
||||
+13
-4
@@ -3,8 +3,17 @@
|
||||
"version": "2.0.0",
|
||||
"description": "BizGaze Support — remote screen sharing: public landing, agent console, sessions, SSO + webhook for BizGaze integration",
|
||||
"main": "server.js",
|
||||
"scripts": { "start": "node server.js" },
|
||||
"engines": { "node": ">=22.5.0" },
|
||||
"dependencies": { "ws": "^8.18.0" },
|
||||
"optionalDependencies": { "nodemailer": "^6.9.14" }
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"nodemailer": "^6.9.14"
|
||||
}
|
||||
}
|
||||
|
||||
+28
-7
@@ -743,7 +743,7 @@ function openSettings(){
|
||||
const setPref=(k,v)=>{ try{ localStorage.setItem('notif_'+k, v?'on':'off'); }catch(_){} };
|
||||
ov.querySelector('#setGroup').onchange=e=>setPref('group', e.target.checked);
|
||||
ov.querySelector('#setDm').onchange=e=>setPref('dm', e.target.checked);
|
||||
const perm=ov.querySelector('#setPerm'); if(perm) perm.onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ perm.textContent='Enabled'; perm.disabled=true; toast('Desktop notifications enabled'); } else toast('Notifications blocked — allow them in your browser site settings'); }catch(_){ toast('Notifications need HTTPS or localhost'); } };
|
||||
const perm=ov.querySelector('#setPerm'); if(perm) perm.onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ perm.textContent='Enabled'; perm.disabled=true; toast('Desktop notifications enabled'); try{ await subscribePush(); }catch(_){} } else toast('Notifications blocked — allow them in your browser site settings'); }catch(_){ toast('Notifications need HTTPS or localhost'); } };
|
||||
}
|
||||
|
||||
// ---------- Chat (1:1 + groups) ----------
|
||||
@@ -1443,11 +1443,28 @@ function ensureNotifyPermission(){ try{ if('Notification' in window && Notificat
|
||||
// Notification preferences (set in Dashboard → Settings; stored per browser). Default ON.
|
||||
function notifOn(kind){ try{ return localStorage.getItem('notif_'+kind)!=='off'; }catch(_){ return true; } }
|
||||
document.addEventListener('click', ensureNotifyPermission, { once:true });
|
||||
// System notification for a new message (shown when the tab isn't focused).
|
||||
// Open a chat from a notification (called via the service worker, or the fallback path).
|
||||
// Remove the old service worker (an earlier attempt) so it can't interfere.
|
||||
if('serviceWorker' in navigator){ try{ navigator.serviceWorker.getRegistrations().then(rs=>rs.forEach(r=>r.unregister())).catch(()=>{}); }catch(_){} }
|
||||
// Open the chat from a notification. Navigation reliably repaints across browsers (a
|
||||
// ----- Web Push: reliable notifications when the tab is backgrounded / frozen / closed -----
|
||||
// A notification-only service worker (sw.js, no caching) shows OS popups via the push service.
|
||||
// pushActive => the page can leave hidden-tab popups to push (avoids double-notifying).
|
||||
let pushActive=false, _swReg=null;
|
||||
function urlB64ToUint8(base64){ const pad='='.repeat((4-base64.length%4)%4); const b=(base64+pad).replace(/-/g,'+').replace(/_/g,'/'); const raw=atob(b); const arr=new Uint8Array(raw.length); for(let i=0;i<raw.length;i++) arr[i]=raw.charCodeAt(i); return arr; }
|
||||
async function setupPush(){
|
||||
if(!('serviceWorker' in navigator)) return;
|
||||
try{ _swReg=await navigator.serviceWorker.register('/sw.js'); }catch(_){ return; }
|
||||
// Clicking an OS notification asks us (if a tab is already open) to open that chat in place.
|
||||
try{ navigator.serviceWorker.addEventListener('message',(e)=>{ const d=e.data||{}; if(d.type==='open-chat' && d.id){ try{ selectChat(d.kind||'dm', d.id); }catch(_){} } }); }catch(_){}
|
||||
try{ await subscribePush(); }catch(_){}
|
||||
}
|
||||
async function subscribePush(){
|
||||
if(!_swReg || !('PushManager' in window)) return;
|
||||
if(!('Notification' in window) || Notification.permission!=='granted') return; // only once allowed
|
||||
let cfg; try{ cfg=await fetch('/api/push/vapid').then(r=>r.json()); }catch(_){ return; }
|
||||
if(!cfg || !cfg.enabled || !cfg.key) return; // server hasn't configured VAPID -> stay on in-page notifs
|
||||
let sub=null; try{ sub=await _swReg.pushManager.getSubscription(); }catch(_){}
|
||||
if(!sub){ try{ sub=await _swReg.pushManager.subscribe({ userVisibleOnly:true, applicationServerKey:urlB64ToUint8(cfg.key) }); }catch(_){ return; } }
|
||||
try{ await postJSON('/api/push/subscribe', sub.toJSON()); pushActive=true; }catch(_){}
|
||||
}
|
||||
// Open the chat from an in-page notification. Navigation reliably repaints across browsers (a
|
||||
// notification click is not an in-page gesture, so an in-place open won't paint until you
|
||||
// tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first.
|
||||
function notify(title, body, kind, id){
|
||||
@@ -1509,7 +1526,10 @@ function onChatMessage(m){
|
||||
const isSys=!!m.system || m.from==='__system__'; // activity lines: show in chat, but no ping/notify/unread
|
||||
if(m.from!==ME.id && !isSys){
|
||||
if(!isGroupMsg && chatWs && chatWs.readyState===1){ try{ chatWs.send(JSON.stringify({type:'chat-delivered', id:m.id})); }catch(_){} } // ack DM delivery
|
||||
if(notifOn(kind)){ playPing(); if(!(isOpen && !document.hidden)) notify((m.fromName||'New message'), m.body?(m.body.length>80?m.body.slice(0,80)+'…':m.body):'Sent an attachment', kind, rid); }
|
||||
// Popup rule: ping always. In-page popup only when the tab is VISIBLE but you're on another
|
||||
// chat. When the tab is HIDDEN, let Web Push show it (the SW). If push isn't active, fall
|
||||
// back to an in-page popup so hidden-tab users still get alerted.
|
||||
if(notifOn(kind)){ playPing(); const wantPopup=!(isOpen && !document.hidden); if(wantPopup && !(document.hidden && pushActive)) notify((m.fromName||'New message'), m.body?(m.body.length>80?m.body.slice(0,80)+'…':m.body):'Sent an attachment', kind, rid); }
|
||||
// Activity-center entries for things easy to miss.
|
||||
if(m.poll) addNotif({icon:'barChart', text:pEsc(m.fromName||'Someone')+' created a poll'+(m.poll.question?': '+pEsc(m.poll.question):''), link:{kind, id:rid}});
|
||||
else if(kind==='dm' && wasNew) addNotif({icon:'chat', text:'New chat from '+pEsc(m.fromName||'someone'), link:{kind:'dm', id:rid}});
|
||||
@@ -2285,6 +2305,7 @@ async function doRegister(){
|
||||
document.getElementById('hdrRight').innerHTML=bellHTML()+profileHTML(me);
|
||||
loadNotifs(); wireBell(); wireProfile();
|
||||
connectChatWs();
|
||||
setupPush(); // register the notification service worker + subscribe to Web Push (if granted)
|
||||
document.getElementById('loading').style.display='none';
|
||||
// Fast-path: when opened from a notification, show the chat immediately (only needs the
|
||||
// thread fetch) and load the sidebar in the background — don't make the reload wait on it.
|
||||
|
||||
@@ -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 (_) {}
|
||||
})());
|
||||
});
|
||||
@@ -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 };
|
||||
+10
-1
@@ -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 };
|
||||
|
||||
+29
-1
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user