IDK, it's working and we're moving on

This commit is contained in:
cottongin
2025-11-02 16:06:31 -05:00
parent 6308d99d33
commit 2a75237e90
26 changed files with 5231 additions and 45 deletions

View File

@@ -2,6 +2,8 @@ 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 router = express.Router();
@@ -103,6 +105,27 @@ router.post('/', authenticateToken, (req, res) => {
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 });
@@ -139,7 +162,37 @@ router.post('/:id/close', authenticateToken, (req, res) => {
stmt.run(notes || null, req.params.id);
const closedSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get(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 });
@@ -202,7 +255,7 @@ router.get('/:id/games', (req, res) => {
// Add game to session (admin only)
router.post('/:id/games', authenticateToken, (req, res) => {
try {
const { game_id, manually_added } = req.body;
const { game_id, manually_added, room_code } = req.body;
if (!game_id) {
return res.status(400).json({ error: 'game_id is required' });
@@ -238,11 +291,11 @@ router.post('/:id/games', authenticateToken, (req, res) => {
// Add game to session with 'playing' status
const stmt = db.prepare(`
INSERT INTO session_games (session_id, game_id, manually_added, status)
VALUES (?, ?, ?, 'playing')
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);
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);
@@ -252,12 +305,56 @@ router.post('/:id/games', authenticateToken, (req, res) => {
sg.*,
g.pack_name,
g.title,
g.game_type
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);
}
res.status(201).json(sessionGame);
} catch (error) {
res.status(500).json({ error: error.message });
@@ -498,6 +595,56 @@ router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => {
}
});
// 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 {

198
backend/routes/votes.js Normal file
View File

@@ -0,0 +1,198 @@
const express = require('express');
const { authenticateToken } = require('../middleware/auth');
const db = require('../database');
const router = express.Router();
// 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(`
SELECT sg.game_id, sg.played_at, g.title, g.upvotes, g.downvotes, g.popularity_score
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();
// Get updated game stats
const updatedGame = db.prepare(`
SELECT id, title, upvotes, downvotes, popularity_score
FROM games
WHERE id = ?
`).get(matchedGame.game_id);
// 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;

271
backend/routes/webhooks.js Normal file
View File

@@ -0,0 +1,271 @@
const express = require('express');
const { authenticateToken } = require('../middleware/auth');
const db = require('../database');
const { triggerWebhook } = require('../utils/webhooks');
const router = express.Router();
// Get all webhooks (admin only)
router.get('/', authenticateToken, (req, res) => {
try {
const webhooks = db.prepare(`
SELECT id, name, url, events, enabled, created_at
FROM webhooks
ORDER BY created_at DESC
`).all();
// Parse events JSON for each webhook
const webhooksWithParsedEvents = webhooks.map(webhook => ({
...webhook,
events: JSON.parse(webhook.events),
enabled: webhook.enabled === 1
}));
res.json(webhooksWithParsedEvents);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get single webhook by ID (admin only)
router.get('/:id', authenticateToken, (req, res) => {
try {
const webhook = db.prepare(`
SELECT id, name, url, events, enabled, created_at
FROM webhooks
WHERE id = ?
`).get(req.params.id);
if (!webhook) {
return res.status(404).json({ error: 'Webhook not found' });
}
res.json({
...webhook,
events: JSON.parse(webhook.events),
enabled: webhook.enabled === 1
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Create new webhook (admin only)
router.post('/', authenticateToken, (req, res) => {
try {
const { name, url, secret, events } = req.body;
// Validate required fields
if (!name || !url || !secret || !events) {
return res.status(400).json({
error: 'Missing required fields: name, url, secret, events'
});
}
// Validate events is an array
if (!Array.isArray(events)) {
return res.status(400).json({
error: 'events must be an array'
});
}
// Validate URL format
try {
new URL(url);
} catch (err) {
return res.status(400).json({ error: 'Invalid URL format' });
}
// Insert webhook
const stmt = db.prepare(`
INSERT INTO webhooks (name, url, secret, events, enabled)
VALUES (?, ?, ?, ?, 1)
`);
const result = stmt.run(name, url, secret, JSON.stringify(events));
const newWebhook = db.prepare(`
SELECT id, name, url, events, enabled, created_at
FROM webhooks
WHERE id = ?
`).get(result.lastInsertRowid);
res.status(201).json({
...newWebhook,
events: JSON.parse(newWebhook.events),
enabled: newWebhook.enabled === 1,
message: 'Webhook created successfully'
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Update webhook (admin only)
router.patch('/:id', authenticateToken, (req, res) => {
try {
const { name, url, secret, events, enabled } = req.body;
const webhookId = req.params.id;
// Check if webhook exists
const webhook = db.prepare('SELECT * FROM webhooks WHERE id = ?').get(webhookId);
if (!webhook) {
return res.status(404).json({ error: 'Webhook not found' });
}
// Build update query dynamically based on provided fields
const updates = [];
const params = [];
if (name !== undefined) {
updates.push('name = ?');
params.push(name);
}
if (url !== undefined) {
// Validate URL format
try {
new URL(url);
} catch (err) {
return res.status(400).json({ error: 'Invalid URL format' });
}
updates.push('url = ?');
params.push(url);
}
if (secret !== undefined) {
updates.push('secret = ?');
params.push(secret);
}
if (events !== undefined) {
if (!Array.isArray(events)) {
return res.status(400).json({ error: 'events must be an array' });
}
updates.push('events = ?');
params.push(JSON.stringify(events));
}
if (enabled !== undefined) {
updates.push('enabled = ?');
params.push(enabled ? 1 : 0);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
params.push(webhookId);
const stmt = db.prepare(`
UPDATE webhooks
SET ${updates.join(', ')}
WHERE id = ?
`);
stmt.run(...params);
const updatedWebhook = db.prepare(`
SELECT id, name, url, events, enabled, created_at
FROM webhooks
WHERE id = ?
`).get(webhookId);
res.json({
...updatedWebhook,
events: JSON.parse(updatedWebhook.events),
enabled: updatedWebhook.enabled === 1,
message: 'Webhook updated successfully'
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Delete webhook (admin only)
router.delete('/:id', authenticateToken, (req, res) => {
try {
const webhook = db.prepare('SELECT * FROM webhooks WHERE id = ?').get(req.params.id);
if (!webhook) {
return res.status(404).json({ error: 'Webhook not found' });
}
// Delete webhook (logs will be cascade deleted)
db.prepare('DELETE FROM webhooks WHERE id = ?').run(req.params.id);
res.json({
message: 'Webhook deleted successfully',
webhookId: parseInt(req.params.id)
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Test webhook (admin only)
router.post('/test/:id', authenticateToken, async (req, res) => {
try {
const webhook = db.prepare('SELECT * FROM webhooks WHERE id = ?').get(req.params.id);
if (!webhook) {
return res.status(404).json({ error: 'Webhook not found' });
}
// Send a test payload
const testData = {
session: {
id: 0,
is_active: true,
games_played: 0
},
game: {
id: 0,
title: 'Test Game',
pack_name: 'Test Pack',
min_players: 2,
max_players: 8,
manually_added: false
}
};
// Trigger the webhook asynchronously
triggerWebhook('game.added', testData);
res.json({
message: 'Test webhook sent',
note: 'Check webhook_logs table for delivery status'
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get webhook logs (admin only)
router.get('/:id/logs', authenticateToken, (req, res) => {
try {
const { limit = 50 } = req.query;
const logs = db.prepare(`
SELECT *
FROM webhook_logs
WHERE webhook_id = ?
ORDER BY created_at DESC
LIMIT ?
`).all(req.params.id, parseInt(limit));
// Parse payload JSON for each log
const logsWithParsedPayload = logs.map(log => ({
...log,
payload: JSON.parse(log.payload)
}));
res.json(logsWithParsedPayload);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;