IDK, it's working and we're moving on
This commit is contained in:
@@ -77,6 +77,13 @@ function initializeDatabase() {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Add room_code column if it doesn't exist (for existing databases)
|
||||
try {
|
||||
db.exec(`ALTER TABLE session_games ADD COLUMN room_code TEXT`);
|
||||
} catch (err) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Add favor_bias column to games if it doesn't exist
|
||||
try {
|
||||
db.exec(`ALTER TABLE games ADD COLUMN favor_bias INTEGER DEFAULT 0`);
|
||||
@@ -167,6 +174,55 @@ function initializeDatabase() {
|
||||
// Index already exists, ignore error
|
||||
}
|
||||
|
||||
// Live votes table for real-time voting
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS live_votes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
game_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
vote_type INTEGER NOT NULL,
|
||||
timestamp DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Create index for duplicate checking (username + timestamp within 1 second)
|
||||
try {
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_live_votes_dedup ON live_votes(username, timestamp)`);
|
||||
} catch (err) {
|
||||
// Index already exists, ignore error
|
||||
}
|
||||
|
||||
// Webhooks table for external integrations
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
secret TEXT NOT NULL,
|
||||
events TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Webhook logs table for debugging
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS webhook_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
webhook_id INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
response_status INTEGER,
|
||||
error_message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('Database initialized successfully');
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"csv-parse": "^5.5.3",
|
||||
"csv-stringify": "^6.4.5"
|
||||
"csv-stringify": "^6.4.5",
|
||||
"ws": "^8.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
|
||||
@@ -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
198
backend/routes/votes.js
Normal 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
271
backend/routes/webhooks.js
Normal 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;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const cors = require('cors');
|
||||
const { bootstrapGames } = require('./bootstrap');
|
||||
const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 5000;
|
||||
@@ -24,12 +26,16 @@ const gamesRoutes = require('./routes/games');
|
||||
const sessionsRoutes = require('./routes/sessions');
|
||||
const statsRoutes = require('./routes/stats');
|
||||
const pickerRoutes = require('./routes/picker');
|
||||
const votesRoutes = require('./routes/votes');
|
||||
const webhooksRoutes = require('./routes/webhooks');
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/games', gamesRoutes);
|
||||
app.use('/api/sessions', sessionsRoutes);
|
||||
app.use('/api/stats', statsRoutes);
|
||||
app.use('/api', pickerRoutes);
|
||||
app.use('/api/votes', votesRoutes);
|
||||
app.use('/api/webhooks', webhooksRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
@@ -37,7 +43,15 @@ app.use((err, req, res, next) => {
|
||||
res.status(500).json({ error: 'Something went wrong!', message: err.message });
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
// Create HTTP server and attach WebSocket
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Initialize WebSocket Manager
|
||||
const wsManager = new WebSocketManager(server);
|
||||
setWebSocketManager(wsManager);
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
|
||||
});
|
||||
|
||||
|
||||
122
backend/test-websocket.js
Normal file
122
backend/test-websocket.js
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* WebSocket Test Client
|
||||
*
|
||||
* Tests the WebSocket event system for the Jackbox Game Picker API
|
||||
*
|
||||
* Usage:
|
||||
* JWT_TOKEN="your_token" node test-websocket.js
|
||||
*/
|
||||
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const API_URL = process.env.API_URL || 'ws://localhost:5000';
|
||||
const JWT_TOKEN = process.env.JWT_TOKEN || '';
|
||||
|
||||
if (!JWT_TOKEN) {
|
||||
console.error('\n❌ ERROR: JWT_TOKEN not set!');
|
||||
console.error('\nGet your token:');
|
||||
console.error(' curl -X POST "http://localhost:5000/api/auth/login" \\');
|
||||
console.error(' -H "Content-Type: application/json" \\');
|
||||
console.error(' -d \'{"key":"YOUR_ADMIN_KEY"}\'');
|
||||
console.error('\nThen run:');
|
||||
console.error(' JWT_TOKEN="your_token" node test-websocket.js\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n🚀 WebSocket Test Client');
|
||||
console.log('═══════════════════════════════════════════════════════\n');
|
||||
console.log(`Connecting to: ${API_URL}/api/sessions/live`);
|
||||
console.log('');
|
||||
|
||||
const ws = new WebSocket(`${API_URL}/api/sessions/live`);
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('✅ Connected to WebSocket server\n');
|
||||
|
||||
// Step 1: Authenticate
|
||||
console.log('📝 Step 1: Authenticating...');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'auth',
|
||||
token: JWT_TOKEN
|
||||
}));
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
switch (message.type) {
|
||||
case 'auth_success':
|
||||
console.log('✅ Authentication successful\n');
|
||||
|
||||
// Step 2: Subscribe to session (you can change this ID)
|
||||
console.log('📝 Step 2: Subscribing to session 1...');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
sessionId: 1
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'auth_error':
|
||||
console.error('❌ Authentication failed:', message.message);
|
||||
process.exit(1);
|
||||
break;
|
||||
|
||||
case 'subscribed':
|
||||
console.log(`✅ Subscribed to session ${message.sessionId}\n`);
|
||||
console.log('🎧 Listening for events...');
|
||||
console.log(' Add a game in the Picker page to see events here');
|
||||
console.log(' Press Ctrl+C to exit\n');
|
||||
|
||||
// Start heartbeat
|
||||
setInterval(() => {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}, 30000);
|
||||
break;
|
||||
|
||||
case 'game.added':
|
||||
console.log('\n🎮 GAME ADDED EVENT RECEIVED!');
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log('Game:', message.data.game.title);
|
||||
console.log('Pack:', message.data.game.pack_name);
|
||||
console.log('Players:', `${message.data.game.min_players}-${message.data.game.max_players}`);
|
||||
console.log('Session ID:', message.data.session.id);
|
||||
console.log('Games Played:', message.data.session.games_played);
|
||||
console.log('Timestamp:', message.timestamp);
|
||||
console.log('═══════════════════════════════════════════════════════\n');
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
console.log('💓 Heartbeat');
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('❌ Error:', message.message);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('📨 Received:', message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse message:', err);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('\n❌ WebSocket error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('\n👋 Connection closed');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Handle Ctrl+C
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n\n⚠️ Closing connection...');
|
||||
ws.close();
|
||||
});
|
||||
|
||||
151
backend/utils/webhooks.js
Normal file
151
backend/utils/webhooks.js
Normal file
@@ -0,0 +1,151 @@
|
||||
const crypto = require('crypto');
|
||||
const db = require('../database');
|
||||
|
||||
/**
|
||||
* Trigger webhooks for a specific event type
|
||||
* @param {string} eventType - The event type (e.g., 'game.added')
|
||||
* @param {object} data - The payload data to send
|
||||
*/
|
||||
async function triggerWebhook(eventType, data) {
|
||||
try {
|
||||
// Get all enabled webhooks that are subscribed to this event
|
||||
const webhooks = db.prepare(`
|
||||
SELECT * FROM webhooks
|
||||
WHERE enabled = 1
|
||||
`).all();
|
||||
|
||||
if (webhooks.length === 0) {
|
||||
return; // No webhooks configured
|
||||
}
|
||||
|
||||
// Filter webhooks that are subscribed to this event
|
||||
const subscribedWebhooks = webhooks.filter(webhook => {
|
||||
try {
|
||||
const events = JSON.parse(webhook.events);
|
||||
return events.includes(eventType);
|
||||
} catch (err) {
|
||||
console.error(`Invalid events JSON for webhook ${webhook.id}:`, err);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (subscribedWebhooks.length === 0) {
|
||||
return; // No webhooks subscribed to this event
|
||||
}
|
||||
|
||||
// Build the payload
|
||||
const payload = {
|
||||
event: eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: data
|
||||
};
|
||||
|
||||
// Send to each webhook asynchronously (non-blocking)
|
||||
subscribedWebhooks.forEach(webhook => {
|
||||
sendWebhook(webhook, payload, eventType).catch(err => {
|
||||
console.error(`Error sending webhook ${webhook.id}:`, err);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error triggering webhooks:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a webhook to a specific URL
|
||||
* @param {object} webhook - The webhook configuration
|
||||
* @param {object} payload - The payload to send
|
||||
* @param {string} eventType - The event type
|
||||
*/
|
||||
async function sendWebhook(webhook, payload, eventType) {
|
||||
const payloadString = JSON.stringify(payload);
|
||||
|
||||
// Generate HMAC signature
|
||||
const signature = 'sha256=' + crypto
|
||||
.createHmac('sha256', webhook.secret)
|
||||
.update(payloadString)
|
||||
.digest('hex');
|
||||
|
||||
const startTime = Date.now();
|
||||
let responseStatus = null;
|
||||
let errorMessage = null;
|
||||
|
||||
try {
|
||||
// Send the webhook
|
||||
const response = await fetch(webhook.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Signature': signature,
|
||||
'X-Webhook-Event': eventType,
|
||||
'User-Agent': 'Jackbox-Game-Picker-Webhook/1.0'
|
||||
},
|
||||
body: payloadString,
|
||||
// Set a timeout of 5 seconds
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
responseStatus = response.status;
|
||||
|
||||
if (!response.ok) {
|
||||
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
errorMessage = err.message;
|
||||
responseStatus = 0; // Indicates connection/network error
|
||||
}
|
||||
|
||||
// Log the webhook call
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO webhook_logs (webhook_id, event_type, payload, response_status, error_message)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(webhook.id, eventType, payloadString, responseStatus, errorMessage);
|
||||
} catch (logErr) {
|
||||
console.error('Error logging webhook call:', logErr);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (errorMessage) {
|
||||
console.error(`Webhook ${webhook.id} (${webhook.name}) failed: ${errorMessage} (${duration}ms)`);
|
||||
} else {
|
||||
console.log(`Webhook ${webhook.id} (${webhook.name}) sent successfully: ${responseStatus} (${duration}ms)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a webhook signature
|
||||
* @param {string} signature - The signature from the X-Webhook-Signature header
|
||||
* @param {string} payload - The raw request body as a string
|
||||
* @param {string} secret - The webhook secret
|
||||
* @returns {boolean} - True if signature is valid
|
||||
*/
|
||||
function verifyWebhookSignature(signature, payload, secret) {
|
||||
if (!signature || !signature.startsWith('sha256=')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedSignature = 'sha256=' + crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
|
||||
// Use timing-safe comparison to prevent timing attacks
|
||||
try {
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature)
|
||||
);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
triggerWebhook,
|
||||
verifyWebhookSignature
|
||||
};
|
||||
|
||||
333
backend/utils/websocket-manager.js
Normal file
333
backend/utils/websocket-manager.js
Normal file
@@ -0,0 +1,333 @@
|
||||
const { WebSocketServer } = require('ws');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { JWT_SECRET } = require('../middleware/auth');
|
||||
|
||||
/**
|
||||
* WebSocket Manager for handling real-time session events
|
||||
* Manages client connections, authentication, and event broadcasting
|
||||
*/
|
||||
class WebSocketManager {
|
||||
constructor(server) {
|
||||
this.wss = new WebSocketServer({
|
||||
server,
|
||||
path: '/api/sessions/live'
|
||||
});
|
||||
|
||||
this.clients = new Map(); // Map<ws, clientInfo>
|
||||
this.sessionSubscriptions = new Map(); // Map<sessionId, Set<ws>>
|
||||
|
||||
this.wss.on('connection', (ws, req) => this.handleConnection(ws, req));
|
||||
this.startHeartbeat();
|
||||
|
||||
console.log('[WebSocket] WebSocket server initialized on /api/sessions/live');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new WebSocket connection
|
||||
*/
|
||||
handleConnection(ws, req) {
|
||||
console.log('[WebSocket] New connection from', req.socket.remoteAddress);
|
||||
|
||||
// Initialize client info
|
||||
const clientInfo = {
|
||||
authenticated: false,
|
||||
userId: null,
|
||||
subscribedSessions: new Set(),
|
||||
lastPing: Date.now()
|
||||
};
|
||||
|
||||
this.clients.set(ws, clientInfo);
|
||||
|
||||
// Handle incoming messages
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
this.handleMessage(ws, message);
|
||||
} catch (err) {
|
||||
console.error('[WebSocket] Failed to parse message:', err);
|
||||
this.sendError(ws, 'Invalid message format');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle connection close
|
||||
ws.on('close', () => {
|
||||
this.removeClient(ws);
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
ws.on('error', (err) => {
|
||||
console.error('[WebSocket] Client error:', err);
|
||||
this.removeClient(ws);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming messages from clients
|
||||
*/
|
||||
handleMessage(ws, message) {
|
||||
const clientInfo = this.clients.get(ws);
|
||||
|
||||
if (!clientInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'auth':
|
||||
this.authenticateClient(ws, message.token);
|
||||
break;
|
||||
|
||||
case 'subscribe':
|
||||
if (!clientInfo.authenticated) {
|
||||
this.sendError(ws, 'Not authenticated');
|
||||
return;
|
||||
}
|
||||
this.subscribeToSession(ws, message.sessionId);
|
||||
break;
|
||||
|
||||
case 'unsubscribe':
|
||||
if (!clientInfo.authenticated) {
|
||||
this.sendError(ws, 'Not authenticated');
|
||||
return;
|
||||
}
|
||||
this.unsubscribeFromSession(ws, message.sessionId);
|
||||
break;
|
||||
|
||||
case 'ping':
|
||||
clientInfo.lastPing = Date.now();
|
||||
this.send(ws, { type: 'pong' });
|
||||
break;
|
||||
|
||||
default:
|
||||
this.sendError(ws, `Unknown message type: ${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a client using JWT token
|
||||
*/
|
||||
authenticateClient(ws, token) {
|
||||
if (!token) {
|
||||
this.sendError(ws, 'Token required', 'auth_error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
const clientInfo = this.clients.get(ws);
|
||||
|
||||
if (clientInfo) {
|
||||
clientInfo.authenticated = true;
|
||||
clientInfo.userId = decoded.role; // 'admin' for now
|
||||
|
||||
this.send(ws, {
|
||||
type: 'auth_success',
|
||||
message: 'Authenticated successfully'
|
||||
});
|
||||
|
||||
console.log('[WebSocket] Client authenticated:', clientInfo.userId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WebSocket] Authentication failed:', err.message);
|
||||
this.sendError(ws, 'Invalid or expired token', 'auth_error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a client to session events
|
||||
*/
|
||||
subscribeToSession(ws, sessionId) {
|
||||
if (!sessionId) {
|
||||
this.sendError(ws, 'Session ID required');
|
||||
return;
|
||||
}
|
||||
|
||||
const clientInfo = this.clients.get(ws);
|
||||
if (!clientInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to session subscriptions
|
||||
if (!this.sessionSubscriptions.has(sessionId)) {
|
||||
this.sessionSubscriptions.set(sessionId, new Set());
|
||||
}
|
||||
|
||||
this.sessionSubscriptions.get(sessionId).add(ws);
|
||||
clientInfo.subscribedSessions.add(sessionId);
|
||||
|
||||
this.send(ws, {
|
||||
type: 'subscribed',
|
||||
sessionId: sessionId,
|
||||
message: `Subscribed to session ${sessionId}`
|
||||
});
|
||||
|
||||
console.log(`[WebSocket] Client subscribed to session ${sessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe a client from session events
|
||||
*/
|
||||
unsubscribeFromSession(ws, sessionId) {
|
||||
const clientInfo = this.clients.get(ws);
|
||||
if (!clientInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from session subscriptions
|
||||
if (this.sessionSubscriptions.has(sessionId)) {
|
||||
this.sessionSubscriptions.get(sessionId).delete(ws);
|
||||
|
||||
// Clean up empty subscription sets
|
||||
if (this.sessionSubscriptions.get(sessionId).size === 0) {
|
||||
this.sessionSubscriptions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
clientInfo.subscribedSessions.delete(sessionId);
|
||||
|
||||
this.send(ws, {
|
||||
type: 'unsubscribed',
|
||||
sessionId: sessionId,
|
||||
message: `Unsubscribed from session ${sessionId}`
|
||||
});
|
||||
|
||||
console.log(`[WebSocket] Client unsubscribed from session ${sessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an event to all clients subscribed to a session
|
||||
*/
|
||||
broadcastEvent(eventType, data, sessionId) {
|
||||
const subscribers = this.sessionSubscriptions.get(sessionId);
|
||||
|
||||
if (!subscribers || subscribers.size === 0) {
|
||||
console.log(`[WebSocket] No subscribers for session ${sessionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: data
|
||||
};
|
||||
|
||||
let sentCount = 0;
|
||||
subscribers.forEach((ws) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
this.send(ws, message);
|
||||
sentCount++;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[WebSocket] Broadcasted ${eventType} to ${sentCount} client(s) for session ${sessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an event to all authenticated clients (not session-specific)
|
||||
* Used for session.started and other global events
|
||||
*/
|
||||
broadcastToAll(eventType, data) {
|
||||
const message = {
|
||||
type: eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: data
|
||||
};
|
||||
|
||||
let sentCount = 0;
|
||||
this.clients.forEach((clientInfo, ws) => {
|
||||
if (clientInfo.authenticated && ws.readyState === ws.OPEN) {
|
||||
this.send(ws, message);
|
||||
sentCount++;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[WebSocket] Broadcasted ${eventType} to ${sentCount} authenticated client(s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a specific client
|
||||
*/
|
||||
send(ws, message) {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error message to a client
|
||||
*/
|
||||
sendError(ws, message, type = 'error') {
|
||||
this.send(ws, {
|
||||
type: type,
|
||||
message: message
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a client and clean up subscriptions
|
||||
*/
|
||||
removeClient(ws) {
|
||||
const clientInfo = this.clients.get(ws);
|
||||
|
||||
if (clientInfo) {
|
||||
// Remove from all session subscriptions
|
||||
clientInfo.subscribedSessions.forEach((sessionId) => {
|
||||
if (this.sessionSubscriptions.has(sessionId)) {
|
||||
this.sessionSubscriptions.get(sessionId).delete(ws);
|
||||
|
||||
// Clean up empty subscription sets
|
||||
if (this.sessionSubscriptions.get(sessionId).size === 0) {
|
||||
this.sessionSubscriptions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.clients.delete(ws);
|
||||
console.log('[WebSocket] Client disconnected and cleaned up');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat to detect dead connections
|
||||
*/
|
||||
startHeartbeat() {
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const timeout = 60000; // 60 seconds
|
||||
|
||||
this.clients.forEach((clientInfo, ws) => {
|
||||
if (now - clientInfo.lastPing > timeout) {
|
||||
console.log('[WebSocket] Client timeout, closing connection');
|
||||
ws.terminate();
|
||||
this.removeClient(ws);
|
||||
}
|
||||
});
|
||||
}, 30000); // Check every 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection statistics
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
totalClients: this.clients.size,
|
||||
authenticatedClients: Array.from(this.clients.values()).filter(c => c.authenticated).length,
|
||||
totalSubscriptions: this.sessionSubscriptions.size,
|
||||
subscriptionDetails: Array.from(this.sessionSubscriptions.entries()).map(([sessionId, subs]) => ({
|
||||
sessionId,
|
||||
subscribers: subs.size
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let instance = null;
|
||||
|
||||
module.exports = {
|
||||
WebSocketManager,
|
||||
getWebSocketManager: () => instance,
|
||||
setWebSocketManager: (manager) => {
|
||||
instance = manager;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user