initial commit

This commit is contained in:
cottongin
2025-10-30 04:27:43 -04:00
commit 2db707961c
34 changed files with 3487 additions and 0 deletions

0
backend/.gitkeep Normal file
View File

22
backend/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy application files
COPY . .
# Create data directory for SQLite database
RUN mkdir -p /app/data
# Expose port
EXPOSE 5000
# Start the application
CMD ["node", "server.js"]

73
backend/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,73 @@
const fs = require('fs');
const path = require('path');
const { parse } = require('csv-parse/sync');
const db = require('./database');
function bootstrapGames() {
// Check if games already exist
const count = db.prepare('SELECT COUNT(*) as count FROM games').get();
if (count.count > 0) {
console.log(`Database already has ${count.count} games. Skipping bootstrap.`);
return;
}
// Read the CSV file
const csvPath = path.join(__dirname, '..', 'games-list.csv');
if (!fs.existsSync(csvPath)) {
console.log('games-list.csv not found. Skipping bootstrap.');
return;
}
const csvContent = fs.readFileSync(csvPath, 'utf-8');
const records = parse(csvContent, {
columns: true,
skip_empty_lines: true,
trim: true
});
const insert = db.prepare(`
INSERT INTO games (
pack_name, title, min_players, max_players, length_minutes,
has_audience, family_friendly, game_type, secondary_type, enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
`);
const insertMany = db.transaction((games) => {
for (const game of games) {
insert.run(
game['Game Pack'],
game['Game Title'],
parseInt(game['Min. Players']) || 1,
parseInt(game['Max. Players']) || 8,
parseLengthMinutes(game['Length']),
parseBoolean(game['Audience']),
parseBoolean(game['Family Friendly?']),
game['Game Type'] || null,
game['Secondary Type'] || null
);
}
});
insertMany(records);
console.log(`Successfully imported ${records.length} games from CSV`);
}
function parseLengthMinutes(lengthStr) {
if (!lengthStr || lengthStr === '????' || lengthStr === '?') {
return null;
}
const match = lengthStr.match(/(\d+)/);
return match ? parseInt(match[1]) : null;
}
function parseBoolean(value) {
if (!value || value === '?' || value === '????') {
return 0;
}
return value.toLowerCase() === 'yes' ? 1 : 0;
}
module.exports = { bootstrapGames };

83
backend/database.js Normal file
View File

@@ -0,0 +1,83 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const dbPath = process.env.DB_PATH || path.join(__dirname, 'data', 'jackbox.db');
const dbDir = path.dirname(dbPath);
// Ensure data directory exists
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const db = new Database(dbPath);
// Enable foreign keys
db.pragma('foreign_keys = ON');
// Create tables
function initializeDatabase() {
// Games table
db.exec(`
CREATE TABLE IF NOT EXISTS games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pack_name TEXT NOT NULL,
title TEXT NOT NULL,
min_players INTEGER NOT NULL,
max_players INTEGER NOT NULL,
length_minutes INTEGER,
has_audience INTEGER DEFAULT 0,
family_friendly INTEGER DEFAULT 0,
game_type TEXT,
secondary_type TEXT,
play_count INTEGER DEFAULT 0,
popularity_score INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Sessions table
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
closed_at DATETIME,
is_active INTEGER DEFAULT 1,
notes TEXT
)
`);
// Session games table
db.exec(`
CREATE TABLE IF NOT EXISTS session_games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
game_id INTEGER NOT NULL,
played_at DATETIME DEFAULT CURRENT_TIMESTAMP,
manually_added INTEGER DEFAULT 0,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
)
`);
// Chat logs table
db.exec(`
CREATE TABLE IF NOT EXISTS chat_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
chatter_name TEXT NOT NULL,
message TEXT NOT NULL,
timestamp DATETIME NOT NULL,
parsed_vote TEXT,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
)
`);
console.log('Database initialized successfully');
}
initializeDatabase();
module.exports = db;

View File

@@ -0,0 +1,23 @@
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
req.user = user;
next();
});
}
module.exports = { authenticateToken, JWT_SECRET };

26
backend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "jackbox-game-picker-backend",
"version": "1.0.0",
"description": "Backend API for Jackbox Party Pack Game Picker",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"better-sqlite3": "^9.2.2",
"jsonwebtoken": "^9.0.2",
"dotenv": "^16.3.1",
"csv-parse": "^5.5.3",
"csv-stringify": "^6.4.5"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

44
backend/routes/auth.js Normal file
View File

@@ -0,0 +1,44 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const { JWT_SECRET, authenticateToken } = require('../middleware/auth');
const router = express.Router();
const ADMIN_KEY = process.env.ADMIN_KEY || 'admin123';
// Login with admin key
router.post('/login', (req, res) => {
const { key } = req.body;
if (!key) {
return res.status(400).json({ error: 'Admin key is required' });
}
if (key !== ADMIN_KEY) {
return res.status(401).json({ error: 'Invalid admin key' });
}
// Generate JWT token
const token = jwt.sign(
{ role: 'admin', timestamp: Date.now() },
JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
token,
message: 'Authentication successful',
expiresIn: '24h'
});
});
// Verify token validity
router.post('/verify', authenticateToken, (req, res) => {
res.json({
valid: true,
user: req.user
});
});
module.exports = router;

364
backend/routes/games.js Normal file
View File

@@ -0,0 +1,364 @@
const express = require('express');
const { authenticateToken } = require('../middleware/auth');
const db = require('../database');
const { stringify } = require('csv-stringify/sync');
const { parse } = require('csv-parse/sync');
const router = express.Router();
// Get all games with optional filters
router.get('/', (req, res) => {
try {
const {
enabled,
minPlayers,
maxPlayers,
playerCount,
drawing,
length,
familyFriendly,
pack
} = req.query;
let query = 'SELECT * FROM games WHERE 1=1';
const params = [];
if (enabled !== undefined) {
query += ' AND enabled = ?';
params.push(enabled === 'true' ? 1 : 0);
}
if (playerCount) {
const count = parseInt(playerCount);
query += ' AND min_players <= ? AND max_players >= ?';
params.push(count, count);
}
if (drawing === 'only') {
query += ' AND game_type = ?';
params.push('Drawing');
} else if (drawing === 'exclude') {
query += ' AND (game_type != ? OR game_type IS NULL)';
params.push('Drawing');
}
if (length) {
if (length === 'short') {
query += ' AND (length_minutes <= 15 OR length_minutes IS NULL)';
} else if (length === 'medium') {
query += ' AND length_minutes > 15 AND length_minutes <= 25';
} else if (length === 'long') {
query += ' AND length_minutes > 25';
}
}
if (familyFriendly !== undefined) {
query += ' AND family_friendly = ?';
params.push(familyFriendly === 'true' ? 1 : 0);
}
if (pack) {
query += ' AND pack_name = ?';
params.push(pack);
}
query += ' ORDER BY pack_name, title';
const stmt = db.prepare(query);
const games = stmt.all(...params);
res.json(games);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get single game by ID
router.get('/:id', (req, res) => {
try {
const game = db.prepare('SELECT * FROM games WHERE id = ?').get(req.params.id);
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
res.json(game);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Create new game (admin only)
router.post('/', authenticateToken, (req, res) => {
try {
const {
pack_name,
title,
min_players,
max_players,
length_minutes,
has_audience,
family_friendly,
game_type,
secondary_type
} = req.body;
if (!pack_name || !title || !min_players || !max_players) {
return res.status(400).json({ error: 'Missing required fields' });
}
const stmt = db.prepare(`
INSERT INTO games (
pack_name, title, min_players, max_players, length_minutes,
has_audience, family_friendly, game_type, secondary_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
pack_name,
title,
min_players,
max_players,
length_minutes || null,
has_audience ? 1 : 0,
family_friendly ? 1 : 0,
game_type || null,
secondary_type || null
);
const newGame = db.prepare('SELECT * FROM games WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(newGame);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Update game (admin only)
router.put('/:id', authenticateToken, (req, res) => {
try {
const {
pack_name,
title,
min_players,
max_players,
length_minutes,
has_audience,
family_friendly,
game_type,
secondary_type,
enabled
} = req.body;
const stmt = db.prepare(`
UPDATE games SET
pack_name = COALESCE(?, pack_name),
title = COALESCE(?, title),
min_players = COALESCE(?, min_players),
max_players = COALESCE(?, max_players),
length_minutes = ?,
has_audience = COALESCE(?, has_audience),
family_friendly = COALESCE(?, family_friendly),
game_type = ?,
secondary_type = ?,
enabled = COALESCE(?, enabled)
WHERE id = ?
`);
const result = stmt.run(
pack_name || null,
title || null,
min_players || null,
max_players || null,
length_minutes !== undefined ? length_minutes : null,
has_audience !== undefined ? (has_audience ? 1 : 0) : null,
family_friendly !== undefined ? (family_friendly ? 1 : 0) : null,
game_type !== undefined ? game_type : null,
secondary_type !== undefined ? secondary_type : null,
enabled !== undefined ? (enabled ? 1 : 0) : null,
req.params.id
);
if (result.changes === 0) {
return res.status(404).json({ error: 'Game not found' });
}
const updatedGame = db.prepare('SELECT * FROM games WHERE id = ?').get(req.params.id);
res.json(updatedGame);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Delete game (admin only)
router.delete('/:id', authenticateToken, (req, res) => {
try {
const stmt = db.prepare('DELETE FROM games WHERE id = ?');
const result = stmt.run(req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Game not found' });
}
res.json({ message: 'Game deleted successfully' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Toggle game enabled status (admin only)
router.patch('/:id/toggle', authenticateToken, (req, res) => {
try {
const game = db.prepare('SELECT enabled FROM games WHERE id = ?').get(req.params.id);
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
const newStatus = game.enabled === 1 ? 0 : 1;
db.prepare('UPDATE games SET enabled = ? WHERE id = ?').run(newStatus, req.params.id);
const updatedGame = db.prepare('SELECT * FROM games WHERE id = ?').get(req.params.id);
res.json(updatedGame);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get list of unique pack names
router.get('/meta/packs', (req, res) => {
try {
const packs = db.prepare(`
SELECT
pack_name,
COUNT(*) as game_count,
SUM(enabled) as enabled_count
FROM games
GROUP BY pack_name
ORDER BY pack_name
`).all();
res.json(packs);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Toggle entire pack (admin only)
router.patch('/packs/:name/toggle', authenticateToken, (req, res) => {
try {
const { enabled } = req.body;
if (enabled === undefined) {
return res.status(400).json({ error: 'enabled status required' });
}
const stmt = db.prepare('UPDATE games SET enabled = ? WHERE pack_name = ?');
const result = stmt.run(enabled ? 1 : 0, req.params.name);
res.json({
message: `Pack ${enabled ? 'enabled' : 'disabled'} successfully`,
gamesAffected: result.changes
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Export games to CSV (admin only)
router.get('/export/csv', authenticateToken, (req, res) => {
try {
const games = db.prepare('SELECT * FROM games ORDER BY pack_name, title').all();
const csvData = games.map(game => ({
'Game Pack': game.pack_name,
'Game Title': game.title,
'Min. Players': game.min_players,
'Max. Players': game.max_players,
'Length': game.length_minutes ? `${game.length_minutes} minutes` : '????',
'Audience': game.has_audience ? 'Yes' : 'No',
'Family Friendly?': game.family_friendly ? 'Yes' : 'No',
'Game Type': game.game_type || '',
'Secondary Type': game.secondary_type || ''
}));
const csv = stringify(csvData, { header: true });
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=games-export.csv');
res.send(csv);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Import games from CSV (admin only)
router.post('/import/csv', authenticateToken, (req, res) => {
try {
const { csvData, mode } = req.body; // mode: 'append' or 'replace'
if (!csvData) {
return res.status(400).json({ error: 'CSV data required' });
}
const records = parse(csvData, {
columns: true,
skip_empty_lines: true,
trim: true
});
if (mode === 'replace') {
db.prepare('DELETE FROM games').run();
}
const insert = db.prepare(`
INSERT INTO games (
pack_name, title, min_players, max_players, length_minutes,
has_audience, family_friendly, game_type, secondary_type, enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
`);
const insertMany = db.transaction((games) => {
for (const game of games) {
insert.run(
game['Game Pack'],
game['Game Title'],
parseInt(game['Min. Players']) || 1,
parseInt(game['Max. Players']) || 8,
parseLengthMinutes(game['Length']),
parseBoolean(game['Audience']),
parseBoolean(game['Family Friendly?']),
game['Game Type'] || null,
game['Secondary Type'] || null
);
}
});
insertMany(records);
res.json({
message: `Successfully imported ${records.length} games`,
count: records.length,
mode
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
function parseLengthMinutes(lengthStr) {
if (!lengthStr || lengthStr === '????' || lengthStr === '?') {
return null;
}
const match = lengthStr.match(/(\d+)/);
return match ? parseInt(match[1]) : null;
}
function parseBoolean(value) {
if (!value || value === '?' || value === '????') {
return 0;
}
return value.toLowerCase() === 'yes' ? 1 : 0;
}
module.exports = router;

100
backend/routes/picker.js Normal file
View File

@@ -0,0 +1,100 @@
const express = require('express');
const db = require('../database');
const router = express.Router();
// Pick a random game with filters and repeat avoidance
router.post('/pick', (req, res) => {
try {
const {
playerCount,
drawing,
length,
familyFriendly,
sessionId
} = req.body;
// Build query for eligible games
let query = 'SELECT * FROM games WHERE enabled = 1';
const params = [];
if (playerCount) {
const count = parseInt(playerCount);
query += ' AND min_players <= ? AND max_players >= ?';
params.push(count, count);
}
if (drawing === 'only') {
query += ' AND game_type = ?';
params.push('Drawing');
} else if (drawing === 'exclude') {
query += ' AND (game_type != ? OR game_type IS NULL)';
params.push('Drawing');
}
if (length) {
if (length === 'short') {
query += ' AND (length_minutes <= 15 OR length_minutes IS NULL)';
} else if (length === 'medium') {
query += ' AND length_minutes > 15 AND length_minutes <= 25';
} else if (length === 'long') {
query += ' AND length_minutes > 25';
}
}
if (familyFriendly !== undefined) {
query += ' AND family_friendly = ?';
params.push(familyFriendly ? 1 : 0);
}
// Get eligible games
const stmt = db.prepare(query);
let eligibleGames = stmt.all(...params);
if (eligibleGames.length === 0) {
return res.status(404).json({
error: 'No games match the current filters',
suggestion: 'Try adjusting your filters or enabling more games'
});
}
// Apply repeat avoidance if session provided
if (sessionId) {
const lastGames = db.prepare(`
SELECT game_id FROM session_games
WHERE session_id = ?
ORDER BY played_at DESC
LIMIT 2
`).all(sessionId);
const excludeIds = lastGames.map(g => g.game_id);
if (excludeIds.length > 0) {
eligibleGames = eligibleGames.filter(game => !excludeIds.includes(game.id));
}
if (eligibleGames.length === 0) {
return res.status(404).json({
error: 'All eligible games have been played recently',
suggestion: 'Enable more games or adjust your filters',
recentlyPlayed: excludeIds
});
}
}
// Pick random game from eligible pool
const randomIndex = Math.floor(Math.random() * eligibleGames.length);
const selectedGame = eligibleGames[randomIndex];
res.json({
game: selectedGame,
poolSize: eligibleGames.length,
totalEnabled: eligibleGames.length + (sessionId ? 2 : 0) // Approximate
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

340
backend/routes/sessions.js Normal file
View File

@@ -0,0 +1,340 @@
const express = require('express');
const { authenticateToken } = require('../middleware/auth');
const db = require('../database');
const router = express.Router();
// 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();
if (!session) {
return res.status(404).json({ error: 'No active session found' });
}
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);
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' });
}
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);
const closedSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id);
res.json(closedSession);
} 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
FROM session_games sg
JOIN games g ON sg.game_id = g.id
WHERE sg.session_id = ?
ORDER BY sg.played_at ASC
`).all(req.params.id);
res.json(games);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Add game to session (admin only)
router.post('/:id/games', authenticateToken, (req, res) => {
try {
const { game_id, manually_added } = 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' });
}
// Add game to session
const stmt = db.prepare(`
INSERT INTO session_games (session_id, game_id, manually_added)
VALUES (?, ?, ?)
`);
const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0);
// 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
FROM session_games sg
JOIN games g ON sg.game_id = g.id
WHERE sg.id = ?
`).get(result.lastInsertRowid);
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
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;
const votesByGame = {};
const insertChatLog = db.prepare(`
INSERT INTO chat_logs (session_id, chatter_name, message, timestamp, parsed_vote)
VALUES (?, ?, ?, ?, ?)
`);
const updatePopularity = db.prepare(`
UPDATE games SET popularity_score = popularity_score + ? WHERE id = ?
`);
const processVotes = db.transaction((messages) => {
for (const msg of messages) {
const { username, message, timestamp } = msg;
if (!username || !message || !timestamp) {
continue;
}
// Check for vote patterns
let vote = null;
if (message.includes('thisgame++')) {
vote = 'thisgame++';
} else if (message.includes('thisgame--')) {
vote = 'thisgame--';
}
// Insert into chat logs
insertChatLog.run(
req.params.id,
username,
message,
timestamp,
vote
);
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 points = vote === 'thisgame++' ? 1 : -1;
updatePopularity.run(points, matchedGame.game_id);
if (!votesByGame[matchedGame.game_id]) {
votesByGame[matchedGame.game_id] = {
title: matchedGame.title,
upvotes: 0,
downvotes: 0
};
}
if (points > 0) {
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,
votesProcessed,
votesByGame
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

39
backend/routes/stats.js Normal file
View File

@@ -0,0 +1,39 @@
const express = require('express');
const db = require('../database');
const router = express.Router();
// Get overall statistics
router.get('/', (req, res) => {
try {
const stats = {
games: db.prepare('SELECT COUNT(*) as count FROM games').get(),
gamesEnabled: db.prepare('SELECT COUNT(*) as count FROM games WHERE enabled = 1').get(),
packs: db.prepare('SELECT COUNT(DISTINCT pack_name) as count FROM games').get(),
sessions: db.prepare('SELECT COUNT(*) as count FROM sessions').get(),
activeSessions: db.prepare('SELECT COUNT(*) as count FROM sessions WHERE is_active = 1').get(),
totalGamesPlayed: db.prepare('SELECT COUNT(*) as count FROM session_games').get(),
mostPlayedGames: db.prepare(`
SELECT g.id, g.title, g.pack_name, g.play_count, g.popularity_score
FROM games g
WHERE g.play_count > 0
ORDER BY g.play_count DESC
LIMIT 10
`).all(),
topRatedGames: db.prepare(`
SELECT g.id, g.title, g.pack_name, g.play_count, g.popularity_score
FROM games g
WHERE g.popularity_score > 0
ORDER BY g.popularity_score DESC
LIMIT 10
`).all()
};
res.json(stats);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

43
backend/server.js Normal file
View File

@@ -0,0 +1,43 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const { bootstrapGames } = require('./bootstrap');
const app = express();
const PORT = process.env.PORT || 5000;
// Middleware
app.use(cors());
app.use(express.json());
// Bootstrap database with games
bootstrapGames();
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', message: 'Jackbox Game Picker API is running' });
});
// Routes
const authRoutes = require('./routes/auth');
const gamesRoutes = require('./routes/games');
const sessionsRoutes = require('./routes/sessions');
const statsRoutes = require('./routes/stats');
const pickerRoutes = require('./routes/picker');
app.use('/api/auth', authRoutes);
app.use('/api/games', gamesRoutes);
app.use('/api/sessions', sessionsRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api', pickerRoutes);
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something went wrong!', message: err.message });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server is running on port ${PORT}`);
});