2025-11-02 16:06:31 -05:00
|
|
|
const express = require('express');
|
|
|
|
|
const { authenticateToken } = require('../middleware/auth');
|
|
|
|
|
const db = require('../database');
|
2026-03-15 19:08:00 -04:00
|
|
|
const { getWebSocketManager } = require('../utils/websocket-manager');
|
2025-11-02 16:06:31 -05:00
|
|
|
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
|
2026-03-15 19:00:00 -04:00
|
|
|
// Get vote history with filtering and pagination
|
|
|
|
|
router.get('/', (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
let { session_id, game_id, username, vote_type, page, limit } = req.query;
|
|
|
|
|
|
|
|
|
|
page = parseInt(page) || 1;
|
|
|
|
|
limit = Math.min(parseInt(limit) || 50, 100);
|
|
|
|
|
if (page < 1) page = 1;
|
|
|
|
|
if (limit < 1) limit = 50;
|
|
|
|
|
const offset = (page - 1) * limit;
|
|
|
|
|
|
|
|
|
|
const where = [];
|
|
|
|
|
const params = [];
|
|
|
|
|
|
|
|
|
|
if (session_id !== undefined) {
|
|
|
|
|
const sid = parseInt(session_id);
|
|
|
|
|
if (isNaN(sid)) {
|
|
|
|
|
return res.status(400).json({ error: 'session_id must be an integer' });
|
|
|
|
|
}
|
|
|
|
|
where.push('lv.session_id = ?');
|
|
|
|
|
params.push(sid);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (game_id !== undefined) {
|
|
|
|
|
const gid = parseInt(game_id);
|
|
|
|
|
if (isNaN(gid)) {
|
|
|
|
|
return res.status(400).json({ error: 'game_id must be an integer' });
|
|
|
|
|
}
|
|
|
|
|
where.push('lv.game_id = ?');
|
|
|
|
|
params.push(gid);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (username) {
|
|
|
|
|
where.push('lv.username = ?');
|
|
|
|
|
params.push(username);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (vote_type !== undefined) {
|
|
|
|
|
if (vote_type !== 'up' && vote_type !== 'down') {
|
|
|
|
|
return res.status(400).json({ error: 'vote_type must be "up" or "down"' });
|
|
|
|
|
}
|
|
|
|
|
where.push('lv.vote_type = ?');
|
|
|
|
|
params.push(vote_type === 'up' ? 1 : -1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const whereClause = where.length > 0 ? 'WHERE ' + where.join(' AND ') : '';
|
|
|
|
|
|
|
|
|
|
const countResult = db.prepare(
|
|
|
|
|
`SELECT COUNT(*) as total FROM live_votes lv ${whereClause}`
|
|
|
|
|
).get(...params);
|
|
|
|
|
|
|
|
|
|
const total = countResult.total;
|
|
|
|
|
const total_pages = Math.ceil(total / limit) || 0;
|
|
|
|
|
|
|
|
|
|
const votes = db.prepare(`
|
|
|
|
|
SELECT
|
|
|
|
|
lv.id,
|
|
|
|
|
lv.session_id,
|
|
|
|
|
lv.game_id,
|
|
|
|
|
g.title AS game_title,
|
|
|
|
|
g.pack_name,
|
|
|
|
|
lv.username,
|
|
|
|
|
CASE WHEN lv.vote_type = 1 THEN 'up' ELSE 'down' END AS vote_type,
|
|
|
|
|
lv.timestamp,
|
|
|
|
|
lv.created_at
|
|
|
|
|
FROM live_votes lv
|
|
|
|
|
JOIN games g ON lv.game_id = g.id
|
|
|
|
|
${whereClause}
|
|
|
|
|
ORDER BY lv.timestamp DESC
|
|
|
|
|
LIMIT ? OFFSET ?
|
|
|
|
|
`).all(...params, limit, offset);
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
votes,
|
|
|
|
|
pagination: { page, limit, total, total_pages },
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
res.status(500).json({ error: error.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-02 16:06:31 -05:00
|
|
|
// Live vote endpoint - receives real-time votes from bot
|
|
|
|
|
router.post('/live', authenticateToken, (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { username, vote, timestamp } = req.body;
|
|
|
|
|
|
|
|
|
|
// Validate payload
|
|
|
|
|
if (!username || !vote || !timestamp) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
error: 'Missing required fields: username, vote, timestamp'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (vote !== 'up' && vote !== 'down') {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
error: 'vote must be either "up" or "down"'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate timestamp format
|
|
|
|
|
const voteTimestamp = new Date(timestamp);
|
|
|
|
|
if (isNaN(voteTimestamp.getTime())) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
error: 'Invalid timestamp format. Use ISO 8601 format (e.g., 2025-11-01T20:30:00Z)'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for active session
|
|
|
|
|
const activeSession = db.prepare(`
|
|
|
|
|
SELECT * FROM sessions WHERE is_active = 1 LIMIT 1
|
|
|
|
|
`).get();
|
|
|
|
|
|
|
|
|
|
if (!activeSession) {
|
|
|
|
|
return res.status(404).json({
|
|
|
|
|
error: 'No active session found'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get all games played in this session with timestamps
|
|
|
|
|
const sessionGames = db.prepare(`
|
2026-03-15 19:08:00 -04:00
|
|
|
SELECT sg.game_id, sg.played_at, g.title, g.pack_name, g.upvotes, g.downvotes, g.popularity_score
|
2025-11-02 16:06:31 -05:00
|
|
|
FROM session_games sg
|
|
|
|
|
JOIN games g ON sg.game_id = g.id
|
|
|
|
|
WHERE sg.session_id = ?
|
|
|
|
|
ORDER BY sg.played_at ASC
|
|
|
|
|
`).all(activeSession.id);
|
|
|
|
|
|
|
|
|
|
if (sessionGames.length === 0) {
|
|
|
|
|
return res.status(404).json({
|
|
|
|
|
error: 'No games have been played in the active session yet'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Match vote timestamp to the correct game using interval logic
|
|
|
|
|
const voteTime = voteTimestamp.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 (voteTime >= currentGameTime && voteTime < nextGameTime) {
|
|
|
|
|
matchedGame = currentGame;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Last game in session - vote belongs here if timestamp is after this game started
|
|
|
|
|
if (voteTime >= currentGameTime) {
|
|
|
|
|
matchedGame = currentGame;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!matchedGame) {
|
|
|
|
|
return res.status(404).json({
|
|
|
|
|
error: 'Vote timestamp does not match any game in the active session',
|
|
|
|
|
debug: {
|
|
|
|
|
voteTimestamp: timestamp,
|
|
|
|
|
sessionGames: sessionGames.map(g => ({
|
|
|
|
|
title: g.title,
|
|
|
|
|
played_at: g.played_at
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for duplicate vote (within 1 second window)
|
|
|
|
|
// Get the most recent vote from this user
|
|
|
|
|
const lastVote = db.prepare(`
|
|
|
|
|
SELECT timestamp FROM live_votes
|
|
|
|
|
WHERE username = ?
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
LIMIT 1
|
|
|
|
|
`).get(username);
|
|
|
|
|
|
|
|
|
|
if (lastVote) {
|
|
|
|
|
const lastVoteTime = new Date(lastVote.timestamp).getTime();
|
|
|
|
|
const currentVoteTime = new Date(timestamp).getTime();
|
|
|
|
|
const timeDiffSeconds = Math.abs(currentVoteTime - lastVoteTime) / 1000;
|
|
|
|
|
|
|
|
|
|
if (timeDiffSeconds <= 1) {
|
|
|
|
|
return res.status(409).json({
|
|
|
|
|
error: 'Duplicate vote detected (within 1 second of previous vote)',
|
|
|
|
|
message: 'Please wait at least 1 second between votes',
|
|
|
|
|
timeSinceLastVote: timeDiffSeconds
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Process the vote in a transaction
|
|
|
|
|
const voteType = vote === 'up' ? 1 : -1;
|
|
|
|
|
|
|
|
|
|
const insertVote = db.prepare(`
|
|
|
|
|
INSERT INTO live_votes (session_id, game_id, username, vote_type, timestamp)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?)
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
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 processVote = db.transaction(() => {
|
|
|
|
|
insertVote.run(activeSession.id, matchedGame.game_id, username, voteType, timestamp);
|
|
|
|
|
|
|
|
|
|
if (voteType === 1) {
|
|
|
|
|
updateUpvote.run(matchedGame.game_id);
|
|
|
|
|
} else {
|
|
|
|
|
updateDownvote.run(matchedGame.game_id);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
processVote();
|
|
|
|
|
|
2026-03-16 20:53:32 -04:00
|
|
|
// Verify the live_votes row was persisted (diagnostic for production debugging)
|
|
|
|
|
const voteCheck = db.prepare(
|
|
|
|
|
'SELECT id FROM live_votes WHERE session_id = ? AND game_id = ? AND username = ? AND timestamp = ?'
|
|
|
|
|
).get(activeSession.id, matchedGame.game_id, username, timestamp);
|
|
|
|
|
if (!voteCheck) {
|
|
|
|
|
console.error('[votes] CRITICAL: live_votes INSERT committed but row not found', {
|
|
|
|
|
session_id: activeSession.id, game_id: matchedGame.game_id, username, timestamp,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-02 16:06:31 -05:00
|
|
|
// Get updated game stats
|
|
|
|
|
const updatedGame = db.prepare(`
|
|
|
|
|
SELECT id, title, upvotes, downvotes, popularity_score
|
|
|
|
|
FROM games
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
`).get(matchedGame.game_id);
|
|
|
|
|
|
2026-03-15 19:08:00 -04:00
|
|
|
// Broadcast vote.received via WebSocket
|
|
|
|
|
try {
|
|
|
|
|
const wsManager = getWebSocketManager();
|
|
|
|
|
if (wsManager) {
|
|
|
|
|
wsManager.broadcastEvent('vote.received', {
|
|
|
|
|
sessionId: activeSession.id,
|
|
|
|
|
game: {
|
|
|
|
|
id: updatedGame.id,
|
|
|
|
|
title: updatedGame.title,
|
|
|
|
|
pack_name: matchedGame.pack_name,
|
|
|
|
|
},
|
|
|
|
|
vote: {
|
|
|
|
|
username: username,
|
|
|
|
|
type: vote,
|
|
|
|
|
timestamp: timestamp,
|
|
|
|
|
},
|
|
|
|
|
totals: {
|
|
|
|
|
upvotes: updatedGame.upvotes,
|
|
|
|
|
downvotes: updatedGame.downvotes,
|
|
|
|
|
popularity_score: updatedGame.popularity_score,
|
|
|
|
|
},
|
|
|
|
|
}, activeSession.id);
|
|
|
|
|
}
|
|
|
|
|
} catch (wsError) {
|
|
|
|
|
console.error('Error broadcasting vote.received event:', wsError);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-02 16:06:31 -05:00
|
|
|
// Get session stats
|
|
|
|
|
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(activeSession.id);
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
message: 'Vote recorded successfully',
|
|
|
|
|
session: {
|
|
|
|
|
id: sessionStats.id,
|
|
|
|
|
games_played: sessionStats.games_played
|
|
|
|
|
},
|
|
|
|
|
game: {
|
|
|
|
|
id: updatedGame.id,
|
|
|
|
|
title: updatedGame.title,
|
|
|
|
|
upvotes: updatedGame.upvotes,
|
|
|
|
|
downvotes: updatedGame.downvotes,
|
|
|
|
|
popularity_score: updatedGame.popularity_score
|
|
|
|
|
},
|
|
|
|
|
vote: {
|
|
|
|
|
username: username,
|
|
|
|
|
type: vote,
|
|
|
|
|
timestamp: timestamp
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error processing live vote:', error);
|
|
|
|
|
res.status(500).json({ error: error.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
module.exports = router;
|
|
|
|
|
|