Work spanning May 7-10 across multiple sessions:
Poll winner detection + source column (May 7):
- Fix race condition in handleEndPolling where WS voting.ended cleared
leadingGame before the setTimeout could capture the winner
- Add pollActiveRef guard to prevent late poll.leading messages from
re-activating an ended poll
- Add 'source' column to session_games (dice/manual/poll) with backward-
compatible fallback from manually_added flag
- Show indigo "Poll" badge in game lists (Picker, Home, SessionDetail)
- Include source in session export (JSON and text formats)
Multi-admin poll state sync (May 9):
- Enrich poll.start broadcast with pollStartedAt timestamp so all admin
clients can start their timers from the correct time
- Enrich voting.ended broadcast with winnerGameId/Label/Votes so all
admins see the winner prompt, not just the one who clicked End Poll
- Add poll.start WS handler in SessionInfo so Admin B sees polls started
by Admin A without refreshing
- Make handleStartPolling optimistic with rollback on failure
WebSocket keepalive + auto-reconnect (May 9):
- Add 30s ping interval to SessionInfo WS connection (matching server's
60s timeout) to prevent silent disconnects
- Add auto-reconnect on close with 3s delay
- Proper cleanup of ping interval, reconnect timeout, and onclose handler
Sync selected game across admin clients (May 10):
- New POST/DELETE /sessions/:id/game-selection endpoints with DB
persistence (pending_game_id, pending_game_source columns)
- Broadcast game.picked/game.dismissed WS events to session subscribers
- handleDismissGame replaces inline setSelectedGame(null) calls
- Restore pending game selection on page load for late-joining admins
- Clear pending selection when game is formally added to session
Poll ending countdown timer (May 10):
- POST /:id/voting/end now accepts optional { delay } (0-300 seconds)
- New POST /:id/voting/cancel-end to abort a scheduled end
- New poll.ending and poll.ending.cancelled WS events
- poll_ending_at column on sessions table for crash recovery
- rescheduleEndingPolls() called on server startup to resume countdowns
- End Poll button opens popover with End Now / 5s / 10s / 30s / custom
- Red "Poll Ending" card with countdown display and Cancel button
- Document new WS events in docs/api/websocket.md
Co-authored-by: Cursor <cursoragent@cursor.com>
318 lines
9.1 KiB
JavaScript
318 lines
9.1 KiB
JavaScript
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 with proper permissions
|
|
try {
|
|
if (!fs.existsSync(dbDir)) {
|
|
fs.mkdirSync(dbDir, { recursive: true, mode: 0o777 });
|
|
}
|
|
// Also ensure the directory is writable
|
|
fs.accessSync(dbDir, fs.constants.W_OK);
|
|
} catch (err) {
|
|
console.error(`Error with database directory ${dbDir}:`, err.message);
|
|
console.error('Please ensure the directory exists and is writable');
|
|
process.exit(1);
|
|
}
|
|
|
|
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
|
|
)
|
|
`);
|
|
|
|
// Add archived column if it doesn't exist (for existing databases)
|
|
try {
|
|
db.exec(`ALTER TABLE sessions ADD COLUMN archived INTEGER DEFAULT 0`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
|
|
// Poll state columns on sessions
|
|
try {
|
|
db.exec(`ALTER TABLE sessions ADD COLUMN poll_active INTEGER DEFAULT 0`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
try {
|
|
db.exec(`ALTER TABLE sessions ADD COLUMN poll_started_at TEXT`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
try {
|
|
db.exec(`ALTER TABLE sessions ADD COLUMN poll_leading_game_id INTEGER`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
try {
|
|
db.exec(`ALTER TABLE sessions ADD COLUMN poll_leading_label TEXT`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
try {
|
|
db.exec(`ALTER TABLE sessions ADD COLUMN poll_leading_votes INTEGER`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
try {
|
|
db.exec(`ALTER TABLE sessions ADD COLUMN poll_ending_at TEXT`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
|
|
// Pending game selection columns on sessions
|
|
try {
|
|
db.exec(`ALTER TABLE sessions ADD COLUMN pending_game_id INTEGER`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
try {
|
|
db.exec(`ALTER TABLE sessions ADD COLUMN pending_game_source TEXT`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
|
|
// 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,
|
|
status TEXT DEFAULT 'played',
|
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
|
)
|
|
`);
|
|
|
|
// Add status column if it doesn't exist (for existing databases)
|
|
try {
|
|
db.exec(`ALTER TABLE session_games ADD COLUMN status TEXT DEFAULT 'played'`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
|
|
// Add room_code column if it doesn't exist (for existing databases)
|
|
try {
|
|
db.exec(`ALTER TABLE session_games ADD COLUMN room_code TEXT`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
|
|
// Add player_count column if it doesn't exist (for existing databases)
|
|
try {
|
|
db.exec(`ALTER TABLE session_games ADD COLUMN player_count INTEGER`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
|
|
// Add player_count_check_status column if it doesn't exist (for existing databases)
|
|
try {
|
|
db.exec(`ALTER TABLE session_games ADD COLUMN player_count_check_status TEXT DEFAULT 'not_started'`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
|
|
// Add source column: 'dice' (random pick), 'manual' (admin search), 'poll' (poll winner)
|
|
try {
|
|
db.exec(`ALTER TABLE session_games ADD COLUMN source TEXT DEFAULT 'dice'`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
|
|
// Add favor_bias column to games if it doesn't exist
|
|
try {
|
|
db.exec(`ALTER TABLE games ADD COLUMN favor_bias INTEGER DEFAULT 0`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
|
|
// Add upvotes and downvotes columns to games if they don't exist
|
|
try {
|
|
db.exec(`ALTER TABLE games ADD COLUMN upvotes INTEGER DEFAULT 0`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
|
|
try {
|
|
db.exec(`ALTER TABLE games ADD COLUMN downvotes INTEGER DEFAULT 0`);
|
|
} catch (err) {
|
|
// 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
|
|
try {
|
|
const gamesWithScore = db.prepare(`
|
|
SELECT id, popularity_score FROM games
|
|
WHERE popularity_score != 0 AND (upvotes = 0 AND downvotes = 0)
|
|
`).all();
|
|
|
|
if (gamesWithScore.length > 0) {
|
|
const updateGame = db.prepare(`
|
|
UPDATE games
|
|
SET upvotes = ?, downvotes = ?
|
|
WHERE id = ?
|
|
`);
|
|
|
|
for (const game of gamesWithScore) {
|
|
if (game.popularity_score > 0) {
|
|
updateGame.run(game.popularity_score, 0, game.id);
|
|
} else {
|
|
updateGame.run(0, Math.abs(game.popularity_score), game.id);
|
|
}
|
|
}
|
|
console.log(`Migrated popularity scores for ${gamesWithScore.length} games`);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error migrating popularity scores:', err);
|
|
}
|
|
|
|
// Packs table for pack-level favoriting
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS packs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
favor_bias INTEGER DEFAULT 0,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
|
|
// Populate packs table with unique pack names from games
|
|
db.exec(`
|
|
INSERT OR IGNORE INTO packs (name)
|
|
SELECT DISTINCT pack_name FROM games
|
|
`);
|
|
|
|
// 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
|
|
)
|
|
`);
|
|
|
|
// Add message_hash column if it doesn't exist
|
|
try {
|
|
db.exec(`ALTER TABLE chat_logs ADD COLUMN message_hash TEXT`);
|
|
} catch (err) {
|
|
// Column already exists, ignore error
|
|
}
|
|
|
|
// Create index on message_hash for fast duplicate checking
|
|
try {
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_chat_logs_hash ON chat_logs(message_hash)`);
|
|
} catch (err) {
|
|
// Index already exists, ignore error
|
|
}
|
|
|
|
// Live votes table for real-time voting
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS live_votes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id INTEGER NOT NULL,
|
|
game_id INTEGER NOT NULL,
|
|
username TEXT NOT NULL,
|
|
vote_type INTEGER NOT NULL,
|
|
timestamp DATETIME NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
|
)
|
|
`);
|
|
|
|
// Create index for duplicate checking (username + timestamp within 1 second)
|
|
try {
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_live_votes_dedup ON live_votes(username, timestamp)`);
|
|
} catch (err) {
|
|
// Index already exists, ignore error
|
|
}
|
|
|
|
// Webhooks table for external integrations
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS webhooks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
secret TEXT NOT NULL,
|
|
events TEXT NOT NULL,
|
|
enabled INTEGER DEFAULT 1,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
|
|
// Webhook logs table for debugging
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS webhook_logs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
webhook_id INTEGER NOT NULL,
|
|
event_type TEXT NOT NULL,
|
|
payload TEXT NOT NULL,
|
|
response_status INTEGER,
|
|
error_message TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE
|
|
)
|
|
`);
|
|
|
|
console.log('Database initialized successfully');
|
|
}
|
|
|
|
initializeDatabase();
|
|
|
|
module.exports = db;
|
|
|