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 { startPlayerCountCheck, stopPlayerCountCheck } = require('../utils/player-count-checker'); 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 }); } }); // 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 player count check if room code was provided if (room_code) { try { startPlayerCountCheck(req.params.id, result.lastInsertRowid, room_code, game.max_players); } catch (error) { console.error('Error starting player count check:', error); // Don't fail the request if player count check fails } } 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 player count check if game is no longer playing if (status !== 'playing') { try { stopPlayerCountCheck(sessionId, gameId); } catch (error) { console.error('Error stopping 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 player count check before deleting try { stopPlayerCountCheck(sessionId, gameId); } catch (error) { console.error('Error stopping 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 }); } }); // 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 the check startPlayerCountCheck(sessionId, gameId, game.room_code, game.max_players); res.json({ message: 'Player count check started', status: 'waiting' }); } 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 the check stopPlayerCountCheck(sessionId, gameId); res.json({ message: '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;