initial commit
This commit is contained in:
0
backend/.gitkeep
Normal file
0
backend/.gitkeep
Normal file
22
backend/Dockerfile
Normal file
22
backend/Dockerfile
Normal 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
73
backend/bootstrap.js
vendored
Normal 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
83
backend/database.js
Normal 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;
|
||||
|
||||
23
backend/middleware/auth.js
Normal file
23
backend/middleware/auth.js
Normal 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
26
backend/package.json
Normal 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
44
backend/routes/auth.js
Normal 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
364
backend/routes/games.js
Normal 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
100
backend/routes/picker.js
Normal 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
340
backend/routes/sessions.js
Normal 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
39
backend/routes/stats.js
Normal 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
43
backend/server.js
Normal 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}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user