/** * In-memory presence + peer registry for the staff live-chat module. * * Presence = *connected* AND *recently active*. A peer reports its own activity * (active/idle) over the socket; a user is: * - online → ≥1 connected peer that is currently active * - away → ≥1 connected peer but all are idle (no interaction ≥ idle window) * - offline → no connected peers * This is what makes a "logged in but away for an hour" user show as away, not * online. * * ⚠️ PER-PROCESS (same limitation as notificationBus). Correct ONLY while PM2 * runs a single instance (ecosystem.config.cjs → instances: 1). At 2+ instances * presence is split-brain (instance A can't see instance B's peers) and the * offline-vs-deliver decision is wrong for ~half of users. Before scaling, move * presence + fan-out onto a Redis backplane (publish presence/message events; * each instance re-emits locally; presence as a Redis hash with heartbeat TTL). * * Stashed on globalThis so HMR / multiple imports share one registry. */ const STALE_MS = 35000 // drop a peer we haven't heard a ping from in this long const SWEEP_MS = 30000 export interface PeerMeta { peer: any peerId: string userId: number roleId: number token: string active: boolean lastPong: number } interface Store { peers: Map byUser: Map> sweepTimer: any } const g = globalThis as any const store: Store = g.__chatPresence ?? (g.__chatPresence = { peers: new Map(), byUser: new Map>(), sweepTimer: null }) const now = () => Date.now() const send = (peer: any, frame: any) => { try { peer.send(JSON.stringify(frame)) } catch { /* peer gone */ } } export const statusOf = (userId: number): 'online' | 'away' | 'offline' => { const set = store.byUser.get(userId) if (!set || set.size === 0) return 'offline' for (const peerId of set) { const m = store.peers.get(peerId) if (m && m.active) return 'online' } return 'away' } const snapshot = () => { const online: number[] = [] const away: number[] = [] for (const userId of store.byUser.keys()) { const s = statusOf(userId) if (s === 'online') online.push(userId) else if (s === 'away') away.push(userId) } return { online, away } } /** Broadcast a frame to every connected staff peer on this instance. */ const broadcastAll = (frame: any) => { store.peers.forEach((m) => send(m.peer, frame)) } const broadcastPresence = (userId: number) => { broadcastAll({ type: 'presence', userId, status: statusOf(userId) }) } /** Send a frame to every peer (tab) belonging to one user. */ export const sendToUser = (userId: number, frame: any) => { const set = store.byUser.get(userId) if (!set) return for (const peerId of set) { const m = store.peers.get(peerId) if (m) send(m.peer, frame) } } export const isConnected = (userId: number) => { const set = store.byUser.get(userId) return !!set && set.size > 0 } export const getPeer = (peerId: string) => store.peers.get(peerId) const startSweep = () => { if (store.sweepTimer) return store.sweepTimer = setInterval(() => { const t = now() for (const [peerId, m] of store.peers) { if (t - m.lastPong > STALE_MS) { try { m.peer.terminate?.() } catch {} removePeer(peerId) } } }, SWEEP_MS) } const stopSweep = () => { if (store.sweepTimer) { clearInterval(store.sweepTimer) store.sweepTimer = null } } export const addPeer = (meta: Omit) => { const prev = statusOf(meta.userId) const full: PeerMeta = { ...meta, active: true, lastPong: now() } store.peers.set(meta.peerId, full) let set = store.byUser.get(meta.userId) if (!set) { set = new Set(); store.byUser.set(meta.userId, set) } set.add(meta.peerId) startSweep() // Tell the new peer it's connected, and hand it the current presence snapshot. send(full.peer, { type: 'connected', userId: meta.userId }) send(full.peer, { type: 'presence-snapshot', ...snapshot() }) // If this user just transitioned (offline→online), let everyone know. if (statusOf(meta.userId) !== prev) broadcastPresence(meta.userId) } export const removePeer = (peerId: string) => { const m = store.peers.get(peerId) if (!m) return const prev = statusOf(m.userId) store.peers.delete(peerId) const set = store.byUser.get(m.userId) if (set) { set.delete(peerId) if (set.size === 0) store.byUser.delete(m.userId) } if (store.peers.size === 0) stopSweep() if (statusOf(m.userId) !== prev) broadcastPresence(m.userId) } export const setActive = (peerId: string, active: boolean) => { const m = store.peers.get(peerId) if (!m) return const prev = statusOf(m.userId) m.active = active m.lastPong = now() if (statusOf(m.userId) !== prev) broadcastPresence(m.userId) } export const touch = (peerId: string) => { const m = store.peers.get(peerId) if (m) m.lastPong = now() }