Mark games as 'played' when shard detects game.ended or room_closed. Stop old shard monitors before demoting previous playing games on new game add or status change. Sync frontend playingGame state with the games list on every refresh to prevent stale UI. Use terminate() for probe connections to prevent shard connection leaks. Made-with: Cursor
1005 lines
29 KiB
JavaScript
1005 lines
29 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' });
|
|
}
|
|
|
|
// Stop monitors for currently-playing games before demoting them
|
|
const previouslyPlaying = db.prepare(
|
|
'SELECT id FROM session_games WHERE session_id = ? AND status = ?'
|
|
).all(req.params.id, 'playing');
|
|
for (const prev of previouslyPlaying) {
|
|
try { stopMonitor(req.params.id, prev.id); } catch (_) {}
|
|
}
|
|
|
|
// 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 stop monitors and demote other playing games
|
|
if (status === 'playing') {
|
|
const previouslyPlaying = db.prepare(
|
|
'SELECT id FROM session_games WHERE session_id = ? AND status = ?'
|
|
).all(sessionId, 'playing');
|
|
for (const prev of previouslyPlaying) {
|
|
if (String(prev.id) !== String(gameId)) {
|
|
try { stopMonitor(sessionId, prev.id); } catch (_) {}
|
|
}
|
|
}
|
|
|
|
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;
|
|
|