Compare commits
5 Commits
0a59da8ee9
...
91b7de3bb7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91b7de3bb7
|
||
|
|
ea23b66cbf
|
||
|
|
ea6e8db90b
|
||
|
|
b2bb2989e9
|
||
|
|
52e9a7af42
|
13
backend/.dockerignore
Normal file
13
backend/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
data/
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
config/admins.json
|
||||||
|
|
||||||
29
backend/bootstrap.js
vendored
29
backend/bootstrap.js
vendored
@@ -54,6 +54,33 @@ function bootstrapGames() {
|
|||||||
console.log(`Successfully imported ${records.length} games from CSV`);
|
console.log(`Successfully imported ${records.length} games from CSV`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bootstrapTickers() {
|
||||||
|
const tickersPath = path.join(__dirname, 'config', 'tickers.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(tickersPath)) {
|
||||||
|
console.log('tickers.json not found. Skipping ticker bootstrap.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickers = JSON.parse(fs.readFileSync(tickersPath, 'utf-8'));
|
||||||
|
|
||||||
|
const update = db.prepare('UPDATE games SET ticker = ? WHERE title = ? AND (ticker IS NULL OR ticker != ?)');
|
||||||
|
|
||||||
|
const updateMany = db.transaction((entries) => {
|
||||||
|
let updated = 0;
|
||||||
|
for (const [symbol, title] of entries) {
|
||||||
|
const result = update.run(symbol, title, symbol);
|
||||||
|
updated += result.changes;
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = updateMany(Object.entries(tickers));
|
||||||
|
if (updated > 0) {
|
||||||
|
console.log(`Updated ticker symbols for ${updated} games`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseLengthMinutes(lengthStr) {
|
function parseLengthMinutes(lengthStr) {
|
||||||
if (!lengthStr || lengthStr === '????' || lengthStr === '?') {
|
if (!lengthStr || lengthStr === '????' || lengthStr === '?') {
|
||||||
return null;
|
return null;
|
||||||
@@ -69,5 +96,5 @@ function parseBoolean(value) {
|
|||||||
return value.toLowerCase() === 'yes' ? 1 : 0;
|
return value.toLowerCase() === 'yes' ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { bootstrapGames };
|
module.exports = { bootstrapGames, bootstrapTickers };
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
[
|
[
|
||||||
{ "name": "Alice", "key": "change-me-alice-key" },
|
{ "name": "Alice", "role": "admin", "key": "change-me-alice-key" },
|
||||||
{ "name": "Bob", "key": "change-me-bob-key" }
|
{ "name": "Bob", "role": "bot", "key": "change-me-bob-key" },
|
||||||
|
{ "name": "Charlie", "role": "utility", "key": "change-me-charlie-key" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,10 +3,19 @@ const path = require('path');
|
|||||||
|
|
||||||
const DEFAULT_CONFIG_PATH = path.join(__dirname, 'admins.json');
|
const DEFAULT_CONFIG_PATH = path.join(__dirname, 'admins.json');
|
||||||
|
|
||||||
|
function canRead(filePath) {
|
||||||
|
try {
|
||||||
|
fs.accessSync(filePath, fs.constants.R_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadAdmins() {
|
function loadAdmins() {
|
||||||
const configPath = process.env.ADMIN_CONFIG_PATH || DEFAULT_CONFIG_PATH;
|
const configPath = process.env.ADMIN_CONFIG_PATH || DEFAULT_CONFIG_PATH;
|
||||||
|
|
||||||
if (fs.existsSync(configPath)) {
|
if (canRead(configPath)) {
|
||||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||||
const admins = JSON.parse(raw);
|
const admins = JSON.parse(raw);
|
||||||
|
|
||||||
@@ -35,6 +44,10 @@ function loadAdmins() {
|
|||||||
return admins;
|
return admins;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(configPath) && !canRead(configPath)) {
|
||||||
|
console.warn(`[Auth] Config file exists at ${configPath} but is not readable, skipping`);
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.ADMIN_KEY) {
|
if (process.env.ADMIN_KEY) {
|
||||||
console.log('[Auth] No admins config file found, falling back to ADMIN_KEY env var');
|
console.log('[Auth] No admins config file found, falling back to ADMIN_KEY env var');
|
||||||
return [{ name: 'Admin', key: process.env.ADMIN_KEY }];
|
return [{ name: 'Admin', key: process.env.ADMIN_KEY }];
|
||||||
@@ -49,7 +62,7 @@ const admins = loadAdmins();
|
|||||||
|
|
||||||
function findAdminByKey(key) {
|
function findAdminByKey(key) {
|
||||||
const match = admins.find(a => a.key === key);
|
const match = admins.find(a => a.key === key);
|
||||||
return match ? { name: match.name } : null;
|
return match ? { name: match.name, role: match.role || 'admin' } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { findAdminByKey, admins };
|
module.exports = { findAdminByKey, admins };
|
||||||
|
|||||||
60
backend/config/tickers.json
Normal file
60
backend/config/tickers.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"QPL3": "Quiplash 3",
|
||||||
|
"QPL2": "Quiplash 2",
|
||||||
|
"QLXL": "Quiplash XL",
|
||||||
|
"FBXL": "Fibbage XL",
|
||||||
|
"FBG2": "Fibbage 2",
|
||||||
|
"FBG3": "Fibbage 3",
|
||||||
|
"FBG4": "Fibbage 4",
|
||||||
|
"TMP1": "Trivia Murder Party",
|
||||||
|
"TMP2": "Trivia Murder Party 2",
|
||||||
|
"DRWF": "Drawful",
|
||||||
|
"DRWA": "Drawful Animate",
|
||||||
|
"DD": "Dirty Drawful",
|
||||||
|
"DOOM": "Doominate",
|
||||||
|
"JJ": "Job Job",
|
||||||
|
"TKO2": "Tee K.O. 2",
|
||||||
|
"TKOX": "Tee K.O. T-Shirt Knock Out",
|
||||||
|
"CU": "Champ'd Up",
|
||||||
|
"BR": "Blather 'Round",
|
||||||
|
"STR": "Split the Room",
|
||||||
|
"ROOM": "Roomerang",
|
||||||
|
"BRKT": "Bracketeering",
|
||||||
|
"NNSR": "Nonsensory",
|
||||||
|
"QXRT": "Quixort",
|
||||||
|
"JNKT": "Junktopia",
|
||||||
|
"TP": "Talking Points",
|
||||||
|
"PS": "Patently Stupid",
|
||||||
|
"PTB": "Push the Button",
|
||||||
|
"WD": "Weapons Drawn",
|
||||||
|
"HPNT": "Hypnotorious",
|
||||||
|
"DCTN": "Dictionarium",
|
||||||
|
"RM": "Role Models",
|
||||||
|
"JB": "Joke Boat",
|
||||||
|
"GSPN": "Guesspionage",
|
||||||
|
"MVC": "Mad Verse City",
|
||||||
|
"HRSY": "Hear Say",
|
||||||
|
"CH": "Cookie Haus",
|
||||||
|
"SPCT": "Suspectives",
|
||||||
|
"LOT": "Legends of Trivia",
|
||||||
|
"STI": "Survive the Internet",
|
||||||
|
"CVDL": "Civic Doodle",
|
||||||
|
"MSM": "Monster Seeking Monster",
|
||||||
|
"TPM": "The Poll Mine",
|
||||||
|
"TWEP": "The Wheel of Enormous Proportions",
|
||||||
|
"TJ": "Time Jinx",
|
||||||
|
"DRM": "Dodo Re Mi",
|
||||||
|
"FT": "Fixy Text",
|
||||||
|
"SS": "Survey Scramble",
|
||||||
|
"WS": "Word Spud",
|
||||||
|
"LS": "Lie Swatter",
|
||||||
|
"FI": "Fakin' It!",
|
||||||
|
"FANL": "Fakin' It All Night Long",
|
||||||
|
"LMF": "Let Me Finish",
|
||||||
|
"BDTS": "Bidiots",
|
||||||
|
"BC": "Bomb Corp.",
|
||||||
|
"YDK1": "You Don't Know Jack\u00ae 2015",
|
||||||
|
"YDKJ": "You Don't Know Jack\u00ae Full Stream",
|
||||||
|
"ZPDM": "Zeeple Dome",
|
||||||
|
"EW": "Earwax\u2122"
|
||||||
|
}
|
||||||
@@ -125,6 +125,19 @@ function initializeDatabase() {
|
|||||||
// Column already exists, ignore error
|
// Column already exists, ignore error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add ticker column for ticker-symbol voting
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE games ADD COLUMN ticker TEXT`);
|
||||||
|
} catch (err) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_games_ticker ON games(ticker)`);
|
||||||
|
} catch (err) {
|
||||||
|
// Index already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
// Migrate existing popularity_score to upvotes/downvotes if needed
|
// Migrate existing popularity_score to upvotes/downvotes if needed
|
||||||
try {
|
try {
|
||||||
const gamesWithScore = db.prepare(`
|
const gamesWithScore = db.prepare(`
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ router.post('/login', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ role: 'admin', name: admin.name, timestamp: Date.now() },
|
{ role: admin.role, name: admin.name, timestamp: Date.now() },
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: '24h' }
|
{ expiresIn: '24h' }
|
||||||
);
|
);
|
||||||
@@ -26,6 +26,7 @@ router.post('/login', (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
token,
|
token,
|
||||||
name: admin.name,
|
name: admin.name,
|
||||||
|
role: admin.role,
|
||||||
message: 'Authentication successful',
|
message: 'Authentication successful',
|
||||||
expiresIn: '24h'
|
expiresIn: '24h'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ router.get('/', (req, res) => {
|
|||||||
// Live vote endpoint - receives real-time votes from bot
|
// Live vote endpoint - receives real-time votes from bot
|
||||||
router.post('/live', authenticateToken, (req, res) => {
|
router.post('/live', authenticateToken, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username, vote, timestamp } = req.body;
|
const { username, vote, timestamp, ticker } = req.body;
|
||||||
|
|
||||||
// Validate payload
|
// Validate payload
|
||||||
if (!username || !vote || !timestamp) {
|
if (!username || !vote || !timestamp) {
|
||||||
@@ -123,7 +123,24 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all games played in this session with timestamps
|
let matchedGame = null;
|
||||||
|
|
||||||
|
if (ticker) {
|
||||||
|
// Ticker voting: resolve game globally by ticker symbol
|
||||||
|
const game = db.prepare(`
|
||||||
|
SELECT id AS game_id, title, pack_name, upvotes, downvotes, popularity_score
|
||||||
|
FROM games WHERE ticker = ?
|
||||||
|
`).get(ticker);
|
||||||
|
|
||||||
|
if (!game) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: `Unknown ticker '${ticker}'`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedGame = game;
|
||||||
|
} else {
|
||||||
|
// thisgame++/thisgame-- voting: resolve game by timestamp interval
|
||||||
const sessionGames = db.prepare(`
|
const sessionGames = db.prepare(`
|
||||||
SELECT sg.game_id, sg.played_at, g.title, g.pack_name, g.upvotes, g.downvotes, g.popularity_score
|
SELECT sg.game_id, sg.played_at, g.title, g.pack_name, g.upvotes, g.downvotes, g.popularity_score
|
||||||
FROM session_games sg
|
FROM session_games sg
|
||||||
@@ -138,9 +155,7 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match vote timestamp to the correct game using interval logic
|
|
||||||
const voteTime = voteTimestamp.getTime();
|
const voteTime = voteTimestamp.getTime();
|
||||||
let matchedGame = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < sessionGames.length; i++) {
|
for (let i = 0; i < sessionGames.length; i++) {
|
||||||
const currentGame = sessionGames[i];
|
const currentGame = sessionGames[i];
|
||||||
@@ -155,7 +170,6 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Last game in session - vote belongs here if timestamp is after this game started
|
|
||||||
if (voteTime >= currentGameTime) {
|
if (voteTime >= currentGameTime) {
|
||||||
matchedGame = currentGame;
|
matchedGame = currentGame;
|
||||||
break;
|
break;
|
||||||
@@ -175,6 +189,7 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for duplicate vote (within 1 second window)
|
// Check for duplicate vote (within 1 second window)
|
||||||
// Get the most recent vote from this user
|
// Get the most recent vote from this user
|
||||||
@@ -258,6 +273,7 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
id: updatedGame.id,
|
id: updatedGame.id,
|
||||||
title: updatedGame.title,
|
title: updatedGame.title,
|
||||||
pack_name: matchedGame.pack_name,
|
pack_name: matchedGame.pack_name,
|
||||||
|
ticker: ticker || undefined,
|
||||||
},
|
},
|
||||||
vote: {
|
vote: {
|
||||||
username: username,
|
username: username,
|
||||||
@@ -303,7 +319,8 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
vote: {
|
vote: {
|
||||||
username: username,
|
username: username,
|
||||||
type: vote,
|
type: vote,
|
||||||
timestamp: timestamp
|
timestamp: timestamp,
|
||||||
|
ticker: ticker || undefined,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ require('dotenv').config();
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const { bootstrapGames } = require('./bootstrap');
|
const { bootstrapGames, bootstrapTickers } = require('./bootstrap');
|
||||||
const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager');
|
const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager');
|
||||||
const { cleanupAllShards } = require('./utils/ecast-shard-client');
|
const { cleanupAllShards } = require('./utils/ecast-shard-client');
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ setWebSocketManager(wsManager);
|
|||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
bootstrapGames();
|
bootstrapGames();
|
||||||
|
bootstrapTickers();
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Server is running on port ${PORT}`);
|
console.log(`Server is running on port ${PORT}`);
|
||||||
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
|
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
|
||||||
|
|||||||
@@ -26,13 +26,17 @@ class WebSocketManager {
|
|||||||
* Handle new WebSocket connection
|
* Handle new WebSocket connection
|
||||||
*/
|
*/
|
||||||
handleConnection(ws, req) {
|
handleConnection(ws, req) {
|
||||||
console.log('[WebSocket] New connection from', req.socket.remoteAddress);
|
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim()
|
||||||
|
|| req.headers['x-real-ip']
|
||||||
|
|| req.socket.remoteAddress;
|
||||||
|
console.log('[WebSocket] New connection from', clientIp);
|
||||||
|
|
||||||
// Initialize client info
|
// Initialize client info
|
||||||
const clientInfo = {
|
const clientInfo = {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
userId: null,
|
userId: null,
|
||||||
adminName: null,
|
adminName: null,
|
||||||
|
role: null,
|
||||||
currentPage: null,
|
currentPage: null,
|
||||||
subscribedSessions: new Set(),
|
subscribedSessions: new Set(),
|
||||||
lastPing: Date.now()
|
lastPing: Date.now()
|
||||||
@@ -130,6 +134,7 @@ class WebSocketManager {
|
|||||||
clientInfo.authenticated = true;
|
clientInfo.authenticated = true;
|
||||||
clientInfo.userId = decoded.role;
|
clientInfo.userId = decoded.role;
|
||||||
clientInfo.adminName = decoded.name || null;
|
clientInfo.adminName = decoded.name || null;
|
||||||
|
clientInfo.role = decoded.role || 'admin';
|
||||||
|
|
||||||
if (!decoded.name) {
|
if (!decoded.name) {
|
||||||
this.sendError(ws, 'Token missing admin identity, please re-login', 'auth_error');
|
this.sendError(ws, 'Token missing admin identity, please re-login', 'auth_error');
|
||||||
@@ -141,7 +146,8 @@ class WebSocketManager {
|
|||||||
message: 'Authenticated successfully'
|
message: 'Authenticated successfully'
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[WebSocket] Client authenticated:', clientInfo.userId);
|
console.log('[WebSocket] Client authenticated:', clientInfo.adminName);
|
||||||
|
this.broadcastPresence();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[WebSocket] Authentication failed:', err.message);
|
console.error('[WebSocket] Authentication failed:', err.message);
|
||||||
@@ -299,7 +305,7 @@ class WebSocketManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.clients.delete(ws);
|
this.clients.delete(ws);
|
||||||
console.log('[WebSocket] Client disconnected and cleaned up');
|
console.log('[WebSocket] Client disconnected:', clientInfo.adminName || 'unauthenticated');
|
||||||
this.broadcastPresence();
|
this.broadcastPresence();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,8 +313,12 @@ class WebSocketManager {
|
|||||||
broadcastPresence() {
|
broadcastPresence() {
|
||||||
const admins = [];
|
const admins = [];
|
||||||
this.clients.forEach((info) => {
|
this.clients.forEach((info) => {
|
||||||
if (info.authenticated && info.adminName && info.currentPage) {
|
if (!info.authenticated || !info.adminName) return;
|
||||||
admins.push({ name: info.adminName, page: info.currentPage });
|
const role = info.role || 'admin';
|
||||||
|
if (role === 'bot' || role === 'utility') {
|
||||||
|
admins.push({ name: info.adminName, role, page: null });
|
||||||
|
} else if (info.currentPage) {
|
||||||
|
admins.push({ name: info.adminName, role, page: info.currentPage });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ services:
|
|||||||
- DEBUG=false
|
- DEBUG=false
|
||||||
volumes:
|
volumes:
|
||||||
- jackbox-data:/app/data
|
- jackbox-data:/app/data
|
||||||
- ./games-list.csv:/app/games-list.csv:ro
|
- ./games-list.csv:/app/games-list.csv:ro,z
|
||||||
# - ./backend/config/admins.json:/app/config/admins.json:ro
|
- ./backend/config/admins.json:/app/config/admins.json:ro,z
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# Votes Endpoints
|
# Votes Endpoints
|
||||||
|
|
||||||
Real-time popularity voting. Bots or integrations send votes during live gaming sessions. Votes are matched to the currently-playing game using timestamp intervals.
|
Real-time popularity voting. Bots or integrations send votes during live gaming sessions. Two voting mechanisms are supported:
|
||||||
|
|
||||||
|
- **`thisgame++`/`thisgame--`** — votes for the game currently being played, matched via timestamp intervals.
|
||||||
|
- **Ticker voting** — votes for a specific game by its ticker symbol (e.g. `QPL3` for Quiplash 3), regardless of what is currently being played.
|
||||||
|
|
||||||
## Endpoint Summary
|
## Endpoint Summary
|
||||||
|
|
||||||
@@ -71,7 +74,10 @@ Results are ordered by `timestamp DESC`. The `vote_type` field is returned as `"
|
|||||||
|
|
||||||
## POST /api/votes/live
|
## POST /api/votes/live
|
||||||
|
|
||||||
Submit a real-time up/down vote for the game currently being played. Automatically finds the active session and matches the vote to the correct game using the provided timestamp and session game intervals.
|
Submit a real-time up/down vote. Supports two independent voting mechanisms:
|
||||||
|
|
||||||
|
- **Ticker voting** — include a `ticker` field to vote for a specific game by symbol. The game is resolved globally and does not need to be in the active session.
|
||||||
|
- **`thisgame++`/`thisgame--` voting** — omit `ticker` to vote for the game currently being played, matched via timestamp intervals against `session_games`.
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
@@ -84,6 +90,20 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
|||||||
| username | string | Yes | Identifier for the voter (used for deduplication) |
|
| username | string | Yes | Identifier for the voter (used for deduplication) |
|
||||||
| vote | string | Yes | `"up"` or `"down"` |
|
| vote | string | Yes | `"up"` or `"down"` |
|
||||||
| timestamp | string | Yes | ISO 8601 timestamp when the vote occurred |
|
| timestamp | string | Yes | ISO 8601 timestamp when the vote occurred |
|
||||||
|
| ticker | string | No | Ticker symbol identifying the game (e.g. `QPL3`, `TMP2`). When provided, the game is resolved by ticker and timestamp matching is skipped. |
|
||||||
|
|
||||||
|
**Ticker vote:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "viewer123",
|
||||||
|
"vote": "up",
|
||||||
|
"timestamp": "2026-03-15T20:30:00Z",
|
||||||
|
"ticker": "QPL3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`thisgame++`/`thisgame--` vote (no ticker):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -96,7 +116,8 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
|||||||
### Behavior
|
### Behavior
|
||||||
|
|
||||||
- Finds the active session (single session with `is_active = 1`).
|
- Finds the active session (single session with `is_active = 1`).
|
||||||
- Matches the vote timestamp to the game being played at that time (uses interval between consecutive `played_at` timestamps).
|
- **With `ticker`:** Looks up the game globally by ticker symbol. The game does not need to be part of the active session.
|
||||||
|
- **Without `ticker`:** Matches the vote timestamp to the game being played at that time (uses interval between consecutive `played_at` timestamps).
|
||||||
- Updates game `upvotes`, `downvotes`, and `popularity_score` atomically in a transaction.
|
- Updates game `upvotes`, `downvotes`, and `popularity_score` atomically in a transaction.
|
||||||
- **Deduplication:** Rejects votes from the same username within a 1-second window (409 Conflict).
|
- **Deduplication:** Rejects votes from the same username within a 1-second window (409 Conflict).
|
||||||
- Broadcasts a `vote.received` WebSocket event to all clients subscribed to the active session. See [WebSocket Protocol](../websocket.md#votereceived) for event payload.
|
- Broadcasts a `vote.received` WebSocket event to all clients subscribed to the active session. See [WebSocket Protocol](../websocket.md#votereceived) for event payload.
|
||||||
@@ -105,6 +126,8 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
|||||||
|
|
||||||
**200 OK**
|
**200 OK**
|
||||||
|
|
||||||
|
The `ticker` field is included in the response when the vote was submitted with a ticker.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -120,7 +143,8 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
|||||||
"vote": {
|
"vote": {
|
||||||
"username": "viewer123",
|
"username": "viewer123",
|
||||||
"type": "up",
|
"type": "up",
|
||||||
"timestamp": "2026-03-15T20:30:00Z"
|
"timestamp": "2026-03-15T20:30:00Z",
|
||||||
|
"ticker": "QPL3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -133,12 +157,29 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
|||||||
| 400 | `{ "error": "vote must be either \"up\" or \"down\"" }` | Invalid vote value |
|
| 400 | `{ "error": "vote must be either \"up\" or \"down\"" }` | Invalid vote value |
|
||||||
| 400 | `{ "error": "Invalid timestamp format. Use ISO 8601 format (e.g., 2025-11-01T20:30:00Z)" }` | Invalid timestamp |
|
| 400 | `{ "error": "Invalid timestamp format. Use ISO 8601 format (e.g., 2025-11-01T20:30:00Z)" }` | Invalid timestamp |
|
||||||
| 404 | `{ "error": "No active session found" }` | No session with `is_active = 1` |
|
| 404 | `{ "error": "No active session found" }` | No session with `is_active = 1` |
|
||||||
| 404 | `{ "error": "No games have been played in the active session yet" }` | Active session has no games |
|
| 404 | `{ "error": "Unknown ticker 'XYZ'" }` | Ticker does not match any game |
|
||||||
| 404 | `{ "error": "Vote timestamp does not match any game in the active session", "debug": { "voteTimestamp": "2026-03-15T20:30:00Z", "sessionGames": [{ "title": "Quiplash 3", "played_at": "..." }] } }` | Timestamp outside any game interval |
|
| 404 | `{ "error": "No games have been played in the active session yet" }` | Active session has no games (timestamp voting only) |
|
||||||
|
| 404 | `{ "error": "Vote timestamp does not match any game in the active session", "debug": { ... } }` | Timestamp outside any game interval (timestamp voting only) |
|
||||||
| 409 | `{ "error": "Duplicate vote detected (within 1 second of previous vote)", "message": "Please wait at least 1 second between votes", "timeSinceLastVote": 0.5 }` | Same username voted within 1 second |
|
| 409 | `{ "error": "Duplicate vote detected (within 1 second of previous vote)", "message": "Please wait at least 1 second between votes", "timeSinceLastVote": 0.5 }` | Same username voted within 1 second |
|
||||||
| 500 | `{ "error": "..." }` | Server error |
|
| 500 | `{ "error": "..." }` | Server error |
|
||||||
|
|
||||||
### Example
|
### Examples
|
||||||
|
|
||||||
|
**Ticker vote:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:5000/api/votes/live" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "viewer123",
|
||||||
|
"vote": "up",
|
||||||
|
"timestamp": "2026-03-15T20:30:00Z",
|
||||||
|
"ticker": "QPL3"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**`thisgame++` vote (no ticker):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "http://localhost:5000/api/votes/live" \
|
curl -X POST "http://localhost:5000/api/votes/live" \
|
||||||
|
|||||||
@@ -157,9 +157,6 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Admin Presence */}
|
|
||||||
<PresenceBar />
|
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="container mx-auto px-4 py-8 flex-grow">
|
<main className="container mx-auto px-4 py-8 flex-grow">
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -172,8 +169,11 @@ function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Admin Presence */}
|
||||||
|
<PresenceBar />
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-12 flex-shrink-0">
|
<footer className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||||
<div className="container mx-auto px-4 py-6">
|
<div className="container mx-auto px-4 py-6">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex flex-col md:flex-row justify-between items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<div className="text-center md:text-left">
|
<div className="text-center md:text-left">
|
||||||
|
|||||||
@@ -2,37 +2,87 @@ import React from 'react';
|
|||||||
import { usePresence } from '../hooks/usePresence';
|
import { usePresence } from '../hooks/usePresence';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
function GearIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.248a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatBubbleIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceBadge({ name, role }) {
|
||||||
|
const isBot = role === 'bot';
|
||||||
|
const colorClass = isBot
|
||||||
|
? 'bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300'
|
||||||
|
: 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
|
||||||
|
{isBot ? <ChatBubbleIcon /> : <GearIcon />}
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PresenceBar() {
|
function PresenceBar() {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
const { viewers } = usePresence();
|
const { viewers, services } = usePresence();
|
||||||
|
|
||||||
if (!isAuthenticated) return null;
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
const otherViewers = viewers.filter(v => v !== 'me');
|
const otherViewers = viewers.filter(v => v.name !== 'me');
|
||||||
if (otherViewers.length === 0) return null;
|
const hasViewers = otherViewers.length > 0;
|
||||||
|
const hasServices = services.length > 0;
|
||||||
|
|
||||||
|
if (!hasViewers && !hasServices) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-2 sm:px-4 pt-3">
|
<div className="sticky bottom-0 z-40 flex justify-end px-4 pb-4 pointer-events-none">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 px-4 py-2">
|
<div className="pointer-events-auto bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 px-4 py-2 w-fit">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{hasViewers && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider font-medium flex-shrink-0">
|
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider font-medium flex-shrink-0">
|
||||||
who's here?
|
who's here?
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{viewers.map((name, i) => (
|
{viewers.map((v, i) => (
|
||||||
<span
|
<span
|
||||||
key={`${name}-${i}`}
|
key={`${v.name}-${i}`}
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
name === 'me'
|
v.name === 'me'
|
||||||
? 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300'
|
? 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300'
|
||||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{name}
|
{v.name}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{hasServices && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider font-medium flex-shrink-0">
|
||||||
|
connected
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{services.map((s, i) => (
|
||||||
|
<ServiceBadge key={`${s.name}-${i}`} name={s.name} role={s.role} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export const branding = {
|
|||||||
app: {
|
app: {
|
||||||
name: 'HSO Jackbox Game Picker',
|
name: 'HSO Jackbox Game Picker',
|
||||||
shortName: 'Jackbox Game Picker',
|
shortName: 'Jackbox Game Picker',
|
||||||
version: '0.6.4 - Fish Tank Edition',
|
version: '0.6.5 - Fish Tank Edition',
|
||||||
description: 'Spicing up Hyper Spaceout game nights!',
|
description: 'Spicing up Hyper Spaceout game nights!',
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const useAuth = () => {
|
|||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [token, setToken] = useState(localStorage.getItem('adminToken'));
|
const [token, setToken] = useState(localStorage.getItem('adminToken'));
|
||||||
const [adminName, setAdminName] = useState(localStorage.getItem('adminName'));
|
const [adminName, setAdminName] = useState(localStorage.getItem('adminName'));
|
||||||
|
const [adminRole, setAdminRole] = useState(localStorage.getItem('adminRole'));
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -27,9 +28,12 @@ export const AuthProvider = ({ children }) => {
|
|||||||
});
|
});
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
const name = response.data.user?.name;
|
const name = response.data.user?.name;
|
||||||
|
const role = response.data.user?.role || 'admin';
|
||||||
if (name) {
|
if (name) {
|
||||||
setAdminName(name);
|
setAdminName(name);
|
||||||
|
setAdminRole(role);
|
||||||
localStorage.setItem('adminName', name);
|
localStorage.setItem('adminName', name);
|
||||||
|
localStorage.setItem('adminRole', role);
|
||||||
} else {
|
} else {
|
||||||
logout();
|
logout();
|
||||||
}
|
}
|
||||||
@@ -47,11 +51,13 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const login = async (key) => {
|
const login = async (key) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/api/auth/login', { key });
|
const response = await axios.post('/api/auth/login', { key });
|
||||||
const { token: newToken, name } = response.data;
|
const { token: newToken, name, role } = response.data;
|
||||||
localStorage.setItem('adminToken', newToken);
|
localStorage.setItem('adminToken', newToken);
|
||||||
localStorage.setItem('adminName', name);
|
localStorage.setItem('adminName', name);
|
||||||
|
localStorage.setItem('adminRole', role || 'admin');
|
||||||
setToken(newToken);
|
setToken(newToken);
|
||||||
setAdminName(name);
|
setAdminName(name);
|
||||||
|
setAdminRole(role || 'admin');
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
migratePreferences(name);
|
migratePreferences(name);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -66,14 +72,17 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('adminToken');
|
localStorage.removeItem('adminToken');
|
||||||
localStorage.removeItem('adminName');
|
localStorage.removeItem('adminName');
|
||||||
|
localStorage.removeItem('adminRole');
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setAdminName(null);
|
setAdminName(null);
|
||||||
|
setAdminRole(null);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
token,
|
token,
|
||||||
adminName,
|
adminName,
|
||||||
|
adminRole,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
loading,
|
loading,
|
||||||
login,
|
login,
|
||||||
|
|||||||
@@ -9,9 +9,15 @@ export function usePresence() {
|
|||||||
const { token, adminName, isAuthenticated } = useAuth();
|
const { token, adminName, isAuthenticated } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [viewers, setViewers] = useState([]);
|
const [viewers, setViewers] = useState([]);
|
||||||
|
const [services, setServices] = useState([]);
|
||||||
const wsRef = useRef(null);
|
const wsRef = useRef(null);
|
||||||
const pingRef = useRef(null);
|
const pingRef = useRef(null);
|
||||||
const reconnectRef = useRef(null);
|
const reconnectRef = useRef(null);
|
||||||
|
const locationRef = useRef(location.pathname);
|
||||||
|
const adminNameRef = useRef(adminName);
|
||||||
|
|
||||||
|
locationRef.current = location.pathname;
|
||||||
|
adminNameRef.current = adminName;
|
||||||
|
|
||||||
const getWsUrl = useCallback(() => {
|
const getWsUrl = useCallback(() => {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
@@ -32,7 +38,7 @@ export function usePresence() {
|
|||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
|
|
||||||
if (msg.type === 'auth_success') {
|
if (msg.type === 'auth_success') {
|
||||||
ws.send(JSON.stringify({ type: 'page_focus', page: location.pathname }));
|
ws.send(JSON.stringify({ type: 'page_focus', page: locationRef.current }));
|
||||||
|
|
||||||
pingRef.current = setInterval(() => {
|
pingRef.current = setInterval(() => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
@@ -42,11 +48,16 @@ export function usePresence() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'presence_update') {
|
if (msg.type === 'presence_update') {
|
||||||
const currentPage = location.pathname;
|
const currentPage = locationRef.current;
|
||||||
const onSamePage = msg.admins
|
const currentName = adminNameRef.current;
|
||||||
.filter(a => a.page === currentPage)
|
const pageViewers = msg.admins
|
||||||
.map(a => a.name === adminName ? 'me' : a.name);
|
.filter(a => a.role !== 'bot' && a.role !== 'utility' && a.page === currentPage)
|
||||||
setViewers(onSamePage);
|
.map(a => ({ name: a.name === currentName ? 'me' : a.name, role: a.role || 'admin' }));
|
||||||
|
const connectedServices = msg.admins
|
||||||
|
.filter(a => a.role === 'bot' || a.role === 'utility')
|
||||||
|
.map(a => ({ name: a.name, role: a.role }));
|
||||||
|
setViewers(pageViewers);
|
||||||
|
setServices(connectedServices);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,7 +69,7 @@ export function usePresence() {
|
|||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
ws.close();
|
ws.close();
|
||||||
};
|
};
|
||||||
}, [isAuthenticated, token, adminName, location.pathname, getWsUrl]);
|
}, [isAuthenticated, token, getWsUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connect();
|
connect();
|
||||||
@@ -79,5 +90,5 @@ export function usePresence() {
|
|||||||
}
|
}
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
return { viewers };
|
return { viewers, services };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,23 +26,33 @@ describe('load-admins', () => {
|
|||||||
|
|
||||||
test('loads admins from ADMIN_CONFIG_PATH', () => {
|
test('loads admins from ADMIN_CONFIG_PATH', () => {
|
||||||
const configPath = writeConfig([
|
const configPath = writeConfig([
|
||||||
{ name: 'Alice', key: 'key-a' },
|
{ name: 'Alice', role: 'admin', key: 'key-a' },
|
||||||
{ name: 'Bob', key: 'key-b' }
|
{ name: 'Bob', role: 'bot', key: 'key-b' }
|
||||||
]);
|
]);
|
||||||
process.env.ADMIN_CONFIG_PATH = configPath;
|
process.env.ADMIN_CONFIG_PATH = configPath;
|
||||||
|
|
||||||
const { findAdminByKey } = require('../../backend/config/load-admins');
|
const { findAdminByKey } = require('../../backend/config/load-admins');
|
||||||
expect(findAdminByKey('key-a')).toEqual({ name: 'Alice' });
|
expect(findAdminByKey('key-a')).toEqual({ name: 'Alice', role: 'admin' });
|
||||||
expect(findAdminByKey('key-b')).toEqual({ name: 'Bob' });
|
expect(findAdminByKey('key-b')).toEqual({ name: 'Bob', role: 'bot' });
|
||||||
expect(findAdminByKey('wrong')).toBeNull();
|
expect(findAdminByKey('wrong')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('defaults role to admin when not specified', () => {
|
||||||
|
const configPath = writeConfig([
|
||||||
|
{ name: 'Alice', key: 'key-a' }
|
||||||
|
]);
|
||||||
|
process.env.ADMIN_CONFIG_PATH = configPath;
|
||||||
|
|
||||||
|
const { findAdminByKey } = require('../../backend/config/load-admins');
|
||||||
|
expect(findAdminByKey('key-a')).toEqual({ name: 'Alice', role: 'admin' });
|
||||||
|
});
|
||||||
|
|
||||||
test('falls back to ADMIN_KEY when no config file', () => {
|
test('falls back to ADMIN_KEY when no config file', () => {
|
||||||
process.env.ADMIN_CONFIG_PATH = path.join(tmpDir, 'nonexistent.json');
|
process.env.ADMIN_CONFIG_PATH = path.join(tmpDir, 'nonexistent.json');
|
||||||
process.env.ADMIN_KEY = 'legacy-key';
|
process.env.ADMIN_KEY = 'legacy-key';
|
||||||
|
|
||||||
const { findAdminByKey } = require('../../backend/config/load-admins');
|
const { findAdminByKey } = require('../../backend/config/load-admins');
|
||||||
expect(findAdminByKey('legacy-key')).toEqual({ name: 'Admin' });
|
expect(findAdminByKey('legacy-key')).toEqual({ name: 'Admin', role: 'admin' });
|
||||||
expect(findAdminByKey('wrong')).toBeNull();
|
expect(findAdminByKey('wrong')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,7 +101,7 @@ describe('POST /api/auth/login — named admins', () => {
|
|||||||
({ app } = require('../../backend/server'));
|
({ app } = require('../../backend/server'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('login returns admin name in response', async () => {
|
test('login returns admin name and role in response', async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/auth/login')
|
.post('/api/auth/login')
|
||||||
.set('Content-Type', 'application/json')
|
.set('Content-Type', 'application/json')
|
||||||
@@ -99,10 +109,11 @@ describe('POST /api/auth/login — named admins', () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.name).toBeDefined();
|
expect(res.body.name).toBeDefined();
|
||||||
|
expect(res.body.role).toBe('admin');
|
||||||
expect(res.body.token).toBeDefined();
|
expect(res.body.token).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('verify returns admin name in user object', async () => {
|
test('verify returns admin name and role in user object', async () => {
|
||||||
const loginRes = await request(app)
|
const loginRes = await request(app)
|
||||||
.post('/api/auth/login')
|
.post('/api/auth/login')
|
||||||
.set('Content-Type', 'application/json')
|
.set('Content-Type', 'application/json')
|
||||||
@@ -114,6 +125,7 @@ describe('POST /api/auth/login — named admins', () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.user.name).toBeDefined();
|
expect(res.body.user.name).toBeDefined();
|
||||||
|
expect(res.body.user.role).toBe('admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('invalid key still returns 401', async () => {
|
test('invalid key still returns 401', async () => {
|
||||||
@@ -155,15 +167,15 @@ describe('WebSocket presence', () => {
|
|||||||
server.close(done);
|
server.close(done);
|
||||||
});
|
});
|
||||||
|
|
||||||
function makeToken(name) {
|
function makeToken(name, role = 'admin') {
|
||||||
return jwt.sign({ role: 'admin', name }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
return jwt.sign({ role, name }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectAndAuth(name) {
|
function connectAndAuth(name, role = 'admin') {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
ws.on('open', () => {
|
ws.on('open', () => {
|
||||||
ws.send(JSON.stringify({ type: 'auth', token: makeToken(name) }));
|
ws.send(JSON.stringify({ type: 'auth', token: makeToken(name, role) }));
|
||||||
});
|
});
|
||||||
ws.on('message', (data) => {
|
ws.on('message', (data) => {
|
||||||
const msg = JSON.parse(data.toString());
|
const msg = JSON.parse(data.toString());
|
||||||
@@ -187,7 +199,7 @@ describe('WebSocket presence', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('page_focus triggers presence_update with admin name and page', async () => {
|
test('page_focus triggers presence_update with admin name, role, and page', async () => {
|
||||||
const ws1 = await connectAndAuth('Alice');
|
const ws1 = await connectAndAuth('Alice');
|
||||||
const ws2 = await connectAndAuth('Bob');
|
const ws2 = await connectAndAuth('Bob');
|
||||||
|
|
||||||
@@ -198,7 +210,7 @@ describe('WebSocket presence', () => {
|
|||||||
const msg = await presencePromise;
|
const msg = await presencePromise;
|
||||||
expect(msg.admins).toEqual(
|
expect(msg.admins).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ name: 'Alice', page: '/history' })
|
expect.objectContaining({ name: 'Alice', role: 'admin', page: '/history' })
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -224,4 +236,61 @@ describe('WebSocket presence', () => {
|
|||||||
|
|
||||||
ws2.close();
|
ws2.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('bot role appears in presence without page_focus', async () => {
|
||||||
|
const wsAdmin = await connectAndAuth('Alice');
|
||||||
|
const wsBot = await connectAndAuth('ChatBot', 'bot');
|
||||||
|
|
||||||
|
wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/history' }));
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
|
const presencePromise = waitForMessage(wsAdmin, 'presence_update');
|
||||||
|
wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/picker' }));
|
||||||
|
|
||||||
|
const msg = await presencePromise;
|
||||||
|
const botEntry = msg.admins.find(a => a.name === 'ChatBot');
|
||||||
|
expect(botEntry).toBeDefined();
|
||||||
|
expect(botEntry.role).toBe('bot');
|
||||||
|
expect(botEntry.page).toBeNull();
|
||||||
|
|
||||||
|
wsAdmin.close();
|
||||||
|
wsBot.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('utility role appears in presence without page_focus', async () => {
|
||||||
|
const wsAdmin = await connectAndAuth('Alice');
|
||||||
|
const wsUtil = await connectAndAuth('OBS', 'utility');
|
||||||
|
|
||||||
|
wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/history' }));
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
|
const presencePromise = waitForMessage(wsAdmin, 'presence_update');
|
||||||
|
wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/picker' }));
|
||||||
|
|
||||||
|
const msg = await presencePromise;
|
||||||
|
const utilEntry = msg.admins.find(a => a.name === 'OBS');
|
||||||
|
expect(utilEntry).toBeDefined();
|
||||||
|
expect(utilEntry.role).toBe('utility');
|
||||||
|
expect(utilEntry.page).toBeNull();
|
||||||
|
|
||||||
|
wsAdmin.close();
|
||||||
|
wsUtil.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin without page_focus is NOT in presence', async () => {
|
||||||
|
const ws1 = await connectAndAuth('Alice');
|
||||||
|
const ws2 = await connectAndAuth('Bob');
|
||||||
|
|
||||||
|
const presencePromise = waitForMessage(ws1, 'presence_update');
|
||||||
|
ws1.send(JSON.stringify({ type: 'page_focus', page: '/history' }));
|
||||||
|
|
||||||
|
const msg = await presencePromise;
|
||||||
|
const bobEntry = msg.admins.find(a => a.name === 'Bob');
|
||||||
|
expect(bobEntry).toBeUndefined();
|
||||||
|
|
||||||
|
ws1.close();
|
||||||
|
ws2.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -163,3 +163,104 @@ describe('POST /api/votes/live -> read-back (end-to-end)', () => {
|
|||||||
expect(sessionVotes.body.votes[0].total_votes).toBe(3);
|
expect(sessionVotes.body.votes[0].total_votes).toBe(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /api/votes/live — ticker voting', () => {
|
||||||
|
let session, tickerGame, sessionGame;
|
||||||
|
const baseTime = '2026-03-15T20:00:00.000Z';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
tickerGame = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7', ticker: 'QPL3' });
|
||||||
|
sessionGame = seedGame({ title: 'Drawful 2', pack_name: 'Party Pack 3' });
|
||||||
|
session = seedSession({ is_active: 1 });
|
||||||
|
seedSessionGame(session.id, sessionGame.id, { status: 'playing', played_at: baseTime });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vote with valid ticker resolves to the correct game', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
ticker: 'QPL3',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.game.id).toBe(tickerGame.id);
|
||||||
|
expect(res.body.game.title).toBe('Quiplash 3');
|
||||||
|
expect(res.body.vote.ticker).toBe('QPL3');
|
||||||
|
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT * FROM live_votes WHERE game_id = ? AND username = ?'
|
||||||
|
).get(tickerGame.id, 'viewer1');
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row.vote_type).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ticker vote works for a game not in the active session', async () => {
|
||||||
|
const outsideGame = seedGame({ title: 'Fibbage XL', pack_name: 'Party Pack 1', ticker: 'FBXL' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'down',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
ticker: 'FBXL',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.game.id).toBe(outsideGame.id);
|
||||||
|
expect(res.body.game.title).toBe('Fibbage XL');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown ticker returns 404', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
ticker: 'NOPE',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toMatch(/Unknown ticker 'NOPE'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ticker vote updates game scores', async () => {
|
||||||
|
await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
ticker: 'QPL3',
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer2',
|
||||||
|
vote: 'down',
|
||||||
|
timestamp: '2026-03-15T20:06:05.000Z',
|
||||||
|
ticker: 'QPL3',
|
||||||
|
});
|
||||||
|
|
||||||
|
const game = db.prepare('SELECT upvotes, downvotes, popularity_score FROM games WHERE id = ?').get(tickerGame.id);
|
||||||
|
expect(game.upvotes).toBe(1);
|
||||||
|
expect(game.downvotes).toBe(1);
|
||||||
|
expect(game.popularity_score).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,12 +34,13 @@ function seedGame(overrides = {}) {
|
|||||||
upvotes: 0,
|
upvotes: 0,
|
||||||
downvotes: 0,
|
downvotes: 0,
|
||||||
popularity_score: 0,
|
popularity_score: 0,
|
||||||
|
ticker: null,
|
||||||
};
|
};
|
||||||
const g = { ...defaults, ...overrides };
|
const g = { ...defaults, ...overrides };
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO games (pack_name, title, min_players, max_players, length_minutes, has_audience, family_friendly, game_type, enabled, upvotes, downvotes, popularity_score)
|
INSERT INTO games (pack_name, title, min_players, max_players, length_minutes, has_audience, family_friendly, game_type, enabled, upvotes, downvotes, popularity_score, ticker)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(g.pack_name, g.title, g.min_players, g.max_players, g.length_minutes, g.has_audience, g.family_friendly, g.game_type, g.enabled, g.upvotes, g.downvotes, g.popularity_score);
|
`).run(g.pack_name, g.title, g.min_players, g.max_players, g.length_minutes, g.has_audience, g.family_friendly, g.game_type, g.enabled, g.upvotes, g.downvotes, g.popularity_score, g.ticker);
|
||||||
return db.prepare('SELECT * FROM games WHERE id = ?').get(result.lastInsertRowid);
|
return db.prepare('SELECT * FROM games WHERE id = ?').get(result.lastInsertRowid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user