feat: add poll start/end endpoints, poll.leading WS handler, and poll state persistence

Adds POST /:id/voting/start and POST /:id/voting/end endpoints that
broadcast poll lifecycle events and persist poll state to the sessions
table. The poll.leading WebSocket message is now handled server-side
(rebroadcast + DB persist) with self-healing for polls started before
the persistence columns existed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cottongin
2026-05-07 20:43:39 -04:00
parent 1c9f0ef280
commit 9cd601bab2
4 changed files with 158 additions and 0 deletions

View File

@@ -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 (

View File

@@ -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 {

View File

@@ -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}`);