diff --git a/server/db.js b/server/db.js index 3ad8760..4397b0c 100644 --- a/server/db.js +++ b/server/db.js @@ -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; diff --git a/server/package-lock.json b/server/package-lock.json index f746c44..76263a8 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index ab6c9bb..67a0b7e 100644 --- a/server/package.json +++ b/server/package.json @@ -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" + } } diff --git a/server/public/home.html b/server/public/home.html index da14806..225cf67 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -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{ 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. diff --git a/server/public/sw.js b/server/public/sw.js new file mode 100644 index 0000000..9e827b5 --- /dev/null +++ b/server/public/sw.js @@ -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 (_) {} + })()); +}); diff --git a/server/push.js b/server/push.js new file mode 100644 index 0000000..f1fcaab --- /dev/null +++ b/server/push.js @@ -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 }; diff --git a/server/repos.js b/server/repos.js index 34a6fc9..f256d1e 100644 --- a/server/repos.js +++ b/server/repos.js @@ -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 }; diff --git a/server/routes.js b/server/routes.js index bf1d340..3c2c161 100644 --- a/server/routes.js +++ b/server/routes.js @@ -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); });