From 242150d54cbb35e6f5c54abc921ca66d712aed6f Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 23 Mar 2026 09:54:36 -0400 Subject: [PATCH] feat: WebSocket presence tracking with page_focus and presence_update Made-with: Cursor --- backend/utils/websocket-manager.js | 41 +++++++++++- tests/api/named-admins.test.js | 100 +++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/backend/utils/websocket-manager.js b/backend/utils/websocket-manager.js index a392a5c..bba5e62 100644 --- a/backend/utils/websocket-manager.js +++ b/backend/utils/websocket-manager.js @@ -32,6 +32,8 @@ class WebSocketManager { const clientInfo = { authenticated: false, userId: null, + adminName: null, + currentPage: null, subscribedSessions: new Set(), lastPing: Date.now() }; @@ -96,6 +98,15 @@ class WebSocketManager { clientInfo.lastPing = Date.now(); this.send(ws, { type: 'pong' }); break; + + case 'page_focus': + if (!clientInfo.authenticated) { + this.sendError(ws, 'Not authenticated'); + return; + } + clientInfo.currentPage = message.page || null; + this.broadcastPresence(); + break; default: this.sendError(ws, `Unknown message type: ${message.type}`); @@ -117,7 +128,13 @@ class WebSocketManager { if (clientInfo) { clientInfo.authenticated = true; - clientInfo.userId = decoded.role; // 'admin' for now + clientInfo.userId = decoded.role; + clientInfo.adminName = decoded.name || null; + + if (!decoded.name) { + this.sendError(ws, 'Token missing admin identity, please re-login', 'auth_error'); + return; + } this.send(ws, { type: 'auth_success', @@ -283,9 +300,31 @@ class WebSocketManager { this.clients.delete(ws); console.log('[WebSocket] Client disconnected and cleaned up'); + this.broadcastPresence(); } } + broadcastPresence() { + const admins = []; + this.clients.forEach((info) => { + if (info.authenticated && info.adminName && info.currentPage) { + admins.push({ name: info.adminName, page: info.currentPage }); + } + }); + + const message = { + type: 'presence_update', + timestamp: new Date().toISOString(), + admins + }; + + this.clients.forEach((info, ws) => { + if (info.authenticated && ws.readyState === ws.OPEN) { + this.send(ws, message); + } + }); + } + /** * Start heartbeat to detect dead connections */ diff --git a/tests/api/named-admins.test.js b/tests/api/named-admins.test.js index e4729c4..a0e6ab9 100644 --- a/tests/api/named-admins.test.js +++ b/tests/api/named-admins.test.js @@ -125,3 +125,103 @@ describe('POST /api/auth/login — named admins', () => { expect(res.status).toBe(401); }); }); + +const WebSocket = require('ws'); +const jwt = require('jsonwebtoken'); +const http = require('http'); + +describe('WebSocket presence', () => { + let server, wsUrl; + + beforeAll((done) => { + process.env.ADMIN_KEY = 'test-admin-key'; + process.env.ADMIN_CONFIG_PATH = '/tmp/nonexistent-admins.json'; + jest.resetModules(); + const { app } = require('../../backend/server'); + const { WebSocketManager, setWebSocketManager } = require('../../backend/utils/websocket-manager'); + + server = http.createServer(app); + const wsManager = new WebSocketManager(server); + setWebSocketManager(wsManager); + + server.listen(0, () => { + const port = server.address().port; + wsUrl = `ws://localhost:${port}/api/sessions/live`; + done(); + }); + }); + + afterAll((done) => { + server.close(done); + }); + + function makeToken(name) { + return jwt.sign({ role: 'admin', name }, process.env.JWT_SECRET, { expiresIn: '1h' }); + } + + function connectAndAuth(name) { + return new Promise((resolve) => { + const ws = new WebSocket(wsUrl); + ws.on('open', () => { + ws.send(JSON.stringify({ type: 'auth', token: makeToken(name) })); + }); + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === 'auth_success') { + resolve(ws); + } + }); + }); + } + + function waitForMessage(ws, type) { + return new Promise((resolve) => { + const handler = (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === type) { + ws.off('message', handler); + resolve(msg); + } + }; + ws.on('message', handler); + }); + } + + test('page_focus triggers presence_update with admin name and page', async () => { + const ws1 = await connectAndAuth('Alice'); + const ws2 = await connectAndAuth('Bob'); + + const presencePromise = waitForMessage(ws2, 'presence_update'); + + ws1.send(JSON.stringify({ type: 'page_focus', page: '/history' })); + + const msg = await presencePromise; + expect(msg.admins).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Alice', page: '/history' }) + ]) + ); + + ws1.close(); + ws2.close(); + }); + + test('disconnect removes admin from presence', async () => { + const ws1 = await connectAndAuth('Alice'); + const ws2 = await connectAndAuth('Bob'); + + ws1.send(JSON.stringify({ type: 'page_focus', page: '/picker' })); + ws2.send(JSON.stringify({ type: 'page_focus', page: '/picker' })); + + await new Promise(r => setTimeout(r, 100)); + + const presencePromise = waitForMessage(ws2, 'presence_update'); + ws1.close(); + + const msg = await presencePromise; + const names = msg.admins.map(a => a.name); + expect(names).not.toContain('Alice'); + + ws2.close(); + }); +});