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:
@@ -63,6 +63,33 @@ function initializeDatabase() {
|
|||||||
// Column already exists, ignore error
|
// 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
|
// Session games table
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS session_games (
|
CREATE TABLE IF NOT EXISTS session_games (
|
||||||
|
|||||||
@@ -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)
|
// Delete session (admin only)
|
||||||
router.delete('/:id', authenticateToken, (req, res) => {
|
router.delete('/:id', authenticateToken, (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const { WebSocketServer } = require('ws');
|
const { WebSocketServer } = require('ws');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { JWT_SECRET } = require('../middleware/auth');
|
const { JWT_SECRET } = require('../middleware/auth');
|
||||||
|
const db = require('../database');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket Manager for handling real-time session events
|
* WebSocket Manager for handling real-time session events
|
||||||
@@ -111,6 +112,35 @@ class WebSocketManager {
|
|||||||
clientInfo.currentPage = message.page || null;
|
clientInfo.currentPage = message.page || null;
|
||||||
this.broadcastPresence();
|
this.broadcastPresence();
|
||||||
break;
|
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:
|
default:
|
||||||
this.sendError(ws, `Unknown message type: ${message.type}`);
|
this.sendError(ws, `Unknown message type: ${message.type}`);
|
||||||
|
|||||||
@@ -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 |
|
| `subscribe` | `sessionId` | Subscribe to a session's events |
|
||||||
| `unsubscribe`| `sessionId` | Unsubscribe from a session |
|
| `unsubscribe`| `sessionId` | Unsubscribe from a session |
|
||||||
| `ping` | — | Heartbeat; server responds with `pong` |
|
| `ping` | — | Heartbeat; server responds with `pong` |
|
||||||
|
| `poll.leading` | `sessionId`, `gameId`, `label`, `votes` | Report current poll leader (rebroadcast to subscribers) |
|
||||||
|
|
||||||
### auth
|
### auth
|
||||||
```json
|
```json
|
||||||
@@ -139,6 +140,9 @@ Must be authenticated.
|
|||||||
| `game.status` | Periodic game state heartbeat every 20s (broadcast to subscribers) |
|
| `game.status` | Periodic game state heartbeat every 20s (broadcast to subscribers) |
|
||||||
| `player-count.updated` | Manual player count override (broadcast to subscribers) |
|
| `player-count.updated` | Manual player count override (broadcast to subscribers) |
|
||||||
| `vote.received` | Live vote recorded (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
|
## 7. Error Handling
|
||||||
|
|||||||
Reference in New Issue
Block a user