docs: vote tracking API design (REST + WebSocket)
Covers WebSocket vote.received event, GET /api/sessions/:id/votes breakdown, GET /api/votes paginated history, and two-phase TDD testing strategy with regression tests before implementation. Made-with: Cursor
This commit is contained in:
196
docs/plans/2026-03-15-vote-tracking-api-design.md
Normal file
196
docs/plans/2026-03-15-vote-tracking-api-design.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Vote Tracking API Design
|
||||
|
||||
## Overview
|
||||
|
||||
Extend the REST and WebSocket APIs so clients can track votes at both session and global levels. The primary consumer is a stream overlay (ticker-style display) that already has the admin JWT.
|
||||
|
||||
## Approach
|
||||
|
||||
**Approach B — Split by resource ownership.** Session-scoped vote data lives under the session resource. Global vote history lives under the vote resource. The WebSocket emits real-time events for live votes only.
|
||||
|
||||
## WebSocket: `vote.received` Event
|
||||
|
||||
**Trigger:** `POST /api/votes/live` — fires after the vote transaction succeeds, before the HTTP response. Only live votes emit this event; chat-import does not.
|
||||
|
||||
**Broadcast target:** Session subscribers via `broadcastEvent('vote.received', data, sessionId)`.
|
||||
|
||||
**Payload:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "vote.received",
|
||||
"timestamp": "2026-03-15T20:30:00.000Z",
|
||||
"data": {
|
||||
"sessionId": 5,
|
||||
"game": {
|
||||
"id": 42,
|
||||
"title": "Quiplash 3",
|
||||
"pack_name": "Party Pack 7"
|
||||
},
|
||||
"vote": {
|
||||
"username": "viewer123",
|
||||
"type": "up",
|
||||
"timestamp": "2026-03-15T20:29:55.000Z"
|
||||
},
|
||||
"totals": {
|
||||
"upvotes": 14,
|
||||
"downvotes": 3,
|
||||
"popularity_score": 11
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation notes:**
|
||||
- `votes.js` needs access to the WebSocket manager singleton via `getWebSocketManager()`.
|
||||
- The existing session games JOIN needs to select `pack_name` from the `games` table.
|
||||
|
||||
## REST: `GET /api/sessions/:id/votes`
|
||||
|
||||
Per-game vote breakdown for a specific session.
|
||||
|
||||
**Location:** `backend/routes/sessions.js`
|
||||
|
||||
**Auth:** None (matches `GET /api/sessions/:id/games`).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": 5,
|
||||
"votes": [
|
||||
{
|
||||
"game_id": 42,
|
||||
"title": "Quiplash 3",
|
||||
"pack_name": "Party Pack 7",
|
||||
"upvotes": 14,
|
||||
"downvotes": 3,
|
||||
"net_score": 11,
|
||||
"total_votes": 17
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Query:**
|
||||
|
||||
```sql
|
||||
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
|
||||
```
|
||||
|
||||
**Error handling:**
|
||||
- Session not found → 404
|
||||
- Session exists, no votes → 200 with empty `votes` array
|
||||
|
||||
## REST: `GET /api/votes`
|
||||
|
||||
Paginated global vote history with flexible filtering.
|
||||
|
||||
**Location:** `backend/routes/votes.js`
|
||||
|
||||
**Auth:** None.
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `session_id` | integer | — | Filter by session |
|
||||
| `game_id` | integer | — | Filter by game |
|
||||
| `username` | string | — | Filter by voter |
|
||||
| `vote_type` | `up` or `down` | — | Filter by direction |
|
||||
| `page` | integer | 1 | Page number |
|
||||
| `limit` | integer | 50 | Results per page (max 100) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"votes": [
|
||||
{
|
||||
"id": 891,
|
||||
"session_id": 5,
|
||||
"game_id": 42,
|
||||
"game_title": "Quiplash 3",
|
||||
"pack_name": "Party Pack 7",
|
||||
"username": "viewer123",
|
||||
"vote_type": "up",
|
||||
"timestamp": "2026-03-15T20:29:55.000Z",
|
||||
"created_at": "2026-03-15T20:29:56.000Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 50,
|
||||
"total": 237,
|
||||
"total_pages": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Design notes:**
|
||||
- `vote_type` returned as `"up"` / `"down"`, not raw `1` / `-1`.
|
||||
- `game_title` and `pack_name` included via JOIN.
|
||||
- Ordered by `timestamp DESC`.
|
||||
- `limit` capped at 100 server-side.
|
||||
|
||||
**Error handling:**
|
||||
- Invalid filter values → 400
|
||||
- No results → 200 with empty array and `total: 0`
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Phase 1: Regression tests (pre-implementation)
|
||||
|
||||
Written and passing before any code changes to lock down existing behavior.
|
||||
|
||||
**`tests/api/regression-votes-live.test.js`** — existing `POST /api/votes/live`:
|
||||
- Returns 200 with correct response shape (`success`, `session`, `game`, `vote`)
|
||||
- `game` includes `id`, `title`, `upvotes`, `downvotes`, `popularity_score`
|
||||
- Increments `upvotes`/`popularity_score` for upvote
|
||||
- Increments `downvotes`/decrements `popularity_score` for downvote
|
||||
- 400 for missing fields, invalid vote value, invalid timestamp
|
||||
- 404 when no active session or timestamp doesn't match a game
|
||||
- 409 for duplicate within 1-second window
|
||||
- 401 without JWT
|
||||
|
||||
**`tests/api/regression-games.test.js`** — game aggregate fields:
|
||||
- `GET /api/games` returns `upvotes`, `downvotes`, `popularity_score`
|
||||
- `GET /api/games/:id` returns same fields
|
||||
- Aggregates accurate after votes
|
||||
|
||||
**`tests/api/regression-sessions.test.js`** — session endpoints:
|
||||
- `GET /api/sessions/:id` returns session object
|
||||
- `GET /api/sessions/:id` returns 404 for nonexistent session
|
||||
- `GET /api/sessions/:id/games` returns game list with expected shape
|
||||
|
||||
**`tests/api/regression-websocket.test.js`** — existing WebSocket events:
|
||||
- Auth flow (auth → auth_success)
|
||||
- Subscribe/unsubscribe flow
|
||||
- `session.started` broadcast on session create
|
||||
- `session.ended` broadcast on session close
|
||||
- `game.added` broadcast on game add
|
||||
|
||||
### Phase 2: New feature tests (TDD — written before implementation)
|
||||
|
||||
- **`tests/api/votes-get.test.js`** — `GET /api/votes` history endpoint
|
||||
- **`tests/api/sessions-votes.test.js`** — `GET /api/sessions/:id/votes` breakdown
|
||||
- **`tests/api/votes-live-websocket.test.js`** — `vote.received` WebSocket event
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Write Phase 1 regression tests → run → all green
|
||||
2. Write Phase 2 feature tests → run → all red
|
||||
3. Implement features
|
||||
4. Run all tests → Phase 1 still green, Phase 2 now green
|
||||
Reference in New Issue
Block a user