feat: add GET /api/votes endpoint with filtering and pagination
Made-with: Cursor
This commit is contained in:
@@ -254,6 +254,40 @@ router.get('/:id/games', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get vote breakdown for a session
|
||||
router.get('/:id/votes', (req, res) => {
|
||||
try {
|
||||
const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(req.params.id);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
const votes = db.prepare(`
|
||||
SELECT
|
||||
lv.game_id,
|
||||
g.title,
|
||||
g.pack_name,
|
||||
SUM(CASE WHEN lv.vote_type = 1 THEN 1 ELSE 0 END) AS upvotes,
|
||||
SUM(CASE WHEN lv.vote_type = -1 THEN 1 ELSE 0 END) AS downvotes,
|
||||
SUM(lv.vote_type) AS net_score,
|
||||
COUNT(*) AS total_votes
|
||||
FROM live_votes lv
|
||||
JOIN games g ON lv.game_id = g.id
|
||||
WHERE lv.session_id = ?
|
||||
GROUP BY lv.game_id
|
||||
ORDER BY net_score DESC
|
||||
`).all(req.params.id);
|
||||
|
||||
res.json({
|
||||
session_id: parseInt(req.params.id),
|
||||
votes,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Add game to session (admin only)
|
||||
router.post('/:id/games', authenticateToken, (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,87 @@ const db = require('../database');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get vote history with filtering and pagination
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
let { session_id, game_id, username, vote_type, page, limit } = req.query;
|
||||
|
||||
page = parseInt(page) || 1;
|
||||
limit = Math.min(parseInt(limit) || 50, 100);
|
||||
if (page < 1) page = 1;
|
||||
if (limit < 1) limit = 50;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const where = [];
|
||||
const params = [];
|
||||
|
||||
if (session_id !== undefined) {
|
||||
const sid = parseInt(session_id);
|
||||
if (isNaN(sid)) {
|
||||
return res.status(400).json({ error: 'session_id must be an integer' });
|
||||
}
|
||||
where.push('lv.session_id = ?');
|
||||
params.push(sid);
|
||||
}
|
||||
|
||||
if (game_id !== undefined) {
|
||||
const gid = parseInt(game_id);
|
||||
if (isNaN(gid)) {
|
||||
return res.status(400).json({ error: 'game_id must be an integer' });
|
||||
}
|
||||
where.push('lv.game_id = ?');
|
||||
params.push(gid);
|
||||
}
|
||||
|
||||
if (username) {
|
||||
where.push('lv.username = ?');
|
||||
params.push(username);
|
||||
}
|
||||
|
||||
if (vote_type !== undefined) {
|
||||
if (vote_type !== 'up' && vote_type !== 'down') {
|
||||
return res.status(400).json({ error: 'vote_type must be "up" or "down"' });
|
||||
}
|
||||
where.push('lv.vote_type = ?');
|
||||
params.push(vote_type === 'up' ? 1 : -1);
|
||||
}
|
||||
|
||||
const whereClause = where.length > 0 ? 'WHERE ' + where.join(' AND ') : '';
|
||||
|
||||
const countResult = db.prepare(
|
||||
`SELECT COUNT(*) as total FROM live_votes lv ${whereClause}`
|
||||
).get(...params);
|
||||
|
||||
const total = countResult.total;
|
||||
const total_pages = Math.ceil(total / limit) || 0;
|
||||
|
||||
const votes = db.prepare(`
|
||||
SELECT
|
||||
lv.id,
|
||||
lv.session_id,
|
||||
lv.game_id,
|
||||
g.title AS game_title,
|
||||
g.pack_name,
|
||||
lv.username,
|
||||
CASE WHEN lv.vote_type = 1 THEN 'up' ELSE 'down' END AS vote_type,
|
||||
lv.timestamp,
|
||||
lv.created_at
|
||||
FROM live_votes lv
|
||||
JOIN games g ON lv.game_id = g.id
|
||||
${whereClause}
|
||||
ORDER BY lv.timestamp DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params, limit, offset);
|
||||
|
||||
res.json({
|
||||
votes,
|
||||
pagination: { page, limit, total, total_pages },
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Live vote endpoint - receives real-time votes from bot
|
||||
router.post('/live', authenticateToken, (req, res) => {
|
||||
try {
|
||||
|
||||
94
tests/api/sessions-votes.test.js
Normal file
94
tests/api/sessions-votes.test.js
Normal file
@@ -0,0 +1,94 @@
|
||||
const request = require('supertest');
|
||||
const { app } = require('../../backend/server');
|
||||
const { cleanDb, seedGame, seedSession, seedSessionGame, seedVote } = require('../helpers/test-utils');
|
||||
|
||||
describe('GET /api/sessions/:id/votes', () => {
|
||||
beforeEach(() => {
|
||||
cleanDb();
|
||||
});
|
||||
|
||||
test('returns per-game vote breakdown for a session', async () => {
|
||||
const game1 = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
|
||||
const game2 = seedGame({ title: 'Drawful 2', pack_name: 'Party Pack 3' });
|
||||
const session = seedSession({ is_active: 1 });
|
||||
seedSessionGame(session.id, game1.id);
|
||||
seedSessionGame(session.id, game2.id);
|
||||
|
||||
seedVote(session.id, game1.id, 'user1', 'up');
|
||||
seedVote(session.id, game1.id, 'user2', 'up');
|
||||
seedVote(session.id, game1.id, 'user3', 'down');
|
||||
seedVote(session.id, game2.id, 'user1', 'down');
|
||||
|
||||
const res = await request(app).get(`/api/sessions/${session.id}/votes`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.session_id).toBe(session.id);
|
||||
expect(res.body.votes).toHaveLength(2);
|
||||
|
||||
const q3 = res.body.votes.find((v) => v.game_id === game1.id);
|
||||
expect(q3.title).toBe('Quiplash 3');
|
||||
expect(q3.pack_name).toBe('Party Pack 7');
|
||||
expect(q3.upvotes).toBe(2);
|
||||
expect(q3.downvotes).toBe(1);
|
||||
expect(q3.net_score).toBe(1);
|
||||
expect(q3.total_votes).toBe(3);
|
||||
|
||||
const d2 = res.body.votes.find((v) => v.game_id === game2.id);
|
||||
expect(d2.upvotes).toBe(0);
|
||||
expect(d2.downvotes).toBe(1);
|
||||
expect(d2.net_score).toBe(-1);
|
||||
expect(d2.total_votes).toBe(1);
|
||||
});
|
||||
|
||||
test('returns empty votes array when session has no votes', async () => {
|
||||
const session = seedSession({ is_active: 1 });
|
||||
|
||||
const res = await request(app).get(`/api/sessions/${session.id}/votes`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.session_id).toBe(session.id);
|
||||
expect(res.body.votes).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns 404 for nonexistent session', async () => {
|
||||
const res = await request(app).get('/api/sessions/99999/votes');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe('Session not found');
|
||||
});
|
||||
|
||||
test('only includes votes from the requested session', async () => {
|
||||
const game = seedGame({ title: 'Quiplash 3' });
|
||||
const session1 = seedSession({ is_active: 0 });
|
||||
const session2 = seedSession({ is_active: 1 });
|
||||
seedSessionGame(session1.id, game.id);
|
||||
seedSessionGame(session2.id, game.id);
|
||||
|
||||
seedVote(session1.id, game.id, 'user1', 'up');
|
||||
seedVote(session1.id, game.id, 'user2', 'up');
|
||||
seedVote(session2.id, game.id, 'user3', 'down');
|
||||
|
||||
const res = await request(app).get(`/api/sessions/${session1.id}/votes`);
|
||||
|
||||
expect(res.body.votes).toHaveLength(1);
|
||||
expect(res.body.votes[0].upvotes).toBe(2);
|
||||
expect(res.body.votes[0].downvotes).toBe(0);
|
||||
});
|
||||
|
||||
test('results are ordered by net_score descending', async () => {
|
||||
const game1 = seedGame({ title: 'Good Game' });
|
||||
const game2 = seedGame({ title: 'Bad Game' });
|
||||
const session = seedSession({ is_active: 1 });
|
||||
seedSessionGame(session.id, game1.id);
|
||||
seedSessionGame(session.id, game2.id);
|
||||
|
||||
seedVote(session.id, game2.id, 'user1', 'down');
|
||||
seedVote(session.id, game2.id, 'user2', 'down');
|
||||
seedVote(session.id, game1.id, 'user1', 'up');
|
||||
|
||||
const res = await request(app).get(`/api/sessions/${session.id}/votes`);
|
||||
|
||||
expect(res.body.votes[0].title).toBe('Good Game');
|
||||
expect(res.body.votes[1].title).toBe('Bad Game');
|
||||
});
|
||||
});
|
||||
166
tests/api/votes-get.test.js
Normal file
166
tests/api/votes-get.test.js
Normal file
@@ -0,0 +1,166 @@
|
||||
const request = require('supertest');
|
||||
const { app } = require('../../backend/server');
|
||||
const { cleanDb, seedGame, seedSession, seedSessionGame, seedVote } = require('../helpers/test-utils');
|
||||
|
||||
describe('GET /api/votes', () => {
|
||||
let game1, game2, session;
|
||||
|
||||
beforeEach(() => {
|
||||
cleanDb();
|
||||
game1 = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
|
||||
game2 = seedGame({ title: 'Drawful 2', pack_name: 'Party Pack 3' });
|
||||
session = seedSession({ is_active: 1 });
|
||||
seedSessionGame(session.id, game1.id);
|
||||
seedSessionGame(session.id, game2.id);
|
||||
});
|
||||
|
||||
test('returns all votes with pagination metadata', async () => {
|
||||
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
|
||||
|
||||
const res = await request(app).get('/api/votes');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.votes).toHaveLength(2);
|
||||
expect(res.body.pagination).toEqual({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
total: 2,
|
||||
total_pages: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns vote_type as "up"/"down" not raw integers', async () => {
|
||||
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
|
||||
|
||||
const res = await request(app).get('/api/votes');
|
||||
|
||||
const types = res.body.votes.map((v) => v.vote_type);
|
||||
expect(types).toContain('up');
|
||||
expect(types).toContain('down');
|
||||
expect(types).not.toContain(1);
|
||||
expect(types).not.toContain(-1);
|
||||
});
|
||||
|
||||
test('includes game_title and pack_name via join', async () => {
|
||||
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||
|
||||
const res = await request(app).get('/api/votes');
|
||||
|
||||
expect(res.body.votes[0].game_title).toBe('Quiplash 3');
|
||||
expect(res.body.votes[0].pack_name).toBe('Party Pack 7');
|
||||
});
|
||||
|
||||
test('filters by session_id', async () => {
|
||||
const session2 = seedSession({ is_active: 0 });
|
||||
seedSessionGame(session2.id, game1.id);
|
||||
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||
seedVote(session2.id, game1.id, 'user2', 'up', '2026-03-15T21:01:00.000Z');
|
||||
|
||||
const res = await request(app).get(`/api/votes?session_id=${session.id}`);
|
||||
|
||||
expect(res.body.votes).toHaveLength(1);
|
||||
expect(res.body.votes[0].session_id).toBe(session.id);
|
||||
expect(res.body.pagination.total).toBe(1);
|
||||
});
|
||||
|
||||
test('filters by game_id', async () => {
|
||||
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||
seedVote(session.id, game2.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
|
||||
|
||||
const res = await request(app).get(`/api/votes?game_id=${game1.id}`);
|
||||
|
||||
expect(res.body.votes).toHaveLength(1);
|
||||
expect(res.body.votes[0].game_id).toBe(game1.id);
|
||||
});
|
||||
|
||||
test('filters by username', async () => {
|
||||
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
|
||||
|
||||
const res = await request(app).get('/api/votes?username=user1');
|
||||
|
||||
expect(res.body.votes).toHaveLength(1);
|
||||
expect(res.body.votes[0].username).toBe('user1');
|
||||
});
|
||||
|
||||
test('filters by vote_type', async () => {
|
||||
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
|
||||
|
||||
const res = await request(app).get('/api/votes?vote_type=up');
|
||||
|
||||
expect(res.body.votes).toHaveLength(1);
|
||||
expect(res.body.votes[0].vote_type).toBe('up');
|
||||
});
|
||||
|
||||
test('combines multiple filters', async () => {
|
||||
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
|
||||
seedVote(session.id, game2.id, 'user1', 'up', '2026-03-15T20:03:00.000Z');
|
||||
|
||||
const res = await request(app).get(
|
||||
`/api/votes?game_id=${game1.id}&username=user1`
|
||||
);
|
||||
|
||||
expect(res.body.votes).toHaveLength(1);
|
||||
expect(res.body.votes[0].username).toBe('user1');
|
||||
expect(res.body.votes[0].game_id).toBe(game1.id);
|
||||
});
|
||||
|
||||
test('respects page and limit', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
seedVote(session.id, game1.id, `user${i}`, 'up', `2026-03-15T20:0${i}:00.000Z`);
|
||||
}
|
||||
|
||||
const res = await request(app).get('/api/votes?page=2&limit=2');
|
||||
|
||||
expect(res.body.votes).toHaveLength(2);
|
||||
expect(res.body.pagination).toEqual({
|
||||
page: 2,
|
||||
limit: 2,
|
||||
total: 5,
|
||||
total_pages: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('caps limit at 100', async () => {
|
||||
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||
|
||||
const res = await request(app).get('/api/votes?limit=500');
|
||||
|
||||
expect(res.body.pagination.limit).toBe(100);
|
||||
});
|
||||
|
||||
test('returns 200 with empty array when no votes match', async () => {
|
||||
const res = await request(app).get('/api/votes?username=nonexistent');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.votes).toEqual([]);
|
||||
expect(res.body.pagination.total).toBe(0);
|
||||
});
|
||||
|
||||
test('returns 400 for invalid session_id', async () => {
|
||||
const res = await request(app).get('/api/votes?session_id=abc');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('returns 400 for invalid vote_type', async () => {
|
||||
const res = await request(app).get('/api/votes?vote_type=maybe');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('orders by timestamp descending', async () => {
|
||||
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:05:00.000Z');
|
||||
|
||||
const res = await request(app).get('/api/votes');
|
||||
|
||||
const timestamps = res.body.votes.map((v) => v.timestamp);
|
||||
expect(timestamps[0]).toBe('2026-03-15T20:05:00.000Z');
|
||||
expect(timestamps[1]).toBe('2026-03-15T20:01:00.000Z');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user