Add 20-second game.status WebSocket heartbeat from active shard monitors containing full game state, and GET /status-live REST endpoint for on-demand polling. Fix missing token destructuring in SessionInfo causing crash. Relax frontend polling from 3s to 60s since WebSocket events now cover real-time updates. Bump version to 0.6.0. Made-with: Cursor
988 lines
28 KiB
JavaScript
988 lines
28 KiB
JavaScript
const express = require('express');
|
|
const crypto = require('crypto');
|
|
const { authenticateToken } = require('../middleware/auth');
|
|
const db = require('../database');
|
|
const { triggerWebhook } = require('../utils/webhooks');
|
|
const { getWebSocketManager } = require('../utils/websocket-manager');
|
|
const { startMonitor, stopMonitor, getMonitorSnapshot } = require('../utils/ecast-shard-client');
|
|
|
|
const router = express.Router();
|
|
|
|
// Helper function to create a hash of a message
|
|
function createMessageHash(username, message, timestamp) {
|
|
return crypto
|
|
.createHash('sha256')
|
|
.update(`${username}:${message}:${timestamp}`)
|
|
.digest('hex');
|
|
}
|
|
|
|
// Get all sessions
|
|
router.get('/', (req, res) => {
|
|
try {
|
|
const sessions = db.prepare(`
|
|
SELECT
|
|
s.*,
|
|
COUNT(sg.id) as games_played
|
|
FROM sessions s
|
|
LEFT JOIN session_games sg ON s.id = sg.session_id
|
|
GROUP BY s.id
|
|
ORDER BY s.created_at DESC
|
|
`).all();
|
|
|
|
res.json(sessions);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Get active session
|
|
router.get('/active', (req, res) => {
|
|
try {
|
|
const session = db.prepare(`
|
|
SELECT
|
|
s.*,
|
|
COUNT(sg.id) as games_played
|
|
FROM sessions s
|
|
LEFT JOIN session_games sg ON s.id = sg.session_id
|
|
WHERE s.is_active = 1
|
|
GROUP BY s.id
|
|
LIMIT 1
|
|
`).get();
|
|
|
|
// Return null instead of 404 when no active session
|
|
if (!session) {
|
|
return res.json({ session: null, message: 'No active session' });
|
|
}
|
|
|
|
res.json(session);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Get single session by ID
|
|
router.get('/:id', (req, res) => {
|
|
try {
|
|
const session = db.prepare(`
|
|
SELECT
|
|
s.*,
|
|
COUNT(sg.id) as games_played
|
|
FROM sessions s
|
|
LEFT JOIN session_games sg ON s.id = sg.session_id
|
|
WHERE s.id = ?
|
|
GROUP BY s.id
|
|
`).get(req.params.id);
|
|
|
|
if (!session) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
|
|
res.json(session);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Create new session (admin only)
|
|
router.post('/', authenticateToken, (req, res) => {
|
|
try {
|
|
const { notes } = req.body;
|
|
|
|
// Check if there's already an active session
|
|
const activeSession = db.prepare('SELECT id FROM sessions WHERE is_active = 1').get();
|
|
|
|
if (activeSession) {
|
|
return res.status(400).json({
|
|
error: 'An active session already exists. Please close it before creating a new one.',
|
|
activeSessionId: activeSession.id
|
|
});
|
|
}
|
|
|
|
const stmt = db.prepare(`
|
|
INSERT INTO sessions (notes, is_active)
|
|
VALUES (?, 1)
|
|
`);
|
|
|
|
const result = stmt.run(notes || null);
|
|
const newSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get(result.lastInsertRowid);
|
|
|
|
// Broadcast session.started event via WebSocket to all authenticated clients
|
|
try {
|
|
const wsManager = getWebSocketManager();
|
|
if (wsManager) {
|
|
const eventData = {
|
|
session: {
|
|
id: newSession.id,
|
|
is_active: 1,
|
|
created_at: newSession.created_at,
|
|
notes: newSession.notes
|
|
}
|
|
};
|
|
|
|
wsManager.broadcastToAll('session.started', eventData);
|
|
console.log(`[Sessions] Broadcasted session.started event for session ${newSession.id} to all clients`);
|
|
}
|
|
} catch (error) {
|
|
// Log error but don't fail the request
|
|
console.error('Error broadcasting session.started event:', error);
|
|
}
|
|
|
|
res.status(201).json(newSession);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Close/finalize session (admin only)
|
|
router.post('/:id/close', authenticateToken, (req, res) => {
|
|
try {
|
|
const { notes } = req.body;
|
|
|
|
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id);
|
|
|
|
if (!session) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
|
|
if (session.is_active === 0) {
|
|
return res.status(400).json({ error: 'Session is already closed' });
|
|
}
|
|
|
|
// Set all 'playing' games to 'played' before closing
|
|
db.prepare(`
|
|
UPDATE session_games
|
|
SET status = 'played'
|
|
WHERE session_id = ? AND status = 'playing'
|
|
`).run(req.params.id);
|
|
|
|
const stmt = db.prepare(`
|
|
UPDATE sessions
|
|
SET is_active = 0, closed_at = CURRENT_TIMESTAMP, notes = COALESCE(?, notes)
|
|
WHERE id = ?
|
|
`);
|
|
|
|
stmt.run(notes || null, req.params.id);
|
|
|
|
// Get updated session with games count
|
|
const closedSession = db.prepare(`
|
|
SELECT
|
|
s.*,
|
|
COUNT(sg.id) as games_played
|
|
FROM sessions s
|
|
LEFT JOIN session_games sg ON s.id = sg.session_id
|
|
WHERE s.id = ?
|
|
GROUP BY s.id
|
|
`).get(req.params.id);
|
|
|
|
// Broadcast session.ended event via WebSocket
|
|
try {
|
|
const wsManager = getWebSocketManager();
|
|
if (wsManager) {
|
|
const eventData = {
|
|
session: {
|
|
id: closedSession.id,
|
|
is_active: 0,
|
|
games_played: closedSession.games_played
|
|
}
|
|
};
|
|
|
|
wsManager.broadcastEvent('session.ended', eventData, parseInt(req.params.id));
|
|
console.log(`[Sessions] Broadcasted session.ended event for session ${req.params.id}`);
|
|
}
|
|
} catch (error) {
|
|
// Log error but don't fail the request
|
|
console.error('Error broadcasting session.ended event:', error);
|
|
}
|
|
|
|
res.json(closedSession);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Delete session (admin only)
|
|
router.delete('/:id', authenticateToken, (req, res) => {
|
|
try {
|
|
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id);
|
|
|
|
if (!session) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
|
|
// Prevent deletion of active sessions
|
|
if (session.is_active === 1) {
|
|
return res.status(400).json({ error: 'Cannot delete an active session. Please close it first.' });
|
|
}
|
|
|
|
// Delete related data first (cascade)
|
|
db.prepare('DELETE FROM chat_logs WHERE session_id = ?').run(req.params.id);
|
|
db.prepare('DELETE FROM session_games WHERE session_id = ?').run(req.params.id);
|
|
|
|
// Delete the session
|
|
db.prepare('DELETE FROM sessions WHERE id = ?').run(req.params.id);
|
|
|
|
res.json({ message: 'Session deleted successfully', sessionId: parseInt(req.params.id) });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Get games played in a session
|
|
router.get('/:id/games', (req, res) => {
|
|
try {
|
|
const games = db.prepare(`
|
|
SELECT
|
|
sg.*,
|
|
g.pack_name,
|
|
g.title,
|
|
g.game_type,
|
|
g.min_players,
|
|
g.max_players,
|
|
g.popularity_score,
|
|
g.upvotes,
|
|
g.downvotes
|
|
FROM session_games sg
|
|
JOIN games g ON sg.game_id = g.id
|
|
WHERE sg.session_id = ?
|
|
ORDER BY sg.played_at ASC
|
|
`).all(req.params.id);
|
|
|
|
res.json(games);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Get vote breakdown for a session
|
|
router.get('/:id/votes', (req, res) => {
|
|
try {
|
|
const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(req.params.id);
|
|
|
|
if (!session) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
|
|
const votes = db.prepare(`
|
|
SELECT
|
|
lv.game_id,
|
|
g.title,
|
|
g.pack_name,
|
|
SUM(CASE WHEN lv.vote_type = 1 THEN 1 ELSE 0 END) AS upvotes,
|
|
SUM(CASE WHEN lv.vote_type = -1 THEN 1 ELSE 0 END) AS downvotes,
|
|
SUM(lv.vote_type) AS net_score,
|
|
COUNT(*) AS total_votes
|
|
FROM live_votes lv
|
|
JOIN games g ON lv.game_id = g.id
|
|
WHERE lv.session_id = ?
|
|
GROUP BY lv.game_id
|
|
ORDER BY net_score DESC
|
|
`).all(req.params.id);
|
|
|
|
res.json({
|
|
session_id: parseInt(req.params.id),
|
|
votes,
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Add game to session (admin only)
|
|
router.post('/:id/games', authenticateToken, (req, res) => {
|
|
try {
|
|
const { game_id, manually_added, room_code } = req.body;
|
|
|
|
if (!game_id) {
|
|
return res.status(400).json({ error: 'game_id is required' });
|
|
}
|
|
|
|
// Verify session exists and is active
|
|
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id);
|
|
|
|
if (!session) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
|
|
if (session.is_active === 0) {
|
|
return res.status(400).json({ error: 'Cannot add games to a closed session' });
|
|
}
|
|
|
|
// Verify game exists
|
|
const game = db.prepare('SELECT * FROM games WHERE id = ?').get(game_id);
|
|
|
|
if (!game) {
|
|
return res.status(404).json({ error: 'Game not found' });
|
|
}
|
|
|
|
// Set all current 'playing' games to 'played' (except skipped ones)
|
|
db.prepare(`
|
|
UPDATE session_games
|
|
SET status = CASE
|
|
WHEN status = 'skipped' THEN 'skipped'
|
|
ELSE 'played'
|
|
END
|
|
WHERE session_id = ? AND status = 'playing'
|
|
`).run(req.params.id);
|
|
|
|
// Add game to session with 'playing' status
|
|
const stmt = db.prepare(`
|
|
INSERT INTO session_games (session_id, game_id, manually_added, status, room_code)
|
|
VALUES (?, ?, ?, 'playing', ?)
|
|
`);
|
|
|
|
const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0, room_code || null);
|
|
|
|
// Increment play count for the game
|
|
db.prepare('UPDATE games SET play_count = play_count + 1 WHERE id = ?').run(game_id);
|
|
|
|
const sessionGame = db.prepare(`
|
|
SELECT
|
|
sg.*,
|
|
g.pack_name,
|
|
g.title,
|
|
g.game_type,
|
|
g.min_players,
|
|
g.max_players
|
|
FROM session_games sg
|
|
JOIN games g ON sg.game_id = g.id
|
|
WHERE sg.id = ?
|
|
`).get(result.lastInsertRowid);
|
|
|
|
// Trigger webhook and WebSocket for game.added event
|
|
try {
|
|
const sessionStats = db.prepare(`
|
|
SELECT
|
|
s.*,
|
|
COUNT(sg.id) as games_played
|
|
FROM sessions s
|
|
LEFT JOIN session_games sg ON s.id = sg.session_id
|
|
WHERE s.id = ?
|
|
GROUP BY s.id
|
|
`).get(req.params.id);
|
|
|
|
const eventData = {
|
|
session: {
|
|
id: sessionStats.id,
|
|
is_active: sessionStats.is_active === 1,
|
|
games_played: sessionStats.games_played
|
|
},
|
|
game: {
|
|
id: game.id,
|
|
title: game.title,
|
|
pack_name: game.pack_name,
|
|
min_players: game.min_players,
|
|
max_players: game.max_players,
|
|
manually_added: manually_added || false,
|
|
room_code: room_code || null
|
|
}
|
|
};
|
|
|
|
// Trigger webhook (for backwards compatibility)
|
|
triggerWebhook('game.added', eventData);
|
|
|
|
// Broadcast via WebSocket (new preferred method)
|
|
const wsManager = getWebSocketManager();
|
|
if (wsManager) {
|
|
wsManager.broadcastEvent('game.added', eventData, parseInt(req.params.id));
|
|
}
|
|
} catch (error) {
|
|
// Log error but don't fail the request
|
|
console.error('Error triggering notifications:', error);
|
|
}
|
|
|
|
// Automatically start room monitoring if room code was provided
|
|
if (room_code) {
|
|
try {
|
|
startMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players);
|
|
} catch (error) {
|
|
console.error('Error starting room monitor:', error);
|
|
}
|
|
}
|
|
|
|
res.status(201).json(sessionGame);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Import chat log and process popularity (admin only)
|
|
router.post('/:id/chat-import', authenticateToken, (req, res) => {
|
|
try {
|
|
const { chatData } = req.body;
|
|
|
|
if (!chatData || !Array.isArray(chatData)) {
|
|
return res.status(400).json({ error: 'chatData must be an array' });
|
|
}
|
|
|
|
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id);
|
|
|
|
if (!session) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
|
|
// Get all games played in this session with timestamps
|
|
const sessionGames = db.prepare(`
|
|
SELECT sg.game_id, sg.played_at, g.title, g.upvotes, g.downvotes
|
|
FROM session_games sg
|
|
JOIN games g ON sg.game_id = g.id
|
|
WHERE sg.session_id = ?
|
|
ORDER BY sg.played_at ASC
|
|
`).all(req.params.id);
|
|
|
|
if (sessionGames.length === 0) {
|
|
return res.status(400).json({ error: 'No games played in this session to match votes against' });
|
|
}
|
|
|
|
let votesProcessed = 0;
|
|
let duplicatesSkipped = 0;
|
|
const votesByGame = {};
|
|
const sessionId = parseInt(req.params.id);
|
|
const voteMatches = []; // Debug: track which votes matched to which games
|
|
|
|
const insertChatLog = db.prepare(`
|
|
INSERT INTO chat_logs (session_id, chatter_name, message, timestamp, parsed_vote, message_hash)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`);
|
|
|
|
const checkDuplicate = db.prepare(`
|
|
SELECT id FROM chat_logs WHERE message_hash = ? LIMIT 1
|
|
`);
|
|
|
|
const updateUpvote = db.prepare(`
|
|
UPDATE games SET upvotes = upvotes + 1, popularity_score = popularity_score + 1 WHERE id = ?
|
|
`);
|
|
|
|
const updateDownvote = db.prepare(`
|
|
UPDATE games SET downvotes = downvotes + 1, popularity_score = popularity_score - 1 WHERE id = ?
|
|
`);
|
|
|
|
const processVotes = db.transaction((messages) => {
|
|
for (const msg of messages) {
|
|
const { username, message, timestamp } = msg;
|
|
|
|
if (!username || !message || !timestamp) {
|
|
continue;
|
|
}
|
|
|
|
// Create hash for this message
|
|
const messageHash = createMessageHash(username, message, timestamp);
|
|
|
|
// Check if we've already imported this exact message
|
|
const existing = checkDuplicate.get(messageHash);
|
|
if (existing) {
|
|
duplicatesSkipped++;
|
|
continue; // Skip this duplicate message
|
|
}
|
|
|
|
// Check for vote patterns
|
|
let vote = null;
|
|
if (message.includes('thisgame++')) {
|
|
vote = 'thisgame++';
|
|
} else if (message.includes('thisgame--')) {
|
|
vote = 'thisgame--';
|
|
}
|
|
|
|
// Insert into chat logs with hash
|
|
insertChatLog.run(
|
|
sessionId,
|
|
username,
|
|
message,
|
|
timestamp,
|
|
vote,
|
|
messageHash
|
|
);
|
|
|
|
if (vote) {
|
|
// Find which game was being played at this timestamp
|
|
const messageTime = new Date(timestamp).getTime();
|
|
let matchedGame = null;
|
|
|
|
for (let i = 0; i < sessionGames.length; i++) {
|
|
const currentGame = sessionGames[i];
|
|
const nextGame = sessionGames[i + 1];
|
|
|
|
const currentGameTime = new Date(currentGame.played_at).getTime();
|
|
|
|
if (nextGame) {
|
|
const nextGameTime = new Date(nextGame.played_at).getTime();
|
|
if (messageTime >= currentGameTime && messageTime < nextGameTime) {
|
|
matchedGame = currentGame;
|
|
break;
|
|
}
|
|
} else {
|
|
// Last game in session
|
|
if (messageTime >= currentGameTime) {
|
|
matchedGame = currentGame;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (matchedGame) {
|
|
const isUpvote = vote === 'thisgame++';
|
|
|
|
// Debug: track this vote match
|
|
voteMatches.push({
|
|
username: username,
|
|
vote: vote,
|
|
timestamp: timestamp,
|
|
timestamp_ms: messageTime,
|
|
matched_game: matchedGame.title,
|
|
game_played_at: matchedGame.played_at,
|
|
game_played_at_ms: new Date(matchedGame.played_at).getTime()
|
|
});
|
|
|
|
if (isUpvote) {
|
|
updateUpvote.run(matchedGame.game_id);
|
|
} else {
|
|
updateDownvote.run(matchedGame.game_id);
|
|
}
|
|
|
|
if (!votesByGame[matchedGame.game_id]) {
|
|
votesByGame[matchedGame.game_id] = {
|
|
title: matchedGame.title,
|
|
upvotes: 0,
|
|
downvotes: 0
|
|
};
|
|
}
|
|
|
|
if (isUpvote) {
|
|
votesByGame[matchedGame.game_id].upvotes++;
|
|
} else {
|
|
votesByGame[matchedGame.game_id].downvotes++;
|
|
}
|
|
|
|
votesProcessed++;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
processVotes(chatData);
|
|
|
|
res.json({
|
|
message: 'Chat log imported and processed successfully',
|
|
messagesImported: chatData.length,
|
|
duplicatesSkipped,
|
|
votesProcessed,
|
|
votesByGame,
|
|
debug: {
|
|
sessionGamesTimeline: sessionGames.map(sg => ({
|
|
title: sg.title,
|
|
played_at: sg.played_at,
|
|
played_at_ms: new Date(sg.played_at).getTime()
|
|
})),
|
|
voteMatches: voteMatches
|
|
}
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Update session game status (admin only)
|
|
router.patch('/:sessionId/games/:gameId/status', authenticateToken, (req, res) => {
|
|
try {
|
|
const { status } = req.body;
|
|
const { sessionId, gameId } = req.params;
|
|
|
|
if (!status || !['playing', 'played', 'skipped'].includes(status)) {
|
|
return res.status(400).json({ error: 'Invalid status. Must be playing, played, or skipped' });
|
|
}
|
|
|
|
// If setting to 'playing', first set all other games in session to 'played' or keep as 'skipped'
|
|
if (status === 'playing') {
|
|
db.prepare(`
|
|
UPDATE session_games
|
|
SET status = CASE
|
|
WHEN status = 'skipped' THEN 'skipped'
|
|
ELSE 'played'
|
|
END
|
|
WHERE session_id = ? AND status = 'playing'
|
|
`).run(sessionId);
|
|
}
|
|
|
|
// Update the specific game
|
|
const result = db.prepare(`
|
|
UPDATE session_games
|
|
SET status = ?
|
|
WHERE session_id = ? AND id = ?
|
|
`).run(status, sessionId, gameId);
|
|
|
|
if (result.changes === 0) {
|
|
return res.status(404).json({ error: 'Session game not found' });
|
|
}
|
|
|
|
// Stop room monitor and player count check if game is no longer playing
|
|
if (status !== 'playing') {
|
|
try {
|
|
stopMonitor(sessionId, gameId);
|
|
} catch (error) {
|
|
console.error('Error stopping room monitor/player count check:', error);
|
|
}
|
|
}
|
|
|
|
res.json({ message: 'Status updated successfully', status });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Delete session game (admin only)
|
|
router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => {
|
|
try {
|
|
const { sessionId, gameId } = req.params;
|
|
|
|
// Stop room monitor and player count check before deleting
|
|
try {
|
|
stopMonitor(sessionId, gameId);
|
|
} catch (error) {
|
|
console.error('Error stopping room monitor/player count check:', error);
|
|
}
|
|
|
|
const result = db.prepare(`
|
|
DELETE FROM session_games
|
|
WHERE session_id = ? AND id = ?
|
|
`).run(sessionId, gameId);
|
|
|
|
if (result.changes === 0) {
|
|
return res.status(404).json({ error: 'Session game not found' });
|
|
}
|
|
|
|
res.json({ message: 'Game removed from session successfully' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Update room code for a session game (admin only)
|
|
router.patch('/:sessionId/games/:gameId/room-code', authenticateToken, (req, res) => {
|
|
try {
|
|
const { sessionId, gameId } = req.params;
|
|
const { room_code } = req.body;
|
|
|
|
if (!room_code) {
|
|
return res.status(400).json({ error: 'room_code is required' });
|
|
}
|
|
|
|
// Validate room code format: 4 characters, A-Z and 0-9 only
|
|
const roomCodeRegex = /^[A-Z0-9]{4}$/;
|
|
if (!roomCodeRegex.test(room_code)) {
|
|
return res.status(400).json({ error: 'room_code must be exactly 4 alphanumeric characters (A-Z, 0-9)' });
|
|
}
|
|
|
|
// Update the room code
|
|
const result = db.prepare(`
|
|
UPDATE session_games
|
|
SET room_code = ?
|
|
WHERE session_id = ? AND id = ?
|
|
`).run(room_code, sessionId, gameId);
|
|
|
|
if (result.changes === 0) {
|
|
return res.status(404).json({ error: 'Session game not found' });
|
|
}
|
|
|
|
// Return updated game data
|
|
const updatedGame = db.prepare(`
|
|
SELECT
|
|
sg.*,
|
|
g.pack_name,
|
|
g.title,
|
|
g.game_type,
|
|
g.min_players,
|
|
g.max_players,
|
|
g.popularity_score,
|
|
g.upvotes,
|
|
g.downvotes
|
|
FROM session_games sg
|
|
JOIN games g ON sg.game_id = g.id
|
|
WHERE sg.session_id = ? AND sg.id = ?
|
|
`).get(sessionId, gameId);
|
|
|
|
res.json(updatedGame);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Export session data (plaintext and JSON)
|
|
router.get('/:id/export', authenticateToken, (req, res) => {
|
|
try {
|
|
const { format } = req.query; // 'json' or 'txt'
|
|
const sessionId = req.params.id;
|
|
|
|
// Get session info
|
|
const session = db.prepare(`
|
|
SELECT
|
|
s.*,
|
|
COUNT(sg.id) as games_played
|
|
FROM sessions s
|
|
LEFT JOIN session_games sg ON s.id = sg.session_id
|
|
WHERE s.id = ?
|
|
GROUP BY s.id
|
|
`).get(sessionId);
|
|
|
|
if (!session) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
|
|
// Get games for this session
|
|
const games = db.prepare(`
|
|
SELECT
|
|
sg.*,
|
|
g.title,
|
|
g.pack_name,
|
|
g.min_players,
|
|
g.max_players,
|
|
g.game_type
|
|
FROM session_games sg
|
|
JOIN games g ON sg.game_id = g.id
|
|
WHERE sg.session_id = ?
|
|
ORDER BY sg.played_at ASC
|
|
`).all(sessionId);
|
|
|
|
// Get chat logs if any
|
|
const chatLogs = db.prepare(`
|
|
SELECT * FROM chat_logs
|
|
WHERE session_id = ?
|
|
ORDER BY timestamp ASC
|
|
`).all(sessionId);
|
|
|
|
if (format === 'json') {
|
|
// JSON Export
|
|
const exportData = {
|
|
session: {
|
|
id: session.id,
|
|
created_at: session.created_at,
|
|
closed_at: session.closed_at,
|
|
is_active: session.is_active === 1,
|
|
notes: session.notes,
|
|
games_played: session.games_played
|
|
},
|
|
games: games.map(game => ({
|
|
title: game.title,
|
|
pack: game.pack_name,
|
|
players: `${game.min_players}-${game.max_players}`,
|
|
type: game.game_type,
|
|
played_at: game.played_at,
|
|
manually_added: game.manually_added === 1,
|
|
status: game.status
|
|
})),
|
|
chat_logs: chatLogs.map(log => ({
|
|
username: log.chatter_name,
|
|
message: log.message,
|
|
timestamp: log.timestamp,
|
|
vote: log.parsed_vote
|
|
}))
|
|
};
|
|
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Content-Disposition', `attachment; filename="session-${sessionId}.json"`);
|
|
res.send(JSON.stringify(exportData, null, 2));
|
|
} else {
|
|
// Plain Text Export
|
|
let text = `JACKBOX GAME PICKER - SESSION EXPORT\n`;
|
|
text += `${'='.repeat(50)}\n\n`;
|
|
text += `Session ID: ${session.id}\n`;
|
|
text += `Created: ${session.created_at}\n`;
|
|
if (session.closed_at) {
|
|
text += `Closed: ${session.closed_at}\n`;
|
|
}
|
|
text += `Status: ${session.is_active ? 'Active' : 'Ended'}\n`;
|
|
if (session.notes) {
|
|
text += `Notes: ${session.notes}\n`;
|
|
}
|
|
text += `\nGames Played: ${session.games_played}\n`;
|
|
text += `\n${'='.repeat(50)}\n\n`;
|
|
|
|
if (games.length > 0) {
|
|
text += `GAMES:\n`;
|
|
text += `${'-'.repeat(50)}\n`;
|
|
games.forEach((game, index) => {
|
|
text += `\n${index + 1}. ${game.title}\n`;
|
|
text += ` Pack: ${game.pack_name}\n`;
|
|
text += ` Players: ${game.min_players}-${game.max_players}\n`;
|
|
if (game.game_type) {
|
|
text += ` Type: ${game.game_type}\n`;
|
|
}
|
|
text += ` Played: ${game.played_at}\n`;
|
|
text += ` Status: ${game.status}\n`;
|
|
if (game.manually_added === 1) {
|
|
text += ` (Manually Added)\n`;
|
|
}
|
|
});
|
|
text += `\n${'-'.repeat(50)}\n`;
|
|
}
|
|
|
|
if (chatLogs.length > 0) {
|
|
text += `\nCHAT LOGS:\n`;
|
|
text += `${'-'.repeat(50)}\n`;
|
|
chatLogs.forEach(log => {
|
|
text += `\n[${log.timestamp}] ${log.chatter_name}:\n`;
|
|
text += ` ${log.message}\n`;
|
|
if (log.parsed_vote) {
|
|
text += ` Vote: ${log.parsed_vote}\n`;
|
|
}
|
|
});
|
|
text += `\n${'-'.repeat(50)}\n`;
|
|
}
|
|
|
|
text += `\nEnd of Session Export\n`;
|
|
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
res.setHeader('Content-Disposition', `attachment; filename="session-${sessionId}.txt"`);
|
|
res.send(text);
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Get live game status from shard monitor or DB fallback
|
|
router.get('/:sessionId/games/:gameId/status-live', (req, res) => {
|
|
try {
|
|
const { sessionId, gameId } = req.params;
|
|
|
|
const snapshot = getMonitorSnapshot(sessionId, gameId);
|
|
if (snapshot) {
|
|
return res.json(snapshot);
|
|
}
|
|
|
|
const game = db.prepare(`
|
|
SELECT
|
|
sg.room_code,
|
|
sg.player_count,
|
|
sg.player_count_check_status,
|
|
g.title,
|
|
g.pack_name,
|
|
g.max_players
|
|
FROM session_games sg
|
|
JOIN games g ON sg.game_id = g.id
|
|
WHERE sg.session_id = ? AND sg.id = ?
|
|
`).get(sessionId, gameId);
|
|
|
|
if (!game) {
|
|
return res.status(404).json({ error: 'Session game not found' });
|
|
}
|
|
|
|
res.json({
|
|
sessionId: parseInt(sessionId, 10),
|
|
gameId: parseInt(gameId, 10),
|
|
roomCode: game.room_code,
|
|
appTag: null,
|
|
maxPlayers: game.max_players,
|
|
playerCount: game.player_count,
|
|
players: [],
|
|
lobbyState: null,
|
|
gameState: null,
|
|
gameStarted: false,
|
|
gameFinished: game.player_count_check_status === 'completed',
|
|
monitoring: false,
|
|
title: game.title,
|
|
packName: game.pack_name,
|
|
status: game.player_count_check_status,
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Start player count check for a session game (admin only)
|
|
router.post('/:sessionId/games/:gameId/start-player-check', authenticateToken, (req, res) => {
|
|
try {
|
|
const { sessionId, gameId } = req.params;
|
|
|
|
// Get the game to verify it exists and has a room code
|
|
const game = db.prepare(`
|
|
SELECT sg.*, g.max_players
|
|
FROM session_games sg
|
|
JOIN games g ON sg.game_id = g.id
|
|
WHERE sg.session_id = ? AND sg.id = ?
|
|
`).get(sessionId, gameId);
|
|
|
|
if (!game) {
|
|
return res.status(404).json({ error: 'Session game not found' });
|
|
}
|
|
|
|
if (!game.room_code) {
|
|
return res.status(400).json({ error: 'Game does not have a room code' });
|
|
}
|
|
|
|
// Start room monitoring (will hand off to player count check when game starts)
|
|
startMonitor(sessionId, gameId, game.room_code, game.max_players);
|
|
|
|
res.json({
|
|
message: 'Room monitor started',
|
|
status: 'monitoring'
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Stop player count check for a session game (admin only)
|
|
router.post('/:sessionId/games/:gameId/stop-player-check', authenticateToken, (req, res) => {
|
|
try {
|
|
const { sessionId, gameId } = req.params;
|
|
|
|
// Stop both room monitor and player count check
|
|
stopMonitor(sessionId, gameId);
|
|
|
|
res.json({
|
|
message: 'Room monitor and player count check stopped',
|
|
status: 'stopped'
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Manually update player count for a session game (admin only)
|
|
router.patch('/:sessionId/games/:gameId/player-count', authenticateToken, (req, res) => {
|
|
try {
|
|
const { sessionId, gameId } = req.params;
|
|
const { player_count } = req.body;
|
|
|
|
if (player_count === undefined || player_count === null) {
|
|
return res.status(400).json({ error: 'player_count is required' });
|
|
}
|
|
|
|
const count = parseInt(player_count);
|
|
if (isNaN(count) || count < 0) {
|
|
return res.status(400).json({ error: 'player_count must be a positive number' });
|
|
}
|
|
|
|
// Update the player count
|
|
const result = db.prepare(`
|
|
UPDATE session_games
|
|
SET player_count = ?, player_count_check_status = 'completed'
|
|
WHERE session_id = ? AND id = ?
|
|
`).run(count, sessionId, gameId);
|
|
|
|
if (result.changes === 0) {
|
|
return res.status(404).json({ error: 'Session game not found' });
|
|
}
|
|
|
|
// Broadcast via WebSocket
|
|
const wsManager = getWebSocketManager();
|
|
if (wsManager) {
|
|
wsManager.broadcastEvent('player-count.updated', {
|
|
sessionId,
|
|
gameId,
|
|
playerCount: count,
|
|
status: 'completed'
|
|
}, parseInt(sessionId));
|
|
}
|
|
|
|
res.json({
|
|
message: 'Player count updated successfully',
|
|
player_count: count
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|
|
|