feat: ticker symbol voting for live votes
- Add ticker column to games table with migration - Bootstrap tickers from tickers.json config on startup - POST /api/votes/live accepts optional ticker field for direct game lookup (bypasses timestamp-interval matching) - Ticker votes work for any game, not just session games - Update API docs and add e2e tests for ticker voting - Version bump to 0.6.5 Made-with: Cursor
This commit is contained in:
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`);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!lengthStr || lengthStr === '????' || lengthStr === '?') {
|
||||
return null;
|
||||
@@ -69,5 +96,5 @@ function parseBoolean(value) {
|
||||
return value.toLowerCase() === 'yes' ? 1 : 0;
|
||||
}
|
||||
|
||||
module.exports = { bootstrapGames };
|
||||
module.exports = { bootstrapGames, bootstrapTickers };
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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(`
|
||||
|
||||
@@ -89,7 +89,7 @@ router.get('/', (req, res) => {
|
||||
// Live vote endpoint - receives real-time votes from bot
|
||||
router.post('/live', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { username, vote, timestamp } = req.body;
|
||||
const { username, vote, timestamp, ticker } = req.body;
|
||||
|
||||
// Validate payload
|
||||
if (!username || !vote || !timestamp) {
|
||||
@@ -123,57 +123,72 @@ router.post('/live', authenticateToken, (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Get all games played in this session with timestamps
|
||||
const sessionGames = db.prepare(`
|
||||
SELECT sg.game_id, sg.played_at, g.title, g.pack_name, g.upvotes, g.downvotes, g.popularity_score
|
||||
FROM session_games sg
|
||||
JOIN games g ON sg.game_id = g.id
|
||||
WHERE sg.session_id = ?
|
||||
ORDER BY sg.played_at ASC
|
||||
`).all(activeSession.id);
|
||||
|
||||
if (sessionGames.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'No games have been played in the active session yet'
|
||||
});
|
||||
}
|
||||
|
||||
// Match vote timestamp to the correct game using interval logic
|
||||
const voteTime = voteTimestamp.getTime();
|
||||
let matchedGame = null;
|
||||
|
||||
for (let i = 0; i < sessionGames.length; i++) {
|
||||
const currentGame = sessionGames[i];
|
||||
const nextGame = sessionGames[i + 1];
|
||||
|
||||
const currentGameTime = new Date(currentGame.played_at).getTime();
|
||||
|
||||
if (nextGame) {
|
||||
const nextGameTime = new Date(nextGame.played_at).getTime();
|
||||
if (voteTime >= currentGameTime && voteTime < nextGameTime) {
|
||||
matchedGame = currentGame;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Last game in session - vote belongs here if timestamp is after this game started
|
||||
if (voteTime >= currentGameTime) {
|
||||
matchedGame = currentGame;
|
||||
break;
|
||||
if (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(`
|
||||
SELECT sg.game_id, sg.played_at, g.title, g.pack_name, g.upvotes, g.downvotes, g.popularity_score
|
||||
FROM session_games sg
|
||||
JOIN games g ON sg.game_id = g.id
|
||||
WHERE sg.session_id = ?
|
||||
ORDER BY sg.played_at ASC
|
||||
`).all(activeSession.id);
|
||||
|
||||
if (sessionGames.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'No games have been played in the active session yet'
|
||||
});
|
||||
}
|
||||
|
||||
const voteTime = voteTimestamp.getTime();
|
||||
|
||||
for (let i = 0; i < sessionGames.length; i++) {
|
||||
const currentGame = sessionGames[i];
|
||||
const nextGame = sessionGames[i + 1];
|
||||
|
||||
const currentGameTime = new Date(currentGame.played_at).getTime();
|
||||
|
||||
if (nextGame) {
|
||||
const nextGameTime = new Date(nextGame.played_at).getTime();
|
||||
if (voteTime >= currentGameTime && voteTime < nextGameTime) {
|
||||
matchedGame = currentGame;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (voteTime >= currentGameTime) {
|
||||
matchedGame = currentGame;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedGame) {
|
||||
return res.status(404).json({
|
||||
error: 'Vote timestamp does not match any game in the active session',
|
||||
debug: {
|
||||
voteTimestamp: timestamp,
|
||||
sessionGames: sessionGames.map(g => ({
|
||||
title: g.title,
|
||||
played_at: g.played_at
|
||||
}))
|
||||
}
|
||||
});
|
||||
if (!matchedGame) {
|
||||
return res.status(404).json({
|
||||
error: 'Vote timestamp does not match any game in the active session',
|
||||
debug: {
|
||||
voteTimestamp: timestamp,
|
||||
sessionGames: sessionGames.map(g => ({
|
||||
title: g.title,
|
||||
played_at: g.played_at
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate vote (within 1 second window)
|
||||
@@ -258,6 +273,7 @@ router.post('/live', authenticateToken, (req, res) => {
|
||||
id: updatedGame.id,
|
||||
title: updatedGame.title,
|
||||
pack_name: matchedGame.pack_name,
|
||||
ticker: ticker || undefined,
|
||||
},
|
||||
vote: {
|
||||
username: username,
|
||||
@@ -303,7 +319,8 @@ router.post('/live', authenticateToken, (req, res) => {
|
||||
vote: {
|
||||
username: username,
|
||||
type: vote,
|
||||
timestamp: timestamp
|
||||
timestamp: timestamp,
|
||||
ticker: ticker || undefined,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ require('dotenv').config();
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const cors = require('cors');
|
||||
const { bootstrapGames } = require('./bootstrap');
|
||||
const { bootstrapGames, bootstrapTickers } = require('./bootstrap');
|
||||
const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager');
|
||||
const { cleanupAllShards } = require('./utils/ecast-shard-client');
|
||||
|
||||
@@ -50,6 +50,7 @@ setWebSocketManager(wsManager);
|
||||
|
||||
if (require.main === module) {
|
||||
bootstrapGames();
|
||||
bootstrapTickers();
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# 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
|
||||
|
||||
@@ -71,7 +74,10 @@ Results are ordered by `timestamp DESC`. The `vote_type` field is returned as `"
|
||||
|
||||
## 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
|
||||
|
||||
@@ -84,6 +90,20 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
||||
| username | string | Yes | Identifier for the voter (used for deduplication) |
|
||||
| vote | string | Yes | `"up"` or `"down"` |
|
||||
| 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
|
||||
{
|
||||
@@ -96,7 +116,8 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
||||
### Behavior
|
||||
|
||||
- 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.
|
||||
- **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.
|
||||
@@ -105,6 +126,8 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
||||
|
||||
**200 OK**
|
||||
|
||||
The `ticker` field is included in the response when the vote was submitted with a ticker.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
@@ -120,7 +143,8 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
||||
"vote": {
|
||||
"username": "viewer123",
|
||||
"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": "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 games have been played in the active session yet" }` | Active session has no games |
|
||||
| 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": "Unknown ticker 'XYZ'" }` | Ticker does not match any game |
|
||||
| 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 |
|
||||
| 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
|
||||
curl -X POST "http://localhost:5000/api/votes/live" \
|
||||
|
||||
@@ -2,7 +2,7 @@ export const branding = {
|
||||
app: {
|
||||
name: 'HSO 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!',
|
||||
},
|
||||
meta: {
|
||||
|
||||
@@ -163,3 +163,104 @@ describe('POST /api/votes/live -> read-back (end-to-end)', () => {
|
||||
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,
|
||||
downvotes: 0,
|
||||
popularity_score: 0,
|
||||
ticker: null,
|
||||
};
|
||||
const g = { ...defaults, ...overrides };
|
||||
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)
|
||||
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);
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user