diff --git a/backend/database.js b/backend/database.js index 5b28ffc..9064810 100644 --- a/backend/database.js +++ b/backend/database.js @@ -63,6 +63,33 @@ function initializeDatabase() { // Column already exists, ignore error } + // Poll state columns on sessions + try { + db.exec(`ALTER TABLE sessions ADD COLUMN poll_active INTEGER DEFAULT 0`); + } catch (err) { + // Column already exists, ignore error + } + try { + db.exec(`ALTER TABLE sessions ADD COLUMN poll_started_at TEXT`); + } catch (err) { + // Column already exists, ignore error + } + try { + db.exec(`ALTER TABLE sessions ADD COLUMN poll_leading_game_id INTEGER`); + } catch (err) { + // Column already exists, ignore error + } + try { + db.exec(`ALTER TABLE sessions ADD COLUMN poll_leading_label TEXT`); + } catch (err) { + // Column already exists, ignore error + } + try { + db.exec(`ALTER TABLE sessions ADD COLUMN poll_leading_votes INTEGER`); + } catch (err) { + // Column already exists, ignore error + } + // Session games table db.exec(` CREATE TABLE IF NOT EXISTS session_games ( diff --git a/backend/routes/sessions.js b/backend/routes/sessions.js index a00cd0a..4425911 100644 --- a/backend/routes/sessions.js +++ b/backend/routes/sessions.js @@ -327,6 +327,64 @@ router.post('/:id/close', authenticateToken, (req, res) => { } }); +// Start voting/polling for a session (broadcasts poll.start to subscribers) +router.post('/:id/voting/start', authenticateToken, (req, res) => { + try { + const sessionId = parseInt(req.params.id); + const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId); + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + if (session.is_active === 0) { + return res.status(400).json({ error: 'Session is not active' }); + } + + db.prepare( + 'UPDATE sessions SET poll_active = 1, poll_started_at = ?, poll_leading_game_id = NULL, poll_leading_label = NULL, poll_leading_votes = NULL WHERE id = ?' + ).run(new Date().toISOString(), sessionId); + + const wsManager = getWebSocketManager(); + if (wsManager) { + wsManager.broadcastEvent('poll.start', { sessionId }, sessionId); + } + + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// End voting/polling for a session (broadcasts voting.ended to subscribers) +router.post('/:id/voting/end', authenticateToken, (req, res) => { + try { + const sessionId = parseInt(req.params.id); + const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId); + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + if (session.is_active === 0) { + return res.status(400).json({ error: 'Session is not active' }); + } + + db.prepare( + 'UPDATE sessions SET poll_active = 0, poll_started_at = NULL WHERE id = ?' + ).run(sessionId); + + const wsManager = getWebSocketManager(); + if (wsManager) { + wsManager.broadcastEvent('voting.ended', { sessionId }, sessionId); + } + + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // Delete session (admin only) router.delete('/:id', authenticateToken, (req, res) => { try { diff --git a/backend/utils/websocket-manager.js b/backend/utils/websocket-manager.js index ecfa3ed..e1bd85b 100644 --- a/backend/utils/websocket-manager.js +++ b/backend/utils/websocket-manager.js @@ -1,6 +1,7 @@ const { WebSocketServer } = require('ws'); const jwt = require('jsonwebtoken'); const { JWT_SECRET } = require('../middleware/auth'); +const db = require('../database'); /** * WebSocket Manager for handling real-time session events @@ -111,6 +112,35 @@ class WebSocketManager { clientInfo.currentPage = message.page || null; this.broadcastPresence(); break; + + case 'poll.leading': + if (!clientInfo.authenticated) { + this.sendError(ws, 'Not authenticated'); + return; + } + if (message.sessionId) { + this.broadcastEvent('poll.leading', { + sessionId: message.sessionId, + gameId: message.gameId, + label: message.label, + votes: message.votes + }, message.sessionId); + try { + const result = db.prepare( + 'UPDATE sessions SET poll_leading_game_id = ?, poll_leading_label = ?, poll_leading_votes = ? WHERE id = ? AND poll_active = 1' + ).run(message.gameId, message.label, message.votes, message.sessionId); + if (result.changes === 0) { + // Self-heal: poll.leading arrived but poll_active is 0 (poll started before persistence fix) + db.prepare( + 'UPDATE sessions SET poll_active = 1, poll_started_at = ?, poll_leading_game_id = ?, poll_leading_label = ?, poll_leading_votes = ? WHERE id = ? AND is_active = 1' + ).run(new Date().toISOString(), message.gameId, message.label, message.votes, message.sessionId); + console.log(`[WebSocket] Self-healed poll state for session ${message.sessionId}`); + } + } catch (err) { + console.error('[WebSocket] Failed to persist poll.leading:', err.message); + } + } + break; default: this.sendError(ws, `Unknown message type: ${message.type}`); diff --git a/docs/api/websocket.md b/docs/api/websocket.md index c99c57c..1035718 100644 --- a/docs/api/websocket.md +++ b/docs/api/websocket.md @@ -90,6 +90,7 @@ Obtain a JWT by calling `POST /api/auth/login` with your admin key. | `subscribe` | `sessionId` | Subscribe to a session's events | | `unsubscribe`| `sessionId` | Unsubscribe from a session | | `ping` | — | Heartbeat; server responds with `pong` | +| `poll.leading` | `sessionId`, `gameId`, `label`, `votes` | Report current poll leader (rebroadcast to subscribers) | ### auth ```json @@ -139,6 +140,9 @@ Must be authenticated. | `game.status` | Periodic game state heartbeat every 20s (broadcast to subscribers) | | `player-count.updated` | Manual player count override (broadcast to subscribers) | | `vote.received` | Live vote recorded (broadcast to subscribers) | +| `voting.ended` | Host ended the voting/polling period (broadcast to subscribers) | +| `poll.start` | Host started a new poll (broadcast to subscribers) | +| `poll.leading` | Current poll leader updated (broadcast to subscribers) | --- @@ -384,6 +388,45 @@ Possible `reason` values: `room_closed`, `room_not_found`, `connection_failed`, } ``` +### voting.ended + +- **Broadcast to:** Clients subscribed to the session +- **Triggered by:** `POST /api/sessions/:id/voting/end` (host ends the voting/polling period) + +**Data:** +```json +{ + "sessionId": 3 +} +``` + +### poll.start + +- **Broadcast to:** Clients subscribed to the session +- **Triggered by:** `POST /api/sessions/:id/voting/start` (host starts a new poll) + +**Data:** +```json +{ + "sessionId": 3 +} +``` + +### poll.leading + +- **Broadcast to:** Clients subscribed to the session +- **Triggered by:** Downstream voting client sends `poll.leading` message (rebroadcast to all session subscribers) + +**Data:** +```json +{ + "sessionId": 3, + "gameId": 42, + "label": "Quiplash 3", + "votes": 7 +} +``` + --- ## 7. Error Handling