diff --git a/docs/plans/2026-03-15-vote-tracking-api-design.md b/docs/plans/2026-03-15-vote-tracking-api-design.md new file mode 100644 index 0000000..3beb653 --- /dev/null +++ b/docs/plans/2026-03-15-vote-tracking-api-design.md @@ -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