Compare commits
91 Commits
8f3a12ad76
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a59da8ee9
|
||
|
|
a71ad7ae68
|
||
|
|
850fed5a87
|
||
|
|
0833cf6167
|
||
|
|
bfabf390b4
|
||
|
|
ad8efc0fbf
|
||
|
|
db369a807e
|
||
|
|
d49601c54e
|
||
|
|
de1a02b9bb
|
||
|
|
07858f973b
|
||
|
|
57ab3cf7ba
|
||
|
|
c25db19008
|
||
|
|
85c06ff258
|
||
|
|
3b18034d11
|
||
|
|
3da97a39ad
|
||
|
|
04f66a32cc
|
||
|
|
95e7402d81
|
||
|
|
f0b614e28a
|
||
|
|
242150d54c
|
||
|
|
a4d74baf51
|
||
|
|
9f60c6983d
|
||
|
|
fd72c0d7ee
|
||
|
|
ac26ac2ac5
|
||
|
|
0e5c66b98f
|
||
|
|
86725b6f40
|
||
|
|
512b36da51
|
||
|
|
d613d4e507
|
||
|
|
bbd2e51567
|
||
|
|
b40176033f
|
||
|
|
68045afbbc
|
||
|
|
35474e5df4
|
||
|
|
4da2c15d56
|
||
|
|
bff103e26e
|
||
|
|
a68a617508
|
||
|
|
0ee97b35c5
|
||
|
|
7ce5251543
|
||
|
|
b9206b6cfe
|
||
|
|
ce3347d0b1
|
||
|
|
e9f1b89d44
|
||
|
|
656d9c3bf6
|
||
|
|
974d7315b9
|
||
|
|
341257a04d
|
||
|
|
8c36b399d0
|
||
|
|
c756d45e24
|
||
|
|
171303a6f9
|
||
|
|
4999060970
|
||
|
|
34637d6d2c
|
||
|
|
a7bd0650eb
|
||
|
|
65036a4e1b
|
||
|
|
336ba0e608
|
||
|
|
03f79422af
|
||
|
|
2503c3fc09
|
||
|
|
9c9927218a
|
||
|
|
3c1d5b2224
|
||
|
|
1c4c8bc19c | ||
|
|
de395d3a28 | ||
|
|
3f21299720
|
||
|
|
516db57248
|
||
|
|
0fc2ddbf23
|
||
|
|
7712ebeb04
|
||
|
|
002e1d70a6
|
||
|
|
e6198181f8
|
||
|
|
7b0dc5c015
|
||
|
|
af5e8cbd94
|
||
|
|
e5ba43bcbb
|
||
|
|
35617268e9
|
||
|
|
0d0d20161b
|
||
|
|
3ed3af06ba
|
||
|
|
e9add95efa
|
||
|
|
83b274de79 | ||
|
|
264953453c | ||
|
|
56adbe7aa2
|
||
|
|
8ddbd1440f
|
||
|
|
19c4b7dc37
|
||
|
|
8e8e6bdf05
|
||
|
|
84b0c83409 | ||
|
|
81fcae545e
|
||
|
|
4bf41b64cf
|
||
|
|
8ba32e128c
|
||
|
|
505c335d20
|
||
|
|
4747aa9632
|
||
|
|
52e00e56f6
|
||
|
|
84398ebdd0
|
||
|
|
974f0e4a67
|
||
|
|
5cf5901001
|
||
|
|
f52754ac87
|
||
|
|
140988d01d
|
||
|
|
2a75237e90
|
||
|
|
6308d99d33
|
||
|
|
47db3890e2
|
||
|
|
1a74b4d777
|
102
.gitea/issue_template/bug-report.yaml
Normal file
102
.gitea/issue_template/bug-report.yaml
Normal file
@@ -0,0 +1,102 @@
|
||||
name: Bug Report
|
||||
about: Something is broken or behaving unexpectedly
|
||||
title: "[Bug] "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Affected Area
|
||||
description: Which part of the application is affected?
|
||||
options:
|
||||
- Game Picker (selection, filters, weighting)
|
||||
- Game Manager (CRUD, import/export, packs)
|
||||
- Session Management (create, close, archive, notes)
|
||||
- History / Session Detail (display, pagination, filters)
|
||||
- Room Code / Player Count (ecast, monitoring)
|
||||
- Chat Log Import / Voting
|
||||
- Webhooks
|
||||
- Authentication / Admin
|
||||
- WebSocket / Real-time / Presence
|
||||
- PWA (install, offline, service worker)
|
||||
- UI / Styling / Responsiveness
|
||||
- API (backend endpoint)
|
||||
- Database / Migrations
|
||||
- Docker / Deployment
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deploy-method
|
||||
attributes:
|
||||
label: Deployment Method
|
||||
options:
|
||||
- Docker (docker-compose)
|
||||
- Local development (npm run dev)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Concise summary of what's wrong.
|
||||
placeholder: "When I do X, Y happens instead of Z."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Minimal sequence to trigger the bug.
|
||||
placeholder: |
|
||||
1. Login as admin
|
||||
2. Navigate to Picker page
|
||||
3. Set player count filter to 3
|
||||
4. Click "Roll the Dice"
|
||||
5. Observe error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
placeholder: A game matching the filters should be selected.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
placeholder: The page shows a spinner indefinitely and the console logs a 500 error.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant Logs / Errors
|
||||
description: Paste browser console errors, backend logs (`docker-compose logs backend`), or network responses. Wrap sensitive data.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Browser, OS, Node version, Docker version — whatever is relevant.
|
||||
placeholder: |
|
||||
Browser: Firefox 128
|
||||
OS: macOS 15.3
|
||||
Docker: 27.x / Compose v2.x
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Screenshots, related issues, workarounds tried, etc.
|
||||
1
.gitea/issue_template/config.yaml
Normal file
1
.gitea/issue_template/config.yaml
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: true
|
||||
87
.gitea/issue_template/feature-request.yaml
Normal file
87
.gitea/issue_template/feature-request.yaml
Normal file
@@ -0,0 +1,87 @@
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or improvement to an existing one
|
||||
title: "[Feature] "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: dropdown
|
||||
id: type
|
||||
attributes:
|
||||
label: Request Type
|
||||
options:
|
||||
- New feature
|
||||
- Enhancement to existing feature
|
||||
- UX / UI improvement
|
||||
- API addition or change
|
||||
- Developer experience / tooling
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Affected Area
|
||||
description: Which part of the application does this relate to?
|
||||
multiple: true
|
||||
options:
|
||||
- Game Picker
|
||||
- Game Manager
|
||||
- Session Management
|
||||
- History / Session Detail
|
||||
- Room Code / Player Count
|
||||
- Chat Log Import / Voting
|
||||
- Webhooks
|
||||
- Authentication / Admin
|
||||
- WebSocket / Real-time / Presence
|
||||
- PWA
|
||||
- UI / Styling
|
||||
- API (backend)
|
||||
- Database
|
||||
- Docker / Deployment
|
||||
- Documentation
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem / Motivation
|
||||
description: What problem does this solve, or what use case does it enable?
|
||||
placeholder: "During a stream, I need to X but currently have to Y which is slow because Z."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe what you'd like to happen. Be specific about behavior, UI, or API shape if you have ideas.
|
||||
placeholder: |
|
||||
Add a "favorites" toggle on each game card in the Manager.
|
||||
Favorited games appear at the top of the picker pool.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Other approaches you thought about and why they're less ideal.
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: How important is this to you?
|
||||
options:
|
||||
- Nice to have
|
||||
- Would improve my workflow noticeably
|
||||
- Blocking a use case I need
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Mockups, screenshots, links to related issues, etc.
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -15,6 +15,9 @@ node_modules/
|
||||
frontend/dist/
|
||||
frontend/build/
|
||||
|
||||
# Generated files
|
||||
frontend/public/manifest.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -36,8 +39,12 @@ Thumbs.db
|
||||
.local/
|
||||
.old-chrome-extension/
|
||||
|
||||
# Admin config (real keys)
|
||||
backend/config/admins.json
|
||||
|
||||
# Cursor
|
||||
.cursor/
|
||||
.superpowers/
|
||||
chat-summaries/
|
||||
plan.md
|
||||
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 cottongin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
364
README.md
364
README.md
@@ -1,45 +1,78 @@
|
||||
# Jackbox Party Pack Game Picker
|
||||
|
||||
A full-stack web application that helps groups pick games to play from various Jackbox Party Packs. Features include random game selection with filters, session tracking, game management, and popularity scoring through chat log imports.
|
||||
A full-stack web application that helps groups pick games to play from various Jackbox Party Packs. Features include random game selection with weighted filters, session tracking, game management, popularity scoring through chat log imports and live voting, and Jackbox lobby integration.
|
||||
|
||||
## Features
|
||||
|
||||
### Progressive Web App (PWA)
|
||||
- **Installable**: Add to home screen on mobile and desktop devices
|
||||
- **Offline Support**: Service worker provides offline functionality
|
||||
- **Native Experience**: Runs like a native app when installed
|
||||
- **Auto-updates**: Seamlessly updates to new versions
|
||||
|
||||
### Admin Features
|
||||
- **Game Picker**: Randomly select games with intelligent filters
|
||||
- Filter by player count, drawing games, game length, and family-friendly status
|
||||
- Weighted random selection using game and pack favor bias
|
||||
- Automatic repeat avoidance (prevents same game or alternating pattern)
|
||||
- Manual game selection option
|
||||
- Real-time session tracking
|
||||
|
||||
- **Game Manager**: Complete CRUD operations for games and packs
|
||||
- Enable/disable individual games or entire packs
|
||||
- Set favor bias on games and packs to influence pick weighting
|
||||
- Import/export games via CSV
|
||||
- View statistics (play counts, popularity scores)
|
||||
- View statistics (play counts, upvotes, downvotes, popularity scores)
|
||||
- Add, edit, and delete games
|
||||
|
||||
- **Session Management**: Track gaming sessions over time
|
||||
- Create and close sessions
|
||||
- View session history
|
||||
- Import chat logs to calculate game popularity
|
||||
- Track which games were played when
|
||||
- View session history with pagination and filters
|
||||
- Archive/unarchive sessions
|
||||
- Add, edit, and delete session notes (hidden from unauthenticated users)
|
||||
- Export session data
|
||||
- Bulk session creation
|
||||
- Per-session game management (add, remove, reorder, set status)
|
||||
|
||||
- **Room Code & Player Count**: Live Jackbox lobby integration
|
||||
- Set room codes on session games
|
||||
- Automatic player count fetching via Jackbox ecast shard API
|
||||
- Start/stop player count monitoring
|
||||
- Live status updates via WebSocket
|
||||
|
||||
- **Chat Log Import**: Process chat messages to assess game popularity
|
||||
- Supports "thisgame++" and "thisgame--" voting
|
||||
- Automatically matches votes to games based on timestamps
|
||||
- Updates popularity scores across sessions
|
||||
- Updates upvote/downvote counts across sessions
|
||||
|
||||
- **Live Voting API**: Real-time vote processing from external bots
|
||||
- Accept live votes via REST API
|
||||
- Real-time `vote.received` WebSocket event for stream overlays
|
||||
- Per-session vote breakdown and paginated global vote history
|
||||
- Automatic deduplication (1-second window)
|
||||
- Timestamp-based game matching
|
||||
- JWT authentication for security
|
||||
|
||||
- **Webhook System**: Notify external services of events
|
||||
- Send notifications when games are added to sessions
|
||||
- HMAC-SHA256 signature verification
|
||||
- Webhook management (CRUD operations)
|
||||
- Delivery logging and testing
|
||||
|
||||
### Public Features
|
||||
- View active session and games currently being played
|
||||
- Browse session history
|
||||
- Browse session history (with archived sessions hidden by default)
|
||||
- Detailed session view with game list and vote breakdowns
|
||||
- See game statistics and popularity
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: React 18 with Vite, Tailwind CSS, React Router
|
||||
- **Backend**: Node.js with Express
|
||||
- **Frontend**: React 18 with Vite, Tailwind CSS, React Router 6
|
||||
- **Backend**: Node.js with Express 4
|
||||
- **Database**: SQLite with better-sqlite3
|
||||
- **Real-time**: WebSocket server (`ws`) for presence, subscriptions, and live events
|
||||
- **Authentication**: JWT-based admin authentication
|
||||
- **Deployment**: Docker with docker-compose
|
||||
- **Deployment**: Docker with docker-compose (Node 22 Alpine + nginx Alpine)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -58,12 +91,11 @@ A full-stack web application that helps groups pick games to play from various J
|
||||
|
||||
Create a `.env` file in the root directory:
|
||||
```env
|
||||
PORT=5000
|
||||
NODE_ENV=production
|
||||
DB_PATH=/app/data/jackbox.db
|
||||
JWT_SECRET=your-secret-jwt-key-change-this
|
||||
ADMIN_KEY=your-admin-key-here
|
||||
```
|
||||
|
||||
`JWT_SECRET` is required. Provide either `ADMIN_KEY` (single admin) or configure named admins (see [Named Admins](#named-admins) below).
|
||||
|
||||
3. **Build and start the containers**
|
||||
```bash
|
||||
@@ -76,7 +108,7 @@ A full-stack web application that helps groups pick games to play from various J
|
||||
|
||||
5. **Login as admin**
|
||||
- Navigate to the login page
|
||||
- Enter your `ADMIN_KEY` from the `.env` file
|
||||
- Enter your `ADMIN_KEY` (or a named admin key) from your configuration
|
||||
|
||||
The database will be automatically initialized and populated with games from `games-list.csv` on first run.
|
||||
|
||||
@@ -128,37 +160,141 @@ The backend will run on http://localhost:5000
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The frontend will run on http://localhost:3000 and proxy API requests to the backend.
|
||||
The frontend will run on http://localhost:3000. Note: the Vite dev proxy is configured to forward `/api` requests to `http://backend:5000` (the Docker Compose service name). For bare-metal local development without Docker, you may need to update the proxy target in `vite.config.js` to `http://localhost:5000`.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Named Admins
|
||||
|
||||
The app supports multiple named admin accounts. Create `backend/config/admins.json` (see `admins.example.json` for the format):
|
||||
|
||||
```json
|
||||
[
|
||||
{ "name": "Alice", "key": "change-me-alice-key" },
|
||||
{ "name": "Bob", "key": "change-me-bob-key" }
|
||||
]
|
||||
```
|
||||
|
||||
Each admin logs in with their unique key. Their name is embedded in the JWT and displayed in the presence bar.
|
||||
|
||||
If `admins.json` is not found, the app falls back to the `ADMIN_KEY` environment variable as a single admin named "Admin".
|
||||
|
||||
To use a custom path for the admins file, set `ADMIN_CONFIG_PATH` in your environment.
|
||||
|
||||
For Docker, uncomment the volume mount in `docker-compose.yml`:
|
||||
```yaml
|
||||
- ./backend/config/admins.json:/app/config/admins.json:ro
|
||||
```
|
||||
|
||||
### Branding and Metadata
|
||||
|
||||
All app branding, metadata, and PWA configuration is centralized in `frontend/src/config/branding.js`. Edit this file to customize:
|
||||
|
||||
- **App Name** and **Short Name** - Displayed in UI and when installed as PWA
|
||||
- **Description** - Shown in search engines and app stores
|
||||
- **Version** - Current app version
|
||||
- **Theme Color** - Primary color for browser chrome and PWA theme
|
||||
- **Keywords** - SEO metadata
|
||||
- **Author** - Creator/maintainer information
|
||||
- **Links** - GitHub repo, support contact, etc.
|
||||
|
||||
When you update `branding.js`, the following are automatically synchronized:
|
||||
|
||||
1. **PWA Manifest** (`manifest.json`) - Generated at build time via `generate-manifest.js`
|
||||
2. **HTML Meta Tags** - Updated via Vite HTML transformation plugin
|
||||
3. **App UI** - Components import branding directly
|
||||
|
||||
To regenerate the manifest manually:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run generate-manifest
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `JWT_SECRET` | Yes | — | Secret key for signing JWT tokens |
|
||||
| `ADMIN_KEY` | No | — | Single admin authentication key (fallback when `admins.json` is absent) |
|
||||
| `ADMIN_CONFIG_PATH` | No | `backend/config/admins.json` | Path to named admins JSON file |
|
||||
| `PORT` | No | `5000` | Backend server port |
|
||||
| `NODE_ENV` | No | `development` | Environment (production/development) |
|
||||
| `DB_PATH` | No | `./data/jackbox.db` | Path to SQLite database file |
|
||||
| `DEBUG` | No | `false` | Enable debug logging |
|
||||
|
||||
## Testing
|
||||
|
||||
The project uses Jest for API and integration tests. Tests live in the `tests/` directory at the repository root.
|
||||
|
||||
```bash
|
||||
# Install backend dependencies first
|
||||
cd backend && npm install && cd ..
|
||||
|
||||
# Run tests
|
||||
npx jest
|
||||
|
||||
# Run tests in watch mode
|
||||
npx jest --watch
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
/
|
||||
├── backend/
|
||||
│ ├── routes/ # API route handlers
|
||||
│ │ ├── auth.js # Authentication endpoints
|
||||
│ │ ├── games.js # Game CRUD and management
|
||||
│ │ ├── sessions.js # Session management
|
||||
│ │ ├── picker.js # Game picker algorithm
|
||||
│ │ └── stats.js # Statistics endpoints
|
||||
│ ├── middleware/ # Express middleware
|
||||
│ │ └── auth.js # JWT authentication
|
||||
│ ├── database.js # SQLite database setup
|
||||
│ ├── bootstrap.js # Database initialization
|
||||
│ ├── server.js # Express app entry point
|
||||
│ ├── routes/ # API route handlers
|
||||
│ │ ├── auth.js # Authentication endpoints
|
||||
│ │ ├── games.js # Game CRUD, packs, favor bias
|
||||
│ │ ├── sessions.js # Sessions, archives, notes, room codes, export
|
||||
│ │ ├── picker.js # Game picker algorithm
|
||||
│ │ ├── stats.js # Statistics endpoints
|
||||
│ │ ├── votes.js # Live voting endpoint
|
||||
│ │ └── webhooks.js # Webhook management
|
||||
│ ├── middleware/ # Express middleware
|
||||
│ │ ├── auth.js # JWT authentication (required)
|
||||
│ │ └── optional-auth.js # JWT authentication (optional, for public routes)
|
||||
│ ├── config/ # Configuration files
|
||||
│ │ ├── admins.example.json # Example named admins config
|
||||
│ │ ├── admins.json # Named admins (gitignored)
|
||||
│ │ └── load-admins.js # Admin config loader
|
||||
│ ├── utils/ # Utility modules
|
||||
│ │ ├── websocket-manager.js # WebSocket server (presence, events)
|
||||
│ │ ├── ecast-shard-client.js # Jackbox ecast shard integration
|
||||
│ │ ├── jackbox-api.js # Jackbox API helpers
|
||||
│ │ ├── notes-preview.js # Session notes preview generation
|
||||
│ │ └── webhooks.js # Webhook trigger and HMAC signatures
|
||||
│ ├── database.js # SQLite database setup and migrations
|
||||
│ ├── bootstrap.js # Database initialization from CSV
|
||||
│ ├── server.js # Express + WebSocket entry point
|
||||
│ ├── package.json
|
||||
│ └── Dockerfile
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── pages/ # React page components
|
||||
│ │ ├── pages/ # React page components
|
||||
│ │ │ ├── Home.jsx
|
||||
│ │ │ ├── Login.jsx
|
||||
│ │ │ ├── Picker.jsx
|
||||
│ │ │ ├── Manager.jsx
|
||||
│ │ │ └── History.jsx
|
||||
│ │ ├── context/ # React context providers
|
||||
│ │ │ └── AuthContext.jsx
|
||||
│ │ ├── api/ # API client
|
||||
│ │ │ ├── History.jsx
|
||||
│ │ │ └── SessionDetail.jsx
|
||||
│ │ ├── components/ # Reusable UI components
|
||||
│ │ │ ├── PresenceBar.jsx
|
||||
│ │ │ ├── Toast.jsx
|
||||
│ │ │ ├── ThemeToggle.jsx
|
||||
│ │ │ ├── Logo.jsx
|
||||
│ │ │ ├── RoomCodeModal.jsx
|
||||
│ │ │ ├── GamePoolModal.jsx
|
||||
│ │ │ ├── PopularityBadge.jsx
|
||||
│ │ │ ├── InstallPrompt.jsx
|
||||
│ │ │ └── SafariInstallPrompt.jsx
|
||||
│ │ ├── context/ # React context providers
|
||||
│ │ │ ├── AuthContext.jsx
|
||||
│ │ │ └── ThemeContext.jsx
|
||||
│ │ ├── hooks/ # Custom React hooks
|
||||
│ │ │ └── usePresence.js
|
||||
│ │ ├── config/ # Frontend configuration
|
||||
│ │ │ └── branding.js
|
||||
│ │ ├── api/ # API client
|
||||
│ │ │ └── axios.js
|
||||
│ │ ├── App.jsx
|
||||
│ │ ├── main.jsx
|
||||
@@ -167,47 +303,94 @@ The frontend will run on http://localhost:3000 and proxy API requests to the bac
|
||||
│ ├── vite.config.js
|
||||
│ ├── tailwind.config.js
|
||||
│ ├── package.json
|
||||
│ ├── nginx.conf # Nginx config for Docker
|
||||
│ ├── nginx.conf # Nginx config for Docker (proxy + SPA)
|
||||
│ └── Dockerfile
|
||||
├── tests/ # Jest API and integration tests
|
||||
│ ├── api/ # Route-level tests
|
||||
│ ├── helpers/ # Test utilities
|
||||
│ └── jest.setup.js
|
||||
├── scripts/ # Jackbox lobby inspection utilities
|
||||
├── docs/ # API documentation, design specs, plans
|
||||
│ ├── api/ # OpenAPI spec, endpoint docs, guides
|
||||
│ ├── plans/ # Implementation plans
|
||||
│ └── archive/ # Archived docs
|
||||
├── docker-compose.yml
|
||||
├── games-list.csv # Initial game data
|
||||
├── jest.config.js
|
||||
├── games-list.csv # Initial game seed data
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Health
|
||||
- `GET /health` - Health check (returns `{ status: "ok" }`)
|
||||
|
||||
### Authentication
|
||||
- `POST /api/auth/login` - Login with admin key
|
||||
- `POST /api/auth/login` - Login with admin key (returns JWT with admin name)
|
||||
- `POST /api/auth/verify` - Verify JWT token
|
||||
|
||||
### Games
|
||||
- `GET /api/games` - List all games (with filters)
|
||||
- `GET /api/games/packs` - List unique pack names
|
||||
- `GET /api/games/meta/packs` - Get pack list with stats and favor bias
|
||||
- `GET /api/games/:id` - Get single game
|
||||
- `POST /api/games` - Create game (admin)
|
||||
- `PUT /api/games/:id` - Update game (admin)
|
||||
- `DELETE /api/games/:id` - Delete game (admin)
|
||||
- `PATCH /api/games/:id/toggle` - Toggle game enabled status (admin)
|
||||
- `GET /api/games/meta/packs` - Get pack list with stats
|
||||
- `PATCH /api/games/:id/favor` - Set game favor bias (admin)
|
||||
- `PATCH /api/games/packs/:name/toggle` - Toggle entire pack (admin)
|
||||
- `PATCH /api/games/packs/:name/favor` - Set pack favor bias (admin)
|
||||
- `GET /api/games/export/csv` - Export games to CSV (admin)
|
||||
- `POST /api/games/import/csv` - Import games from CSV (admin)
|
||||
|
||||
### Sessions
|
||||
- `GET /api/sessions` - List all sessions
|
||||
- `GET /api/sessions` - List sessions (paginated, filterable, `X-Total-Count` header)
|
||||
- `GET /api/sessions/active` - Get active session
|
||||
- `GET /api/sessions/:id` - Get single session
|
||||
- `GET /api/sessions/:id` - Get single session (notes hidden from unauthenticated users)
|
||||
- `POST /api/sessions` - Create new session (admin)
|
||||
- `POST /api/sessions/bulk` - Bulk create sessions (admin)
|
||||
- `POST /api/sessions/:id/close` - Close session (admin)
|
||||
- `DELETE /api/sessions/:id` - Delete session (admin)
|
||||
- `PUT /api/sessions/:id/notes` - Update session notes (admin)
|
||||
- `DELETE /api/sessions/:id/notes` - Delete session notes (admin)
|
||||
- `POST /api/sessions/:id/archive` - Archive session (admin)
|
||||
- `POST /api/sessions/:id/unarchive` - Unarchive session (admin)
|
||||
- `GET /api/sessions/:id/games` - Get games in session
|
||||
- `POST /api/sessions/:id/games` - Add game to session (admin)
|
||||
- `PATCH /api/sessions/:sessionId/games/:gameId/status` - Update game status (admin)
|
||||
- `DELETE /api/sessions/:sessionId/games/:gameId` - Remove game from session (admin)
|
||||
- `PATCH /api/sessions/:sessionId/games/:gameId/room-code` - Set room code (admin)
|
||||
- `PATCH /api/sessions/:sessionId/games/:gameId/player-count` - Update player count (admin)
|
||||
- `GET /api/sessions/:sessionId/games/:gameId/status-live` - Get live ecast status
|
||||
- `POST /api/sessions/:sessionId/games/:gameId/start-player-check` - Start player count monitoring (admin)
|
||||
- `POST /api/sessions/:sessionId/games/:gameId/stop-player-check` - Stop player count monitoring (admin)
|
||||
- `GET /api/sessions/:id/votes` - Get per-game vote breakdown for a session
|
||||
- `POST /api/sessions/:id/chat-import` - Import chat log (admin)
|
||||
- `GET /api/sessions/:id/export` - Export session data (admin)
|
||||
|
||||
### Game Picker
|
||||
- `POST /api/pick` - Pick random game with filters
|
||||
- `POST /api/pick` - Pick random game with filters and favor bias weighting
|
||||
|
||||
### Statistics
|
||||
- `GET /api/stats` - Get overall statistics
|
||||
|
||||
### Votes
|
||||
- `GET /api/votes` - Paginated vote history with filtering
|
||||
- `POST /api/votes/live` - Submit real-time vote (admin)
|
||||
|
||||
### Webhooks
|
||||
- `GET /api/webhooks` - List all webhooks (admin)
|
||||
- `GET /api/webhooks/:id` - Get single webhook (admin)
|
||||
- `POST /api/webhooks` - Create webhook (admin)
|
||||
- `PATCH /api/webhooks/:id` - Update webhook (admin)
|
||||
- `DELETE /api/webhooks/:id` - Delete webhook (admin)
|
||||
- `POST /api/webhooks/test/:id` - Test webhook (admin)
|
||||
- `GET /api/webhooks/:id/logs` - Get webhook logs (admin)
|
||||
|
||||
### WebSocket
|
||||
- `ws://host/api/sessions/live` - Real-time connection for presence, session subscriptions, and live events
|
||||
|
||||
## Usage Guide
|
||||
|
||||
### Starting a Game Session
|
||||
@@ -228,9 +411,10 @@ The frontend will run on http://localhost:3000 and proxy API requests to the bac
|
||||
2. Navigate to the Manager page
|
||||
3. View statistics and pack information
|
||||
4. Toggle individual games or entire packs on/off
|
||||
5. Add new games with the "+ Add Game" button
|
||||
6. Edit or delete existing games
|
||||
7. Import/Export games via CSV
|
||||
5. Adjust favor bias to weight certain games or packs in the picker
|
||||
6. Add new games with the "+ Add Game" button
|
||||
7. Edit or delete existing games
|
||||
8. Import/Export games via CSV
|
||||
|
||||
### Closing a Session and Importing Chat Logs
|
||||
|
||||
@@ -256,6 +440,10 @@ The frontend will run on http://localhost:3000 and proxy API requests to the bac
|
||||
6. Click "Close Session" to finalize
|
||||
7. Add optional notes about the session
|
||||
|
||||
### Archiving Sessions
|
||||
|
||||
Closed sessions can be archived to keep the history page clean. Archived sessions are hidden by default but can be viewed by toggling the archive filter. Archiving and unarchiving are available from the session detail page or via bulk actions in the history list.
|
||||
|
||||
## Chat Log Format
|
||||
|
||||
The chat import feature expects a JSON array where each message has:
|
||||
@@ -266,24 +454,83 @@ The chat import feature expects a JSON array where each message has:
|
||||
The system will:
|
||||
1. Parse each message for vote patterns
|
||||
2. Match the timestamp to the game being played at that time
|
||||
3. Update the game's popularity score (+1 for ++, -1 for --)
|
||||
3. Update the game's upvote/downvote counts (+1 for ++, -1 for --)
|
||||
4. Store the chat log in the database
|
||||
|
||||
## Bot Integration
|
||||
|
||||
For integrating external bots (e.g., for live voting and game notifications), see **[BOT_INTEGRATION.md](docs/archive/BOT_INTEGRATION.md)** for detailed documentation including:
|
||||
|
||||
- Live voting API usage
|
||||
- **WebSocket integration (recommended)** for real-time game notifications
|
||||
- Webhook setup and verification (alternative to WebSocket)
|
||||
- Example implementations in Node.js and Go
|
||||
- Security best practices
|
||||
|
||||
## Jackbox Player Count Fetcher
|
||||
|
||||
The `scripts/` directory contains utilities for inspecting Jackbox game lobbies:
|
||||
|
||||
- **[get-player-count.go](scripts/get-player-count.go)** - Go + chromedp script (recommended, most reliable)
|
||||
- **[get-player-count.html](scripts/get-player-count.html)** - Browser-based tool (no installation required!)
|
||||
- **[get-jackbox-player-count.js](scripts/get-jackbox-player-count.js)** - Node.js script (limited, may not work)
|
||||
|
||||
See **[scripts/README.md](scripts/README.md)** for detailed usage instructions.
|
||||
|
||||
### Quick Start
|
||||
|
||||
**Go version (recommended for automation):**
|
||||
```bash
|
||||
cd scripts
|
||||
go run get-player-count.go JYET
|
||||
```
|
||||
|
||||
**Browser version (easiest for manual testing):**
|
||||
1. Open `scripts/get-player-count.html` in any browser
|
||||
2. Enter a 4-letter room code
|
||||
3. View real-time player count and lobby status
|
||||
|
||||
**How it works:**
|
||||
- Automates joining jackbox.tv through Chrome/Chromium
|
||||
- Captures WebSocket messages containing player data
|
||||
- Extracts actual player count from lobby state
|
||||
|
||||
These tools retrieve:
|
||||
- Actual player count (not just max capacity)
|
||||
- List of current players and their roles (host/player)
|
||||
- Game state and lobby status
|
||||
- Audience count
|
||||
|
||||
**Note:** Direct WebSocket connection is not possible without authentication, so the tools join through jackbox.tv to capture the data.
|
||||
|
||||
## Database Schema
|
||||
|
||||
### games
|
||||
- id, pack_name, title, min_players, max_players, length_minutes
|
||||
- has_audience, family_friendly, game_type, secondary_type
|
||||
- play_count, popularity_score, enabled, created_at
|
||||
- play_count, popularity_score, upvotes, downvotes, favor_bias, enabled, created_at
|
||||
|
||||
### packs
|
||||
- id, name (unique), favor_bias, created_at
|
||||
|
||||
### sessions
|
||||
- id, created_at, closed_at, is_active, notes
|
||||
- id, created_at, closed_at, is_active, notes, archived
|
||||
|
||||
### session_games
|
||||
- id, session_id, game_id, played_at, manually_added
|
||||
- id, session_id, game_id, played_at, manually_added, status
|
||||
- room_code, player_count, player_count_check_status
|
||||
|
||||
### chat_logs
|
||||
- id, session_id, chatter_name, message, timestamp, parsed_vote
|
||||
- id, session_id, chatter_name, message, timestamp, parsed_vote, message_hash
|
||||
|
||||
### live_votes
|
||||
- id, session_id, game_id, username, vote_type, timestamp, created_at
|
||||
|
||||
### webhooks
|
||||
- id, name, url, secret, events, enabled, created_at
|
||||
|
||||
### webhook_logs
|
||||
- id, webhook_id, event_type, payload, response_status, error_message, created_at
|
||||
|
||||
## Game Selection Algorithm
|
||||
|
||||
@@ -301,8 +548,10 @@ The picker uses the following logic:
|
||||
- Exclude those games from selection pool
|
||||
- This prevents immediate repeats and alternating patterns
|
||||
|
||||
3. **Random selection**:
|
||||
- Pick a random game from remaining eligible games
|
||||
3. **Weighted random selection**:
|
||||
- Games and packs can have a `favor_bias` (+/- integer)
|
||||
- Positive bias increases pick probability; negative bias decreases it
|
||||
- Pick a random game from remaining eligible games, weighted by bias
|
||||
- Return game details and pool size
|
||||
|
||||
## Docker Deployment
|
||||
@@ -325,36 +574,28 @@ docker-compose down
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set these in your `.env` file or docker-compose environment:
|
||||
|
||||
- `PORT` - Backend server port (default: 5000)
|
||||
- `NODE_ENV` - Environment (production/development)
|
||||
- `DB_PATH` - Path to SQLite database file
|
||||
- `JWT_SECRET` - Secret key for JWT tokens
|
||||
- `ADMIN_KEY` - Admin authentication key
|
||||
|
||||
### Data Persistence
|
||||
|
||||
The SQLite database is stored in `backend/data/` and is persisted via Docker volumes. To backup your data, copy the `backend/data/` directory.
|
||||
The SQLite database is stored in a named Docker volume (`jackbox-data`) mapped to `/app/data` in the backend container. To backup your data, use `docker cp` or bind-mount a host directory.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database not initializing
|
||||
- Ensure `games-list.csv` is in the root directory
|
||||
- Check backend logs: `docker-compose logs backend`
|
||||
- Manually delete `backend/data/jackbox.db` to force re-initialization
|
||||
- Manually delete the database to force re-initialization
|
||||
|
||||
### Can't login as admin
|
||||
- Verify your `ADMIN_KEY` environment variable is set
|
||||
- Verify your `ADMIN_KEY` or `admins.json` is configured correctly
|
||||
- Check that the `.env` file is loaded correctly
|
||||
- If using named admins, ensure `admins.json` has valid JSON with unique names and keys
|
||||
- Try restarting the backend service
|
||||
|
||||
### Frontend can't reach backend
|
||||
- Verify both containers are running: `docker-compose ps`
|
||||
- Check network connectivity: `docker-compose logs frontend`
|
||||
- Ensure nginx.conf proxy settings are correct
|
||||
- For local dev, confirm the Vite proxy target matches your backend URL
|
||||
|
||||
### Games not showing up
|
||||
- Check if games are enabled in the Manager
|
||||
@@ -368,4 +609,3 @@ MIT
|
||||
## Contributing
|
||||
|
||||
Feel free to submit issues and pull requests!
|
||||
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
FROM node:18-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install wget for healthcheck
|
||||
RUN apk add --no-cache wget
|
||||
# Install Chromium, fonts, and dependencies for Puppeteer
|
||||
RUN apk add --no-cache \
|
||||
wget \
|
||||
chromium \
|
||||
nss \
|
||||
freetype \
|
||||
harfbuzz \
|
||||
ca-certificates \
|
||||
ttf-freefont \
|
||||
font-noto-emoji
|
||||
|
||||
# Tell Puppeteer to use the installed Chromium
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
4
backend/config/admins.example.json
Normal file
4
backend/config/admins.example.json
Normal file
@@ -0,0 +1,4 @@
|
||||
[
|
||||
{ "name": "Alice", "key": "change-me-alice-key" },
|
||||
{ "name": "Bob", "key": "change-me-bob-key" }
|
||||
]
|
||||
55
backend/config/load-admins.js
Normal file
55
backend/config/load-admins.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DEFAULT_CONFIG_PATH = path.join(__dirname, 'admins.json');
|
||||
|
||||
function loadAdmins() {
|
||||
const configPath = process.env.ADMIN_CONFIG_PATH || DEFAULT_CONFIG_PATH;
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
const admins = JSON.parse(raw);
|
||||
|
||||
if (!Array.isArray(admins) || admins.length === 0) {
|
||||
throw new Error(`Admin config at ${configPath} must be a non-empty array`);
|
||||
}
|
||||
|
||||
const names = new Set();
|
||||
const keys = new Set();
|
||||
|
||||
for (const admin of admins) {
|
||||
if (!admin.name || !admin.key) {
|
||||
throw new Error('Each admin must have a "name" and "key" property');
|
||||
}
|
||||
if (names.has(admin.name)) {
|
||||
throw new Error(`Duplicate admin name: ${admin.name}`);
|
||||
}
|
||||
if (keys.has(admin.key)) {
|
||||
throw new Error(`Duplicate admin key found`);
|
||||
}
|
||||
names.add(admin.name);
|
||||
keys.add(admin.key);
|
||||
}
|
||||
|
||||
console.log(`[Auth] Loaded ${admins.length} admin(s) from ${configPath}`);
|
||||
return admins;
|
||||
}
|
||||
|
||||
if (process.env.ADMIN_KEY) {
|
||||
console.log('[Auth] No admins config file found, falling back to ADMIN_KEY env var');
|
||||
return [{ name: 'Admin', key: process.env.ADMIN_KEY }];
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'No admin configuration found. Provide backend/config/admins.json or set ADMIN_KEY env var.'
|
||||
);
|
||||
}
|
||||
|
||||
const admins = loadAdmins();
|
||||
|
||||
function findAdminByKey(key) {
|
||||
const match = admins.find(a => a.key === key);
|
||||
return match ? { name: match.name } : null;
|
||||
}
|
||||
|
||||
module.exports = { findAdminByKey, admins };
|
||||
@@ -56,6 +56,13 @@ function initializeDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Session games table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS session_games (
|
||||
@@ -77,6 +84,27 @@ function initializeDatabase() {
|
||||
// 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 favor_bias column to games if it doesn't exist
|
||||
try {
|
||||
db.exec(`ALTER TABLE games ADD COLUMN favor_bias INTEGER DEFAULT 0`);
|
||||
@@ -167,6 +195,55 @@ function initializeDatabase() {
|
||||
// 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');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET environment variable is required');
|
||||
}
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
function authenticateToken(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
19
backend/middleware/optional-auth.js
Normal file
19
backend/middleware/optional-auth.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { JWT_SECRET } = require('./auth');
|
||||
|
||||
function optionalAuthenticateToken(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
jwt.verify(token, JWT_SECRET, (err, user) => {
|
||||
req.user = err ? null : user;
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { optionalAuthenticateToken };
|
||||
5629
backend/package-lock.json
generated
Normal file
5629
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,22 +5,26 @@
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
"dev": "nodemon server.js",
|
||||
"test": "jest --config ../jest.config.js --runInBand --verbose --forceExit",
|
||||
"test:watch": "jest --config ../jest.config.js --runInBand --watch --forceExit"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"cors": "^2.8.5",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"csv-parse": "^5.5.3",
|
||||
"csv-stringify": "^6.4.5"
|
||||
"csv-stringify": "^6.4.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ws": "^8.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"supertest": "^6.3.4"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { JWT_SECRET, authenticateToken } = require('../middleware/auth');
|
||||
const { findAdminByKey } = require('../config/load-admins');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const ADMIN_KEY = process.env.ADMIN_KEY || 'admin123';
|
||||
|
||||
// Login with admin key
|
||||
router.post('/login', (req, res) => {
|
||||
const { key } = req.body;
|
||||
|
||||
@@ -14,31 +12,34 @@ router.post('/login', (req, res) => {
|
||||
return res.status(400).json({ error: 'Admin key is required' });
|
||||
}
|
||||
|
||||
if (key !== ADMIN_KEY) {
|
||||
const admin = findAdminByKey(key);
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'Invalid admin key' });
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = jwt.sign(
|
||||
{ role: 'admin', timestamp: Date.now() },
|
||||
{ role: 'admin', name: admin.name, timestamp: Date.now() },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
res.json({
|
||||
token,
|
||||
name: admin.name,
|
||||
message: 'Authentication successful',
|
||||
expiresIn: '24h'
|
||||
});
|
||||
});
|
||||
|
||||
// Verify token validity
|
||||
router.post('/verify', authenticateToken, (req, res) => {
|
||||
res.json({
|
||||
valid: true,
|
||||
user: req.user
|
||||
if (!req.user.name) {
|
||||
return res.status(403).json({ error: 'Token missing admin identity, please re-login' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
user: req.user
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const db = require('../database');
|
||||
const { triggerWebhook } = require('../utils/webhooks');
|
||||
const { getWebSocketManager } = require('../utils/websocket-manager');
|
||||
const { startMonitor, stopMonitor, getMonitorSnapshot } = require('../utils/ecast-shard-client');
|
||||
const { computeNotesPreview } = require('../utils/notes-preview');
|
||||
const { optionalAuthenticateToken } = require('../middleware/optional-auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -16,17 +21,79 @@ function createMessageHash(username, message, timestamp) {
|
||||
// Get all sessions
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const filter = req.query.filter || 'default';
|
||||
const limitParam = req.query.limit || 'all';
|
||||
const offsetParam = req.query.offset || '0';
|
||||
let offset = parseInt(offsetParam, 10);
|
||||
if (isNaN(offset) || offset < 0) offset = 0;
|
||||
|
||||
let whereClause = '';
|
||||
if (filter === 'default') {
|
||||
whereClause = 'WHERE s.archived = 0';
|
||||
} else if (filter === 'archived') {
|
||||
whereClause = 'WHERE s.archived = 1';
|
||||
}
|
||||
|
||||
const countRow = db.prepare(`
|
||||
SELECT COUNT(DISTINCT s.id) as total
|
||||
FROM sessions s
|
||||
${whereClause}
|
||||
`).get();
|
||||
|
||||
let limitClause = '';
|
||||
if (limitParam !== 'all') {
|
||||
const limitNum = parseInt(limitParam, 10);
|
||||
if (!isNaN(limitNum) && limitNum > 0) {
|
||||
limitClause = `LIMIT ${limitNum}`;
|
||||
}
|
||||
}
|
||||
|
||||
let offsetClause = '';
|
||||
if (offset > 0) {
|
||||
offsetClause = `OFFSET ${offset}`;
|
||||
}
|
||||
|
||||
const sessions = db.prepare(`
|
||||
SELECT
|
||||
s.*,
|
||||
s.id,
|
||||
s.created_at,
|
||||
s.closed_at,
|
||||
s.is_active,
|
||||
s.archived,
|
||||
s.notes,
|
||||
COUNT(sg.id) as games_played
|
||||
FROM sessions s
|
||||
LEFT JOIN session_games sg ON s.id = sg.session_id
|
||||
${whereClause}
|
||||
GROUP BY s.id
|
||||
ORDER BY s.created_at DESC
|
||||
${limitClause}
|
||||
${offsetClause}
|
||||
`).all();
|
||||
|
||||
res.json(sessions);
|
||||
const result = sessions.map(({ notes, ...session }) => {
|
||||
const { has_notes, notes_preview } = computeNotesPreview(notes);
|
||||
return { ...session, has_notes, notes_preview };
|
||||
});
|
||||
|
||||
const absoluteTotal = db.prepare('SELECT COUNT(*) as total FROM sessions').get();
|
||||
|
||||
if (offset > 0) {
|
||||
const prevRow = db.prepare(`
|
||||
SELECT s.created_at
|
||||
FROM sessions s
|
||||
${whereClause}
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT 1 OFFSET ${offset - 1}
|
||||
`).get();
|
||||
if (prevRow) {
|
||||
res.set('X-Prev-Last-Date', prevRow.created_at);
|
||||
}
|
||||
}
|
||||
|
||||
res.set('X-Total-Count', String(countRow.total));
|
||||
res.set('X-Absolute-Total', String(absoluteTotal.total));
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
@@ -58,7 +125,7 @@ router.get('/active', (req, res) => {
|
||||
});
|
||||
|
||||
// Get single session by ID
|
||||
router.get('/:id', (req, res) => {
|
||||
router.get('/:id', optionalAuthenticateToken, (req, res) => {
|
||||
try {
|
||||
const session = db.prepare(`
|
||||
SELECT
|
||||
@@ -74,7 +141,14 @@ router.get('/:id', (req, res) => {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
res.json(session);
|
||||
const { has_notes, notes_preview } = computeNotesPreview(session.notes);
|
||||
|
||||
if (req.user) {
|
||||
res.json({ ...session, has_notes, notes_preview });
|
||||
} else {
|
||||
const { notes, ...publicSession } = session;
|
||||
res.json({ ...publicSession, has_notes, notes_preview });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
@@ -103,12 +177,89 @@ router.post('/', authenticateToken, (req, res) => {
|
||||
const result = stmt.run(notes || null);
|
||||
const newSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get(result.lastInsertRowid);
|
||||
|
||||
// Broadcast session.started event via WebSocket to all authenticated clients
|
||||
try {
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
const eventData = {
|
||||
session: {
|
||||
id: newSession.id,
|
||||
is_active: 1,
|
||||
created_at: newSession.created_at,
|
||||
notes: newSession.notes
|
||||
}
|
||||
};
|
||||
|
||||
wsManager.broadcastToAll('session.started', eventData);
|
||||
console.log(`[Sessions] Broadcasted session.started event for session ${newSession.id} to all clients`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the request
|
||||
console.error('Error broadcasting session.started event:', error);
|
||||
}
|
||||
|
||||
res.status(201).json(newSession);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk session operations (admin only)
|
||||
router.post('/bulk', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { action, ids } = req.body;
|
||||
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return res.status(400).json({ error: 'ids must be a non-empty array' });
|
||||
}
|
||||
|
||||
const validActions = ['archive', 'unarchive', 'delete'];
|
||||
if (!validActions.includes(action)) {
|
||||
return res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
|
||||
}
|
||||
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const sessions = db.prepare(
|
||||
`SELECT id, is_active FROM sessions WHERE id IN (${placeholders})`
|
||||
).all(...ids);
|
||||
|
||||
if (sessions.length !== ids.length) {
|
||||
const foundIds = sessions.map(s => s.id);
|
||||
const missingIds = ids.filter(id => !foundIds.includes(id));
|
||||
return res.status(404).json({ error: 'Some sessions not found', missingIds });
|
||||
}
|
||||
|
||||
if (action === 'archive' || action === 'delete') {
|
||||
const activeIds = sessions.filter(s => s.is_active === 1).map(s => s.id);
|
||||
if (activeIds.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Cannot ${action} active sessions. Close them first.`,
|
||||
activeIds
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const bulkOperation = db.transaction(() => {
|
||||
if (action === 'archive') {
|
||||
db.prepare(`UPDATE sessions SET archived = 1 WHERE id IN (${placeholders})`).run(...ids);
|
||||
} else if (action === 'unarchive') {
|
||||
db.prepare(`UPDATE sessions SET archived = 0 WHERE id IN (${placeholders})`).run(...ids);
|
||||
} else if (action === 'delete') {
|
||||
db.prepare(`DELETE FROM chat_logs WHERE session_id IN (${placeholders})`).run(...ids);
|
||||
db.prepare(`DELETE FROM live_votes WHERE session_id IN (${placeholders})`).run(...ids);
|
||||
db.prepare(`DELETE FROM session_games WHERE session_id IN (${placeholders})`).run(...ids);
|
||||
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...ids);
|
||||
}
|
||||
});
|
||||
|
||||
bulkOperation();
|
||||
|
||||
res.json({ success: true, affected: ids.length });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Close/finalize session (admin only)
|
||||
router.post('/:id/close', authenticateToken, (req, res) => {
|
||||
try {
|
||||
@@ -139,7 +290,37 @@ router.post('/:id/close', authenticateToken, (req, res) => {
|
||||
|
||||
stmt.run(notes || null, req.params.id);
|
||||
|
||||
const closedSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id);
|
||||
// Get updated session with games count
|
||||
const closedSession = db.prepare(`
|
||||
SELECT
|
||||
s.*,
|
||||
COUNT(sg.id) as games_played
|
||||
FROM sessions s
|
||||
LEFT JOIN session_games sg ON s.id = sg.session_id
|
||||
WHERE s.id = ?
|
||||
GROUP BY s.id
|
||||
`).get(req.params.id);
|
||||
|
||||
// Broadcast session.ended event via WebSocket
|
||||
try {
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
const eventData = {
|
||||
session: {
|
||||
id: closedSession.id,
|
||||
is_active: 0,
|
||||
games_played: closedSession.games_played
|
||||
}
|
||||
};
|
||||
|
||||
wsManager.broadcastEvent('session.ended', eventData, parseInt(req.params.id));
|
||||
console.log(`[Sessions] Broadcasted session.ended event for session ${req.params.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the request
|
||||
console.error('Error broadcasting session.ended event:', error);
|
||||
}
|
||||
|
||||
res.json(closedSession);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -173,6 +354,84 @@ router.delete('/:id', authenticateToken, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update session notes (admin only)
|
||||
router.put('/:id/notes', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { notes } = req.body;
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
db.prepare('UPDATE sessions SET notes = ? WHERE id = ?').run(notes, req.params.id);
|
||||
|
||||
const updated = db.prepare(`
|
||||
SELECT s.*, COUNT(sg.id) as games_played
|
||||
FROM sessions s
|
||||
LEFT JOIN session_games sg ON s.id = sg.session_id
|
||||
WHERE s.id = ?
|
||||
GROUP BY s.id
|
||||
`).get(req.params.id);
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Clear session notes (admin only)
|
||||
router.delete('/:id/notes', authenticateToken, (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' });
|
||||
}
|
||||
|
||||
db.prepare('UPDATE sessions SET notes = NULL WHERE id = ?').run(req.params.id);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Archive a session (admin only)
|
||||
router.post('/:id/archive', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const session = db.prepare('SELECT id, is_active FROM sessions WHERE id = ?').get(req.params.id);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
if (session.is_active === 1) {
|
||||
return res.status(400).json({ error: 'Cannot archive an active session. Please close it first.' });
|
||||
}
|
||||
|
||||
db.prepare('UPDATE sessions SET archived = 1 WHERE id = ?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Unarchive a session (admin only)
|
||||
router.post('/:id/unarchive', authenticateToken, (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' });
|
||||
}
|
||||
|
||||
db.prepare('UPDATE sessions SET archived = 0 WHERE id = ?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get games played in a session
|
||||
router.get('/:id/games', (req, res) => {
|
||||
try {
|
||||
@@ -199,10 +458,44 @@ 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 {
|
||||
const { game_id, manually_added } = req.body;
|
||||
const { game_id, manually_added, room_code } = req.body;
|
||||
|
||||
if (!game_id) {
|
||||
return res.status(400).json({ error: 'game_id is required' });
|
||||
@@ -226,6 +519,14 @@ router.post('/:id/games', authenticateToken, (req, res) => {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
// Stop monitors for currently-playing games before demoting them
|
||||
const previouslyPlaying = db.prepare(
|
||||
'SELECT id FROM session_games WHERE session_id = ? AND status = ?'
|
||||
).all(req.params.id, 'playing');
|
||||
for (const prev of previouslyPlaying) {
|
||||
try { stopMonitor(req.params.id, prev.id); } catch (_) {}
|
||||
}
|
||||
|
||||
// Set all current 'playing' games to 'played' (except skipped ones)
|
||||
db.prepare(`
|
||||
UPDATE session_games
|
||||
@@ -238,11 +539,11 @@ router.post('/:id/games', authenticateToken, (req, res) => {
|
||||
|
||||
// Add game to session with 'playing' status
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO session_games (session_id, game_id, manually_added, status)
|
||||
VALUES (?, ?, ?, 'playing')
|
||||
INSERT INTO session_games (session_id, game_id, manually_added, status, room_code)
|
||||
VALUES (?, ?, ?, 'playing', ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0);
|
||||
const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0, room_code || null);
|
||||
|
||||
// Increment play count for the game
|
||||
db.prepare('UPDATE games SET play_count = play_count + 1 WHERE id = ?').run(game_id);
|
||||
@@ -252,12 +553,65 @@ router.post('/:id/games', authenticateToken, (req, res) => {
|
||||
sg.*,
|
||||
g.pack_name,
|
||||
g.title,
|
||||
g.game_type
|
||||
g.game_type,
|
||||
g.min_players,
|
||||
g.max_players
|
||||
FROM session_games sg
|
||||
JOIN games g ON sg.game_id = g.id
|
||||
WHERE sg.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
// Trigger webhook and WebSocket for game.added event
|
||||
try {
|
||||
const sessionStats = db.prepare(`
|
||||
SELECT
|
||||
s.*,
|
||||
COUNT(sg.id) as games_played
|
||||
FROM sessions s
|
||||
LEFT JOIN session_games sg ON s.id = sg.session_id
|
||||
WHERE s.id = ?
|
||||
GROUP BY s.id
|
||||
`).get(req.params.id);
|
||||
|
||||
const eventData = {
|
||||
session: {
|
||||
id: sessionStats.id,
|
||||
is_active: sessionStats.is_active === 1,
|
||||
games_played: sessionStats.games_played
|
||||
},
|
||||
game: {
|
||||
id: game.id,
|
||||
title: game.title,
|
||||
pack_name: game.pack_name,
|
||||
min_players: game.min_players,
|
||||
max_players: game.max_players,
|
||||
manually_added: manually_added || false,
|
||||
room_code: room_code || null
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger webhook (for backwards compatibility)
|
||||
triggerWebhook('game.added', eventData);
|
||||
|
||||
// Broadcast via WebSocket (new preferred method)
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('game.added', eventData, parseInt(req.params.id));
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the request
|
||||
console.error('Error triggering notifications:', error);
|
||||
}
|
||||
|
||||
// Automatically start room monitoring if room code was provided
|
||||
if (room_code) {
|
||||
try {
|
||||
startMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players);
|
||||
} catch (error) {
|
||||
console.error('Error starting room monitor:', error);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json(sessionGame);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -449,8 +803,17 @@ router.patch('/:sessionId/games/:gameId/status', authenticateToken, (req, res) =
|
||||
return res.status(400).json({ error: 'Invalid status. Must be playing, played, or skipped' });
|
||||
}
|
||||
|
||||
// If setting to 'playing', first set all other games in session to 'played' or keep as 'skipped'
|
||||
// If setting to 'playing', first stop monitors and demote other playing games
|
||||
if (status === 'playing') {
|
||||
const previouslyPlaying = db.prepare(
|
||||
'SELECT id FROM session_games WHERE session_id = ? AND status = ?'
|
||||
).all(sessionId, 'playing');
|
||||
for (const prev of previouslyPlaying) {
|
||||
if (String(prev.id) !== String(gameId)) {
|
||||
try { stopMonitor(sessionId, prev.id); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE session_games
|
||||
SET status = CASE
|
||||
@@ -472,6 +835,15 @@ router.patch('/:sessionId/games/:gameId/status', authenticateToken, (req, res) =
|
||||
return res.status(404).json({ error: 'Session game not found' });
|
||||
}
|
||||
|
||||
// Stop room monitor and player count check if game is no longer playing
|
||||
if (status !== 'playing') {
|
||||
try {
|
||||
stopMonitor(sessionId, gameId);
|
||||
} catch (error) {
|
||||
console.error('Error stopping room monitor/player count check:', error);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: 'Status updated successfully', status });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -483,6 +855,13 @@ router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { sessionId, gameId } = req.params;
|
||||
|
||||
// Stop room monitor and player count check before deleting
|
||||
try {
|
||||
stopMonitor(sessionId, gameId);
|
||||
} catch (error) {
|
||||
console.error('Error stopping room monitor/player count check:', error);
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
DELETE FROM session_games
|
||||
WHERE session_id = ? AND id = ?
|
||||
@@ -498,6 +877,56 @@ router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update room code for a session game (admin only)
|
||||
router.patch('/:sessionId/games/:gameId/room-code', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { sessionId, gameId } = req.params;
|
||||
const { room_code } = req.body;
|
||||
|
||||
if (!room_code) {
|
||||
return res.status(400).json({ error: 'room_code is required' });
|
||||
}
|
||||
|
||||
// Validate room code format: 4 characters, A-Z and 0-9 only
|
||||
const roomCodeRegex = /^[A-Z0-9]{4}$/;
|
||||
if (!roomCodeRegex.test(room_code)) {
|
||||
return res.status(400).json({ error: 'room_code must be exactly 4 alphanumeric characters (A-Z, 0-9)' });
|
||||
}
|
||||
|
||||
// Update the room code
|
||||
const result = db.prepare(`
|
||||
UPDATE session_games
|
||||
SET room_code = ?
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).run(room_code, sessionId, gameId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: 'Session game not found' });
|
||||
}
|
||||
|
||||
// Return updated game data
|
||||
const updatedGame = db.prepare(`
|
||||
SELECT
|
||||
sg.*,
|
||||
g.pack_name,
|
||||
g.title,
|
||||
g.game_type,
|
||||
g.min_players,
|
||||
g.max_players,
|
||||
g.popularity_score,
|
||||
g.upvotes,
|
||||
g.downvotes
|
||||
FROM session_games sg
|
||||
JOIN games g ON sg.game_id = g.id
|
||||
WHERE sg.session_id = ? AND sg.id = ?
|
||||
`).get(sessionId, gameId);
|
||||
|
||||
res.json(updatedGame);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Export session data (plaintext and JSON)
|
||||
router.get('/:id/export', authenticateToken, (req, res) => {
|
||||
try {
|
||||
@@ -631,5 +1060,150 @@ router.get('/:id/export', authenticateToken, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get live game status from shard monitor or DB fallback
|
||||
router.get('/:sessionId/games/:gameId/status-live', (req, res) => {
|
||||
try {
|
||||
const { sessionId, gameId } = req.params;
|
||||
|
||||
const snapshot = getMonitorSnapshot(sessionId, gameId);
|
||||
if (snapshot) {
|
||||
return res.json(snapshot);
|
||||
}
|
||||
|
||||
const game = db.prepare(`
|
||||
SELECT
|
||||
sg.room_code,
|
||||
sg.player_count,
|
||||
sg.player_count_check_status,
|
||||
g.title,
|
||||
g.pack_name,
|
||||
g.max_players
|
||||
FROM session_games sg
|
||||
JOIN games g ON sg.game_id = g.id
|
||||
WHERE sg.session_id = ? AND sg.id = ?
|
||||
`).get(sessionId, gameId);
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Session game not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
sessionId: parseInt(sessionId, 10),
|
||||
gameId: parseInt(gameId, 10),
|
||||
roomCode: game.room_code,
|
||||
appTag: null,
|
||||
maxPlayers: game.max_players,
|
||||
playerCount: game.player_count,
|
||||
players: [],
|
||||
lobbyState: null,
|
||||
gameState: null,
|
||||
gameStarted: false,
|
||||
gameFinished: game.player_count_check_status === 'completed',
|
||||
monitoring: false,
|
||||
title: game.title,
|
||||
packName: game.pack_name,
|
||||
status: game.player_count_check_status,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Start player count check for a session game (admin only)
|
||||
router.post('/:sessionId/games/:gameId/start-player-check', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { sessionId, gameId } = req.params;
|
||||
|
||||
// Get the game to verify it exists and has a room code
|
||||
const game = db.prepare(`
|
||||
SELECT sg.*, g.max_players
|
||||
FROM session_games sg
|
||||
JOIN games g ON sg.game_id = g.id
|
||||
WHERE sg.session_id = ? AND sg.id = ?
|
||||
`).get(sessionId, gameId);
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Session game not found' });
|
||||
}
|
||||
|
||||
if (!game.room_code) {
|
||||
return res.status(400).json({ error: 'Game does not have a room code' });
|
||||
}
|
||||
|
||||
// Start room monitoring (will hand off to player count check when game starts)
|
||||
startMonitor(sessionId, gameId, game.room_code, game.max_players);
|
||||
|
||||
res.json({
|
||||
message: 'Room monitor started',
|
||||
status: 'monitoring'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Stop player count check for a session game (admin only)
|
||||
router.post('/:sessionId/games/:gameId/stop-player-check', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { sessionId, gameId } = req.params;
|
||||
|
||||
// Stop both room monitor and player count check
|
||||
stopMonitor(sessionId, gameId);
|
||||
|
||||
res.json({
|
||||
message: 'Room monitor and player count check stopped',
|
||||
status: 'stopped'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Manually update player count for a session game (admin only)
|
||||
router.patch('/:sessionId/games/:gameId/player-count', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { sessionId, gameId } = req.params;
|
||||
const { player_count } = req.body;
|
||||
|
||||
if (player_count === undefined || player_count === null) {
|
||||
return res.status(400).json({ error: 'player_count is required' });
|
||||
}
|
||||
|
||||
const count = parseInt(player_count);
|
||||
if (isNaN(count) || count < 0) {
|
||||
return res.status(400).json({ error: 'player_count must be a positive number' });
|
||||
}
|
||||
|
||||
// Update the player count
|
||||
const result = db.prepare(`
|
||||
UPDATE session_games
|
||||
SET player_count = ?, player_count_check_status = 'completed'
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).run(count, sessionId, gameId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: 'Session game not found' });
|
||||
}
|
||||
|
||||
// Broadcast via WebSocket
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('player-count.updated', {
|
||||
sessionId,
|
||||
gameId,
|
||||
playerCount: count,
|
||||
status: 'completed'
|
||||
}, parseInt(sessionId));
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Player count updated successfully',
|
||||
player_count: count
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
317
backend/routes/votes.js
Normal file
317
backend/routes/votes.js
Normal file
@@ -0,0 +1,317 @@
|
||||
const express = require('express');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const db = require('../database');
|
||||
const { getWebSocketManager } = require('../utils/websocket-manager');
|
||||
|
||||
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 {
|
||||
const { username, vote, timestamp } = req.body;
|
||||
|
||||
// Validate payload
|
||||
if (!username || !vote || !timestamp) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: username, vote, timestamp'
|
||||
});
|
||||
}
|
||||
|
||||
if (vote !== 'up' && vote !== 'down') {
|
||||
return res.status(400).json({
|
||||
error: 'vote must be either "up" or "down"'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate timestamp format
|
||||
const voteTimestamp = new Date(timestamp);
|
||||
if (isNaN(voteTimestamp.getTime())) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid timestamp format. Use ISO 8601 format (e.g., 2025-11-01T20:30:00Z)'
|
||||
});
|
||||
}
|
||||
|
||||
// Check for active session
|
||||
const activeSession = db.prepare(`
|
||||
SELECT * FROM sessions WHERE is_active = 1 LIMIT 1
|
||||
`).get();
|
||||
|
||||
if (!activeSession) {
|
||||
return res.status(404).json({
|
||||
error: 'No active session found'
|
||||
});
|
||||
}
|
||||
|
||||
// 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 (!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)
|
||||
// Get the most recent vote from this user
|
||||
const lastVote = db.prepare(`
|
||||
SELECT timestamp FROM live_votes
|
||||
WHERE username = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`).get(username);
|
||||
|
||||
if (lastVote) {
|
||||
const lastVoteTime = new Date(lastVote.timestamp).getTime();
|
||||
const currentVoteTime = new Date(timestamp).getTime();
|
||||
const timeDiffSeconds = Math.abs(currentVoteTime - lastVoteTime) / 1000;
|
||||
|
||||
if (timeDiffSeconds <= 1) {
|
||||
return res.status(409).json({
|
||||
error: 'Duplicate vote detected (within 1 second of previous vote)',
|
||||
message: 'Please wait at least 1 second between votes',
|
||||
timeSinceLastVote: timeDiffSeconds
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process the vote in a transaction
|
||||
const voteType = vote === 'up' ? 1 : -1;
|
||||
|
||||
const insertVote = db.prepare(`
|
||||
INSERT INTO live_votes (session_id, game_id, username, vote_type, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const updateUpvote = db.prepare(`
|
||||
UPDATE games
|
||||
SET upvotes = upvotes + 1, popularity_score = popularity_score + 1
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const updateDownvote = db.prepare(`
|
||||
UPDATE games
|
||||
SET downvotes = downvotes + 1, popularity_score = popularity_score - 1
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const processVote = db.transaction(() => {
|
||||
insertVote.run(activeSession.id, matchedGame.game_id, username, voteType, timestamp);
|
||||
|
||||
if (voteType === 1) {
|
||||
updateUpvote.run(matchedGame.game_id);
|
||||
} else {
|
||||
updateDownvote.run(matchedGame.game_id);
|
||||
}
|
||||
});
|
||||
|
||||
processVote();
|
||||
|
||||
// Verify the live_votes row was persisted (diagnostic for production debugging)
|
||||
const voteCheck = db.prepare(
|
||||
'SELECT id FROM live_votes WHERE session_id = ? AND game_id = ? AND username = ? AND timestamp = ?'
|
||||
).get(activeSession.id, matchedGame.game_id, username, timestamp);
|
||||
if (!voteCheck) {
|
||||
console.error('[votes] CRITICAL: live_votes INSERT committed but row not found', {
|
||||
session_id: activeSession.id, game_id: matchedGame.game_id, username, timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Get updated game stats
|
||||
const updatedGame = db.prepare(`
|
||||
SELECT id, title, upvotes, downvotes, popularity_score
|
||||
FROM games
|
||||
WHERE id = ?
|
||||
`).get(matchedGame.game_id);
|
||||
|
||||
// Broadcast vote.received via WebSocket
|
||||
try {
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('vote.received', {
|
||||
sessionId: activeSession.id,
|
||||
game: {
|
||||
id: updatedGame.id,
|
||||
title: updatedGame.title,
|
||||
pack_name: matchedGame.pack_name,
|
||||
},
|
||||
vote: {
|
||||
username: username,
|
||||
type: vote,
|
||||
timestamp: timestamp,
|
||||
},
|
||||
totals: {
|
||||
upvotes: updatedGame.upvotes,
|
||||
downvotes: updatedGame.downvotes,
|
||||
popularity_score: updatedGame.popularity_score,
|
||||
},
|
||||
}, activeSession.id);
|
||||
}
|
||||
} catch (wsError) {
|
||||
console.error('Error broadcasting vote.received event:', wsError);
|
||||
}
|
||||
|
||||
// Get session stats
|
||||
const sessionStats = db.prepare(`
|
||||
SELECT
|
||||
s.*,
|
||||
COUNT(sg.id) as games_played
|
||||
FROM sessions s
|
||||
LEFT JOIN session_games sg ON s.id = sg.session_id
|
||||
WHERE s.id = ?
|
||||
GROUP BY s.id
|
||||
`).get(activeSession.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Vote recorded successfully',
|
||||
session: {
|
||||
id: sessionStats.id,
|
||||
games_played: sessionStats.games_played
|
||||
},
|
||||
game: {
|
||||
id: updatedGame.id,
|
||||
title: updatedGame.title,
|
||||
upvotes: updatedGame.upvotes,
|
||||
downvotes: updatedGame.downvotes,
|
||||
popularity_score: updatedGame.popularity_score
|
||||
},
|
||||
vote: {
|
||||
username: username,
|
||||
type: vote,
|
||||
timestamp: timestamp
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing live vote:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
271
backend/routes/webhooks.js
Normal file
271
backend/routes/webhooks.js
Normal file
@@ -0,0 +1,271 @@
|
||||
const express = require('express');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const db = require('../database');
|
||||
const { triggerWebhook } = require('../utils/webhooks');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all webhooks (admin only)
|
||||
router.get('/', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const webhooks = db.prepare(`
|
||||
SELECT id, name, url, events, enabled, created_at
|
||||
FROM webhooks
|
||||
ORDER BY created_at DESC
|
||||
`).all();
|
||||
|
||||
// Parse events JSON for each webhook
|
||||
const webhooksWithParsedEvents = webhooks.map(webhook => ({
|
||||
...webhook,
|
||||
events: JSON.parse(webhook.events),
|
||||
enabled: webhook.enabled === 1
|
||||
}));
|
||||
|
||||
res.json(webhooksWithParsedEvents);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single webhook by ID (admin only)
|
||||
router.get('/:id', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const webhook = db.prepare(`
|
||||
SELECT id, name, url, events, enabled, created_at
|
||||
FROM webhooks
|
||||
WHERE id = ?
|
||||
`).get(req.params.id);
|
||||
|
||||
if (!webhook) {
|
||||
return res.status(404).json({ error: 'Webhook not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
...webhook,
|
||||
events: JSON.parse(webhook.events),
|
||||
enabled: webhook.enabled === 1
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Create new webhook (admin only)
|
||||
router.post('/', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { name, url, secret, events } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !url || !secret || !events) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: name, url, secret, events'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate events is an array
|
||||
if (!Array.isArray(events)) {
|
||||
return res.status(400).json({
|
||||
error: 'events must be an array'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: 'Invalid URL format' });
|
||||
}
|
||||
|
||||
// Insert webhook
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO webhooks (name, url, secret, events, enabled)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
`);
|
||||
|
||||
const result = stmt.run(name, url, secret, JSON.stringify(events));
|
||||
|
||||
const newWebhook = db.prepare(`
|
||||
SELECT id, name, url, events, enabled, created_at
|
||||
FROM webhooks
|
||||
WHERE id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
res.status(201).json({
|
||||
...newWebhook,
|
||||
events: JSON.parse(newWebhook.events),
|
||||
enabled: newWebhook.enabled === 1,
|
||||
message: 'Webhook created successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update webhook (admin only)
|
||||
router.patch('/:id', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { name, url, secret, events, enabled } = req.body;
|
||||
const webhookId = req.params.id;
|
||||
|
||||
// Check if webhook exists
|
||||
const webhook = db.prepare('SELECT * FROM webhooks WHERE id = ?').get(webhookId);
|
||||
|
||||
if (!webhook) {
|
||||
return res.status(404).json({ error: 'Webhook not found' });
|
||||
}
|
||||
|
||||
// Build update query dynamically based on provided fields
|
||||
const updates = [];
|
||||
const params = [];
|
||||
|
||||
if (name !== undefined) {
|
||||
updates.push('name = ?');
|
||||
params.push(name);
|
||||
}
|
||||
|
||||
if (url !== undefined) {
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: 'Invalid URL format' });
|
||||
}
|
||||
updates.push('url = ?');
|
||||
params.push(url);
|
||||
}
|
||||
|
||||
if (secret !== undefined) {
|
||||
updates.push('secret = ?');
|
||||
params.push(secret);
|
||||
}
|
||||
|
||||
if (events !== undefined) {
|
||||
if (!Array.isArray(events)) {
|
||||
return res.status(400).json({ error: 'events must be an array' });
|
||||
}
|
||||
updates.push('events = ?');
|
||||
params.push(JSON.stringify(events));
|
||||
}
|
||||
|
||||
if (enabled !== undefined) {
|
||||
updates.push('enabled = ?');
|
||||
params.push(enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update' });
|
||||
}
|
||||
|
||||
params.push(webhookId);
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE webhooks
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(...params);
|
||||
|
||||
const updatedWebhook = db.prepare(`
|
||||
SELECT id, name, url, events, enabled, created_at
|
||||
FROM webhooks
|
||||
WHERE id = ?
|
||||
`).get(webhookId);
|
||||
|
||||
res.json({
|
||||
...updatedWebhook,
|
||||
events: JSON.parse(updatedWebhook.events),
|
||||
enabled: updatedWebhook.enabled === 1,
|
||||
message: 'Webhook updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete webhook (admin only)
|
||||
router.delete('/:id', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const webhook = db.prepare('SELECT * FROM webhooks WHERE id = ?').get(req.params.id);
|
||||
|
||||
if (!webhook) {
|
||||
return res.status(404).json({ error: 'Webhook not found' });
|
||||
}
|
||||
|
||||
// Delete webhook (logs will be cascade deleted)
|
||||
db.prepare('DELETE FROM webhooks WHERE id = ?').run(req.params.id);
|
||||
|
||||
res.json({
|
||||
message: 'Webhook deleted successfully',
|
||||
webhookId: parseInt(req.params.id)
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Test webhook (admin only)
|
||||
router.post('/test/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const webhook = db.prepare('SELECT * FROM webhooks WHERE id = ?').get(req.params.id);
|
||||
|
||||
if (!webhook) {
|
||||
return res.status(404).json({ error: 'Webhook not found' });
|
||||
}
|
||||
|
||||
// Send a test payload
|
||||
const testData = {
|
||||
session: {
|
||||
id: 0,
|
||||
is_active: true,
|
||||
games_played: 0
|
||||
},
|
||||
game: {
|
||||
id: 0,
|
||||
title: 'Test Game',
|
||||
pack_name: 'Test Pack',
|
||||
min_players: 2,
|
||||
max_players: 8,
|
||||
manually_added: false
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger the webhook asynchronously
|
||||
triggerWebhook('game.added', testData);
|
||||
|
||||
res.json({
|
||||
message: 'Test webhook sent',
|
||||
note: 'Check webhook_logs table for delivery status'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get webhook logs (admin only)
|
||||
router.get('/:id/logs', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { limit = 50 } = req.query;
|
||||
|
||||
const logs = db.prepare(`
|
||||
SELECT *
|
||||
FROM webhook_logs
|
||||
WHERE webhook_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`).all(req.params.id, parseInt(limit));
|
||||
|
||||
// Parse payload JSON for each log
|
||||
const logsWithParsedPayload = logs.map(log => ({
|
||||
...log,
|
||||
payload: JSON.parse(log.payload)
|
||||
}));
|
||||
|
||||
res.json(logsWithParsedPayload);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const cors = require('cors');
|
||||
const { bootstrapGames } = require('./bootstrap');
|
||||
const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager');
|
||||
const { cleanupAllShards } = require('./utils/ecast-shard-client');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 5000;
|
||||
@@ -10,9 +13,6 @@ const PORT = process.env.PORT || 5000;
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Bootstrap database with games
|
||||
bootstrapGames();
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', message: 'Jackbox Game Picker API is running' });
|
||||
@@ -24,12 +24,16 @@ const gamesRoutes = require('./routes/games');
|
||||
const sessionsRoutes = require('./routes/sessions');
|
||||
const statsRoutes = require('./routes/stats');
|
||||
const pickerRoutes = require('./routes/picker');
|
||||
const votesRoutes = require('./routes/votes');
|
||||
const webhooksRoutes = require('./routes/webhooks');
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/games', gamesRoutes);
|
||||
app.use('/api/sessions', sessionsRoutes);
|
||||
app.use('/api/stats', statsRoutes);
|
||||
app.use('/api', pickerRoutes);
|
||||
app.use('/api/votes', votesRoutes);
|
||||
app.use('/api/webhooks', webhooksRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
@@ -37,7 +41,28 @@ app.use((err, req, res, next) => {
|
||||
res.status(500).json({ error: 'Something went wrong!', message: err.message });
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
});
|
||||
// Create HTTP server and attach WebSocket
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Initialize WebSocket Manager
|
||||
const wsManager = new WebSocketManager(server);
|
||||
setWebSocketManager(wsManager);
|
||||
|
||||
if (require.main === module) {
|
||||
bootstrapGames();
|
||||
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`);
|
||||
});
|
||||
|
||||
const shutdown = async () => {
|
||||
console.log('Shutting down gracefully...');
|
||||
await cleanupAllShards();
|
||||
server.close(() => process.exit(0));
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
}
|
||||
|
||||
module.exports = { app, server };
|
||||
|
||||
122
backend/test-websocket.js
Normal file
122
backend/test-websocket.js
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* WebSocket Test Client
|
||||
*
|
||||
* Tests the WebSocket event system for the Jackbox Game Picker API
|
||||
*
|
||||
* Usage:
|
||||
* JWT_TOKEN="your_token" node test-websocket.js
|
||||
*/
|
||||
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const API_URL = process.env.API_URL || 'ws://localhost:5000';
|
||||
const JWT_TOKEN = process.env.JWT_TOKEN || '';
|
||||
|
||||
if (!JWT_TOKEN) {
|
||||
console.error('\n❌ ERROR: JWT_TOKEN not set!');
|
||||
console.error('\nGet your token:');
|
||||
console.error(' curl -X POST "http://localhost:5000/api/auth/login" \\');
|
||||
console.error(' -H "Content-Type: application/json" \\');
|
||||
console.error(' -d \'{"key":"YOUR_ADMIN_KEY"}\'');
|
||||
console.error('\nThen run:');
|
||||
console.error(' JWT_TOKEN="your_token" node test-websocket.js\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n🚀 WebSocket Test Client');
|
||||
console.log('═══════════════════════════════════════════════════════\n');
|
||||
console.log(`Connecting to: ${API_URL}/api/sessions/live`);
|
||||
console.log('');
|
||||
|
||||
const ws = new WebSocket(`${API_URL}/api/sessions/live`);
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('✅ Connected to WebSocket server\n');
|
||||
|
||||
// Step 1: Authenticate
|
||||
console.log('📝 Step 1: Authenticating...');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'auth',
|
||||
token: JWT_TOKEN
|
||||
}));
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
switch (message.type) {
|
||||
case 'auth_success':
|
||||
console.log('✅ Authentication successful\n');
|
||||
|
||||
// Step 2: Subscribe to session (you can change this ID)
|
||||
console.log('📝 Step 2: Subscribing to session 1...');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
sessionId: 1
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'auth_error':
|
||||
console.error('❌ Authentication failed:', message.message);
|
||||
process.exit(1);
|
||||
break;
|
||||
|
||||
case 'subscribed':
|
||||
console.log(`✅ Subscribed to session ${message.sessionId}\n`);
|
||||
console.log('🎧 Listening for events...');
|
||||
console.log(' Add a game in the Picker page to see events here');
|
||||
console.log(' Press Ctrl+C to exit\n');
|
||||
|
||||
// Start heartbeat
|
||||
setInterval(() => {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}, 30000);
|
||||
break;
|
||||
|
||||
case 'game.added':
|
||||
console.log('\n🎮 GAME ADDED EVENT RECEIVED!');
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log('Game:', message.data.game.title);
|
||||
console.log('Pack:', message.data.game.pack_name);
|
||||
console.log('Players:', `${message.data.game.min_players}-${message.data.game.max_players}`);
|
||||
console.log('Session ID:', message.data.session.id);
|
||||
console.log('Games Played:', message.data.session.games_played);
|
||||
console.log('Timestamp:', message.timestamp);
|
||||
console.log('═══════════════════════════════════════════════════════\n');
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
console.log('💓 Heartbeat');
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('❌ Error:', message.message);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('📨 Received:', message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse message:', err);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('\n❌ WebSocket error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('\n👋 Connection closed');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Handle Ctrl+C
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n\n⚠️ Closing connection...');
|
||||
ws.close();
|
||||
});
|
||||
|
||||
710
backend/utils/ecast-shard-client.js
Normal file
710
backend/utils/ecast-shard-client.js
Normal file
@@ -0,0 +1,710 @@
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const db = require('../database');
|
||||
const { getWebSocketManager } = require('./websocket-manager');
|
||||
const { getRoomInfo } = require('./jackbox-api');
|
||||
|
||||
class EcastShardClient {
|
||||
static parsePlayersFromHere(here) {
|
||||
if (here == null || typeof here !== 'object') {
|
||||
return { playerCount: 0, playerNames: [] };
|
||||
}
|
||||
const names = [];
|
||||
const keys = Object.keys(here).sort((a, b) => Number(a) - Number(b));
|
||||
for (const key of keys) {
|
||||
const conn = here[key];
|
||||
if (conn?.roles?.player) {
|
||||
names.push(conn.roles.player.name ?? '');
|
||||
}
|
||||
}
|
||||
return { playerCount: names.length, playerNames: names };
|
||||
}
|
||||
|
||||
static parseRoomEntity(roomVal) {
|
||||
if (roomVal == null || typeof roomVal !== 'object') {
|
||||
return {
|
||||
gameState: null,
|
||||
lobbyState: null,
|
||||
gameCanStart: false,
|
||||
gameIsStarting: false,
|
||||
gameStarted: false,
|
||||
gameFinished: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
gameState: roomVal.state ?? null,
|
||||
lobbyState: roomVal.lobbyState ?? null,
|
||||
gameCanStart: !!roomVal.gameCanStart,
|
||||
gameIsStarting: !!roomVal.gameIsStarting,
|
||||
gameStarted: roomVal.state === 'Gameplay',
|
||||
gameFinished: !!roomVal.gameFinished,
|
||||
};
|
||||
}
|
||||
|
||||
static parsePlayerJoinFromTextDescriptions(val) {
|
||||
if (val == null || typeof val !== 'object') {
|
||||
return [];
|
||||
}
|
||||
const latest = val.latestDescriptions;
|
||||
if (!Array.isArray(latest)) {
|
||||
return [];
|
||||
}
|
||||
const out = [];
|
||||
for (const desc of latest) {
|
||||
if (!desc || typeof desc !== 'object') continue;
|
||||
const { category, text } = desc;
|
||||
if (category !== 'TEXT_DESCRIPTION_PLAYER_JOINED' && category !== 'TEXT_DESCRIPTION_PLAYER_JOINED_VIP') {
|
||||
continue;
|
||||
}
|
||||
if (typeof text !== 'string') continue;
|
||||
const joinedIdx = text.indexOf(' joined');
|
||||
if (joinedIdx === -1) continue;
|
||||
const before = text.slice(0, joinedIdx).trim();
|
||||
const name = before.split(/\s+/)[0] || before;
|
||||
out.push({
|
||||
name,
|
||||
isVIP: category === 'TEXT_DESCRIPTION_PLAYER_JOINED_VIP',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
constructor({ sessionId, gameId, roomCode, maxPlayers, onEvent }) {
|
||||
this.sessionId = sessionId;
|
||||
this.gameId = gameId;
|
||||
this.roomCode = roomCode;
|
||||
this.maxPlayers = maxPlayers;
|
||||
this.onEvent = onEvent || (() => {});
|
||||
|
||||
this.ws = null;
|
||||
this.shardId = null;
|
||||
this.secret = null;
|
||||
this.host = null;
|
||||
this.playerCount = 0;
|
||||
this.playerNames = [];
|
||||
this.lobbyState = null;
|
||||
this.gameState = null;
|
||||
this.gameStarted = false;
|
||||
this.gameFinished = false;
|
||||
this.manuallyStopped = false;
|
||||
this.seq = 0;
|
||||
this.appTag = null;
|
||||
this.reconnecting = false;
|
||||
this.statusInterval = null;
|
||||
}
|
||||
|
||||
getSnapshot() {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
appTag: this.appTag,
|
||||
maxPlayers: this.maxPlayers,
|
||||
playerCount: this.playerCount,
|
||||
players: [...this.playerNames],
|
||||
lobbyState: this.lobbyState,
|
||||
gameState: this.gameState,
|
||||
gameStarted: this.gameStarted,
|
||||
gameFinished: this.gameFinished,
|
||||
monitoring: true,
|
||||
};
|
||||
}
|
||||
|
||||
startStatusBroadcast() {
|
||||
this.stopStatusBroadcast();
|
||||
this.statusInterval = setInterval(() => {
|
||||
this._refreshPlayerCount().finally(() => {
|
||||
if (!this.manuallyStopped && !this.gameFinished) {
|
||||
this.onEvent('game.status', this.getSnapshot());
|
||||
}
|
||||
});
|
||||
}, 20000);
|
||||
}
|
||||
|
||||
_refreshPlayerCount() {
|
||||
if (!this.host || this.gameFinished || this.manuallyStopped) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const url = `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePickerProbe&format=json`;
|
||||
let resolved = false;
|
||||
let welcomed = false;
|
||||
const done = (probe) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
if (probe) {
|
||||
try { probe.removeAllListeners(); probe.terminate(); } catch (_) {}
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const probe = new WebSocket(url, ['ecast-v0'], {
|
||||
headers: { Origin: 'https://jackbox.tv' },
|
||||
handshakeTimeout: 8000,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => done(probe), 10000);
|
||||
|
||||
probe.on('message', (data) => {
|
||||
if (welcomed || this.manuallyStopped) { clearTimeout(timeout); done(probe); return; }
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.opcode === 'client/welcome') {
|
||||
welcomed = true;
|
||||
const { playerCount, playerNames } = EcastShardClient.parsePlayersFromHere(msg.result.here);
|
||||
if (playerCount > this.playerCount || playerNames.length !== this.playerNames.length) {
|
||||
this.playerCount = playerCount;
|
||||
this.playerNames = playerNames;
|
||||
if (!this.manuallyStopped) {
|
||||
this.onEvent('lobby.player-joined', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
playerName: playerNames[playerNames.length - 1] || '',
|
||||
playerCount,
|
||||
players: [...playerNames],
|
||||
maxPlayers: this.maxPlayers,
|
||||
});
|
||||
}
|
||||
} else if (playerCount !== this.playerCount) {
|
||||
this.playerCount = playerCount;
|
||||
this.playerNames = playerNames;
|
||||
}
|
||||
} else if (msg.opcode === 'error' && msg.result?.code === 2027) {
|
||||
this.gameFinished = true;
|
||||
}
|
||||
} catch (_) {}
|
||||
clearTimeout(timeout);
|
||||
done(probe);
|
||||
});
|
||||
|
||||
probe.on('error', () => { clearTimeout(timeout); done(probe); });
|
||||
probe.on('close', () => { clearTimeout(timeout); done(null); });
|
||||
} catch (_) {
|
||||
done(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stopStatusBroadcast() {
|
||||
if (this.statusInterval) {
|
||||
clearInterval(this.statusInterval);
|
||||
this.statusInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
buildReconnectUrl() {
|
||||
return `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&format=json&secret=${this.secret}&id=${this.shardId}`;
|
||||
}
|
||||
|
||||
handleMessage(message) {
|
||||
if (this.manuallyStopped || this.gameFinished) return;
|
||||
switch (message.opcode) {
|
||||
case 'client/welcome':
|
||||
this.handleWelcome(message.result);
|
||||
break;
|
||||
case 'object':
|
||||
this.handleEntityUpdate(message.result);
|
||||
break;
|
||||
case 'client/connected':
|
||||
this.handleClientConnected(message.result);
|
||||
break;
|
||||
case 'client/disconnected':
|
||||
break;
|
||||
case 'error':
|
||||
this.handleError(message.result);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleWelcome(result) {
|
||||
this.shardId = result.id;
|
||||
this.secret = result.secret;
|
||||
|
||||
const { playerCount, playerNames } = EcastShardClient.parsePlayersFromHere(result.here);
|
||||
this.playerCount = playerCount;
|
||||
this.playerNames = playerNames;
|
||||
|
||||
const roomEntity = result.entities?.room || result.entities?.['bc:room'];
|
||||
if (roomEntity) {
|
||||
const roomVal = Array.isArray(roomEntity) ? roomEntity[1]?.val : roomEntity.val;
|
||||
if (roomVal) {
|
||||
const roomState = EcastShardClient.parseRoomEntity(roomVal);
|
||||
this.lobbyState = roomState.lobbyState;
|
||||
this.gameState = roomState.gameState;
|
||||
this.gameStarted = roomState.gameStarted;
|
||||
this.gameFinished = roomState.gameFinished;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Shard Monitor] Welcome: id=${this.shardId}, players=${this.playerCount} [${this.playerNames.join(', ')}], state=${this.gameState}, lobby=${this.lobbyState}`
|
||||
);
|
||||
|
||||
this.onEvent('room.connected', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
appTag: this.appTag,
|
||||
maxPlayers: this.maxPlayers,
|
||||
playerCount: this.playerCount,
|
||||
players: [...this.playerNames],
|
||||
lobbyState: this.lobbyState,
|
||||
gameState: this.gameState,
|
||||
});
|
||||
|
||||
this.startStatusBroadcast();
|
||||
}
|
||||
|
||||
handleEntityUpdate(result) {
|
||||
if (!result?.key) return;
|
||||
|
||||
if (result.key === 'room' || result.key === 'bc:room') {
|
||||
if (result.val) {
|
||||
const prevLobbyState = this.lobbyState;
|
||||
const prevGameStarted = this.gameStarted;
|
||||
const prevGameFinished = this.gameFinished;
|
||||
|
||||
const roomState = EcastShardClient.parseRoomEntity(result.val);
|
||||
this.lobbyState = roomState.lobbyState;
|
||||
this.gameState = roomState.gameState;
|
||||
this.gameStarted = roomState.gameStarted;
|
||||
this.gameFinished = roomState.gameFinished;
|
||||
|
||||
if (this.lobbyState !== prevLobbyState && !this.gameStarted) {
|
||||
this.onEvent('lobby.updated', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
lobbyState: this.lobbyState,
|
||||
gameCanStart: roomState.gameCanStart,
|
||||
gameIsStarting: roomState.gameIsStarting,
|
||||
playerCount: this.playerCount,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.gameStarted && !prevGameStarted) {
|
||||
this.onEvent('game.started', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
playerCount: this.playerCount,
|
||||
players: [...this.playerNames],
|
||||
maxPlayers: this.maxPlayers,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.gameFinished && !prevGameFinished) {
|
||||
this.onEvent('game.ended', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
playerCount: this.playerCount,
|
||||
players: [...this.playerNames],
|
||||
});
|
||||
this.onEvent('room.disconnected', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
reason: 'room_closed',
|
||||
finalPlayerCount: this.playerCount,
|
||||
});
|
||||
activeShards.delete(`${this.sessionId}-${this.gameId}`);
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.key === 'textDescriptions') {
|
||||
if (result.val) {
|
||||
const joins = EcastShardClient.parsePlayerJoinFromTextDescriptions(result.val);
|
||||
for (const join of joins) {
|
||||
if (!this.playerNames.includes(join.name)) {
|
||||
this.playerNames.push(join.name);
|
||||
this.playerCount = this.playerNames.length;
|
||||
|
||||
this.onEvent('lobby.player-joined', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
playerName: join.name,
|
||||
playerCount: this.playerCount,
|
||||
players: [...this.playerNames],
|
||||
maxPlayers: this.maxPlayers,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleClientConnected(result) {
|
||||
if (!result) return;
|
||||
if (result.roles?.player) {
|
||||
const name = result.roles.player.name ?? '';
|
||||
if (!this.playerNames.includes(name)) {
|
||||
this.playerNames.push(name);
|
||||
this.playerCount = this.playerNames.length;
|
||||
|
||||
this.onEvent('lobby.player-joined', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
playerName: name,
|
||||
playerCount: this.playerCount,
|
||||
players: [...this.playerNames],
|
||||
maxPlayers: this.maxPlayers,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleError(result) {
|
||||
console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`);
|
||||
if (result?.code === 2027) {
|
||||
this.gameFinished = true;
|
||||
this.onEvent('game.ended', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
playerCount: this.playerCount,
|
||||
players: [...this.playerNames],
|
||||
});
|
||||
this.onEvent('room.disconnected', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
reason: 'room_closed',
|
||||
finalPlayerCount: this.playerCount,
|
||||
});
|
||||
activeShards.delete(`${this.sessionId}-${this.gameId}`);
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
_openWebSocket(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let welcomeTimeoutId = null;
|
||||
|
||||
const cleanupWelcomeTimeout = () => {
|
||||
if (welcomeTimeoutId != null) {
|
||||
clearTimeout(welcomeTimeoutId);
|
||||
welcomeTimeoutId = null;
|
||||
}
|
||||
};
|
||||
|
||||
this.ws = new WebSocket(url, ['ecast-v0'], {
|
||||
headers: { Origin: 'https://jackbox.tv' },
|
||||
handshakeTimeout: 10000,
|
||||
});
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log(`[Shard Monitor] Connected to room ${this.roomCode}`);
|
||||
});
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
this.handleMessage(message);
|
||||
if (message.opcode === 'client/welcome') {
|
||||
cleanupWelcomeTimeout();
|
||||
resolve();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Shard Monitor] Failed to parse message:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
cleanupWelcomeTimeout();
|
||||
console.error(`[Shard Monitor] WebSocket error for room ${this.roomCode}:`, err.message);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
const thisWs = this.ws;
|
||||
this.ws.on('close', (code, reason) => {
|
||||
console.log(`[Shard Monitor] Disconnected from room ${this.roomCode} (code: ${code})`);
|
||||
if (this.ws === thisWs) {
|
||||
this.ws = null;
|
||||
if (!this.manuallyStopped && !this.gameFinished && this.secret != null && this.host != null) {
|
||||
void this.reconnectWithBackoff();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
welcomeTimeoutId = setTimeout(() => {
|
||||
welcomeTimeoutId = null;
|
||||
if (!this.shardId) {
|
||||
reject(new Error('Timeout waiting for client/welcome'));
|
||||
this.disconnect();
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
}
|
||||
|
||||
async connect(roomInfo, reconnectUrl) {
|
||||
this.disconnect();
|
||||
this.shardId = null;
|
||||
this.secret = null;
|
||||
this.host = roomInfo.host;
|
||||
this.maxPlayers = roomInfo.maxPlayers || this.maxPlayers;
|
||||
this.appTag = roomInfo.appTag;
|
||||
|
||||
const url =
|
||||
reconnectUrl ||
|
||||
`wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&userId=gamepicker-${this.sessionId}&format=json`;
|
||||
|
||||
return this._openWebSocket(url);
|
||||
}
|
||||
|
||||
async reconnect() {
|
||||
const url = this.buildReconnectUrl();
|
||||
this.disconnect();
|
||||
this.shardId = null;
|
||||
return this._openWebSocket(url);
|
||||
}
|
||||
|
||||
async reconnectWithBackoff() {
|
||||
if (this.reconnecting || this.manuallyStopped || this.gameFinished) {
|
||||
return false;
|
||||
}
|
||||
this.reconnecting = true;
|
||||
const delays = [2000, 4000, 8000];
|
||||
|
||||
try {
|
||||
for (let i = 0; i < delays.length; i++) {
|
||||
await new Promise((r) => setTimeout(r, delays[i]));
|
||||
|
||||
const roomInfo = await getRoomInfo(this.roomCode);
|
||||
|
||||
if (!roomInfo.exists) {
|
||||
this.gameFinished = true;
|
||||
this.onEvent('game.ended', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
playerCount: this.playerCount,
|
||||
players: [...this.playerNames],
|
||||
});
|
||||
this.onEvent('room.disconnected', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
reason: 'room_closed',
|
||||
finalPlayerCount: this.playerCount,
|
||||
});
|
||||
activeShards.delete(`${this.sessionId}-${this.gameId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.reconnect();
|
||||
console.log(`[Shard Monitor] Reconnected to room ${this.roomCode} (attempt ${i + 1})`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`[Shard Monitor] Reconnect attempt ${i + 1} failed:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
this.onEvent('room.disconnected', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
reason: 'connection_failed',
|
||||
finalPlayerCount: this.playerCount,
|
||||
});
|
||||
activeShards.delete(`${this.sessionId}-${this.gameId}`);
|
||||
return false;
|
||||
} finally {
|
||||
this.reconnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.stopStatusBroadcast();
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close(1000, 'Monitor stopped');
|
||||
} catch (e) {
|
||||
// Ignore close errors
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(opcode, params = {}) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.seq++;
|
||||
this.ws.send(JSON.stringify({ seq: this.seq, opcode, params }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeShards = new Map();
|
||||
|
||||
function broadcastAndPersist(sessionId, gameId) {
|
||||
return (eventType, eventData) => {
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent(eventType, eventData, parseInt(sessionId, 10));
|
||||
}
|
||||
|
||||
if (['room.connected', 'lobby.player-joined', 'game.started', 'game.ended'].includes(eventType)) {
|
||||
const checkStatus = eventType === 'game.ended' ? 'completed' : 'monitoring';
|
||||
try {
|
||||
if (eventType === 'game.ended') {
|
||||
db.prepare(
|
||||
'UPDATE session_games SET player_count = ?, player_count_check_status = ?, status = ? WHERE session_id = ? AND id = ?'
|
||||
).run(eventData.playerCount ?? null, checkStatus, 'played', sessionId, gameId);
|
||||
} else {
|
||||
db.prepare(
|
||||
'UPDATE session_games SET player_count = ?, player_count_check_status = ? WHERE session_id = ? AND id = ?'
|
||||
).run(eventData.playerCount ?? null, checkStatus, sessionId, gameId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Shard Monitor] DB update failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType === 'room.disconnected') {
|
||||
const reason = eventData.reason;
|
||||
const checkStatus =
|
||||
reason === 'room_closed' ? 'completed' : reason === 'manually_stopped' ? 'stopped' : 'failed';
|
||||
try {
|
||||
const game = db
|
||||
.prepare('SELECT player_count_check_status, status FROM session_games WHERE session_id = ? AND id = ?')
|
||||
.get(sessionId, gameId);
|
||||
if (game && game.player_count_check_status !== 'completed') {
|
||||
db.prepare('UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?').run(
|
||||
checkStatus,
|
||||
sessionId,
|
||||
gameId
|
||||
);
|
||||
}
|
||||
if (game && reason === 'room_closed' && game.status === 'playing') {
|
||||
db.prepare('UPDATE session_games SET status = ? WHERE session_id = ? AND id = ?').run(
|
||||
'played',
|
||||
sessionId,
|
||||
gameId
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Shard Monitor] DB update failed:', e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function startMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
|
||||
const monitorKey = `${sessionId}-${gameId}`;
|
||||
|
||||
if (activeShards.has(monitorKey)) {
|
||||
console.log(`[Shard Monitor] Already monitoring ${monitorKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Shard Monitor] Starting monitor for room ${roomCode} (${monitorKey})`);
|
||||
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
if (!roomInfo.exists) {
|
||||
console.log(`[Shard Monitor] Room ${roomCode} not found`);
|
||||
const onEvent = broadcastAndPersist(sessionId, gameId);
|
||||
onEvent('room.disconnected', {
|
||||
sessionId,
|
||||
gameId,
|
||||
roomCode,
|
||||
reason: 'room_not_found',
|
||||
finalPlayerCount: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const onEvent = broadcastAndPersist(sessionId, gameId);
|
||||
|
||||
try {
|
||||
db.prepare('UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?').run(
|
||||
'monitoring',
|
||||
sessionId,
|
||||
gameId
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[Shard Monitor] DB update failed:', e.message);
|
||||
}
|
||||
|
||||
const client = new EcastShardClient({
|
||||
sessionId,
|
||||
gameId,
|
||||
roomCode,
|
||||
maxPlayers: roomInfo.maxPlayers || maxPlayers,
|
||||
onEvent,
|
||||
});
|
||||
|
||||
activeShards.set(monitorKey, client);
|
||||
|
||||
try {
|
||||
await client.connect(roomInfo);
|
||||
} catch (e) {
|
||||
console.error(`[Shard Monitor] Failed to connect to room ${roomCode}:`, e.message);
|
||||
activeShards.delete(monitorKey);
|
||||
onEvent('room.disconnected', {
|
||||
sessionId,
|
||||
gameId,
|
||||
roomCode,
|
||||
reason: 'connection_failed',
|
||||
finalPlayerCount: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function stopMonitor(sessionId, gameId) {
|
||||
const monitorKey = `${sessionId}-${gameId}`;
|
||||
const client = activeShards.get(monitorKey);
|
||||
|
||||
if (client) {
|
||||
client.manuallyStopped = true;
|
||||
client.disconnect();
|
||||
activeShards.delete(monitorKey);
|
||||
|
||||
const game = db
|
||||
.prepare('SELECT player_count_check_status FROM session_games WHERE session_id = ? AND id = ?')
|
||||
.get(sessionId, gameId);
|
||||
|
||||
if (game && game.player_count_check_status !== 'completed' && game.player_count_check_status !== 'failed') {
|
||||
db.prepare('UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?').run(
|
||||
'stopped',
|
||||
sessionId,
|
||||
gameId
|
||||
);
|
||||
}
|
||||
|
||||
client.onEvent('room.disconnected', {
|
||||
sessionId,
|
||||
gameId,
|
||||
roomCode: client.roomCode,
|
||||
reason: 'manually_stopped',
|
||||
finalPlayerCount: client.playerCount,
|
||||
});
|
||||
|
||||
console.log(`[Shard Monitor] Stopped monitor for ${monitorKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupAllShards() {
|
||||
for (const [, client] of activeShards) {
|
||||
client.manuallyStopped = true;
|
||||
client.disconnect();
|
||||
}
|
||||
activeShards.clear();
|
||||
console.log('[Shard Monitor] Cleaned up all active shards');
|
||||
}
|
||||
|
||||
function getMonitorSnapshot(sessionId, gameId) {
|
||||
const client = activeShards.get(`${sessionId}-${gameId}`);
|
||||
return client ? client.getSnapshot() : null;
|
||||
}
|
||||
|
||||
module.exports = { EcastShardClient, startMonitor, stopMonitor, cleanupAllShards, getMonitorSnapshot };
|
||||
75
backend/utils/jackbox-api.js
Normal file
75
backend/utils/jackbox-api.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const JACKBOX_API_BASE = 'https://ecast.jackboxgames.com/api/v2';
|
||||
|
||||
const DEFAULT_HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; GamePicker/1.0)'
|
||||
};
|
||||
|
||||
/**
|
||||
* Check room status via the Jackbox ecast REST API.
|
||||
* Shared by room-monitor (polling for lock) and player-count-checker (room existence).
|
||||
*/
|
||||
async function checkRoomStatus(roomCode) {
|
||||
try {
|
||||
const response = await fetch(`${JACKBOX_API_BASE}/rooms/${roomCode}`, {
|
||||
headers: DEFAULT_HEADERS
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(`[Jackbox API] Room ${roomCode}: HTTP ${response.status}`);
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const roomData = data.body || data;
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.log('[Jackbox API] Room data:', JSON.stringify(roomData, null, 2));
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
locked: roomData.locked || false,
|
||||
full: roomData.full || false,
|
||||
maxPlayers: roomData.maxPlayers || 8,
|
||||
minPlayers: roomData.minPlayers || 0
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`[Jackbox API] Error checking room ${roomCode}:`, e.message);
|
||||
return { exists: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function getRoomInfo(roomCode) {
|
||||
try {
|
||||
const response = await fetch(`${JACKBOX_API_BASE}/rooms/${roomCode}`, {
|
||||
headers: DEFAULT_HEADERS
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(`[Jackbox API] Room ${roomCode}: HTTP ${response.status}`);
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const body = data.body || data;
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
host: body.host,
|
||||
audienceHost: body.audienceHost,
|
||||
appTag: body.appTag,
|
||||
appId: body.appId,
|
||||
code: body.code,
|
||||
locked: body.locked || false,
|
||||
full: body.full || false,
|
||||
maxPlayers: body.maxPlayers || 8,
|
||||
minPlayers: body.minPlayers || 0,
|
||||
audienceEnabled: body.audienceEnabled || false,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`[Jackbox API] Error getting room info for ${roomCode}:`, e.message);
|
||||
return { exists: false };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkRoomStatus, getRoomInfo };
|
||||
26
backend/utils/notes-preview.js
Normal file
26
backend/utils/notes-preview.js
Normal file
@@ -0,0 +1,26 @@
|
||||
function computeNotesPreview(notes) {
|
||||
if (!notes || notes.trim() === '') {
|
||||
return { has_notes: false, notes_preview: null };
|
||||
}
|
||||
|
||||
const firstParagraph = notes.split(/\n\n/)[0];
|
||||
|
||||
const stripped = firstParagraph
|
||||
.replace(/^#{1,6}\s+/gm, '') // headers
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1') // bold
|
||||
.replace(/\*(.+?)\*/g, '$1') // italic with *
|
||||
.replace(/_(.+?)_/g, '$1') // italic with _
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links
|
||||
.replace(/^[-*+]\s+/gm, '') // list markers
|
||||
.replace(/\n/g, ' ') // collapse remaining newlines
|
||||
.replace(/\s+/g, ' ') // collapse whitespace
|
||||
.trim();
|
||||
|
||||
const truncated = stripped.length > 150
|
||||
? stripped.slice(0, 150) + '...'
|
||||
: stripped;
|
||||
|
||||
return { has_notes: true, notes_preview: truncated };
|
||||
}
|
||||
|
||||
module.exports = { computeNotesPreview };
|
||||
151
backend/utils/webhooks.js
Normal file
151
backend/utils/webhooks.js
Normal file
@@ -0,0 +1,151 @@
|
||||
const crypto = require('crypto');
|
||||
const db = require('../database');
|
||||
|
||||
/**
|
||||
* Trigger webhooks for a specific event type
|
||||
* @param {string} eventType - The event type (e.g., 'game.added')
|
||||
* @param {object} data - The payload data to send
|
||||
*/
|
||||
async function triggerWebhook(eventType, data) {
|
||||
try {
|
||||
// Get all enabled webhooks that are subscribed to this event
|
||||
const webhooks = db.prepare(`
|
||||
SELECT * FROM webhooks
|
||||
WHERE enabled = 1
|
||||
`).all();
|
||||
|
||||
if (webhooks.length === 0) {
|
||||
return; // No webhooks configured
|
||||
}
|
||||
|
||||
// Filter webhooks that are subscribed to this event
|
||||
const subscribedWebhooks = webhooks.filter(webhook => {
|
||||
try {
|
||||
const events = JSON.parse(webhook.events);
|
||||
return events.includes(eventType);
|
||||
} catch (err) {
|
||||
console.error(`Invalid events JSON for webhook ${webhook.id}:`, err);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (subscribedWebhooks.length === 0) {
|
||||
return; // No webhooks subscribed to this event
|
||||
}
|
||||
|
||||
// Build the payload
|
||||
const payload = {
|
||||
event: eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: data
|
||||
};
|
||||
|
||||
// Send to each webhook asynchronously (non-blocking)
|
||||
subscribedWebhooks.forEach(webhook => {
|
||||
sendWebhook(webhook, payload, eventType).catch(err => {
|
||||
console.error(`Error sending webhook ${webhook.id}:`, err);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error triggering webhooks:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a webhook to a specific URL
|
||||
* @param {object} webhook - The webhook configuration
|
||||
* @param {object} payload - The payload to send
|
||||
* @param {string} eventType - The event type
|
||||
*/
|
||||
async function sendWebhook(webhook, payload, eventType) {
|
||||
const payloadString = JSON.stringify(payload);
|
||||
|
||||
// Generate HMAC signature
|
||||
const signature = 'sha256=' + crypto
|
||||
.createHmac('sha256', webhook.secret)
|
||||
.update(payloadString)
|
||||
.digest('hex');
|
||||
|
||||
const startTime = Date.now();
|
||||
let responseStatus = null;
|
||||
let errorMessage = null;
|
||||
|
||||
try {
|
||||
// Send the webhook
|
||||
const response = await fetch(webhook.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Signature': signature,
|
||||
'X-Webhook-Event': eventType,
|
||||
'User-Agent': 'Jackbox-Game-Picker-Webhook/1.0'
|
||||
},
|
||||
body: payloadString,
|
||||
// Set a timeout of 5 seconds
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
responseStatus = response.status;
|
||||
|
||||
if (!response.ok) {
|
||||
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
errorMessage = err.message;
|
||||
responseStatus = 0; // Indicates connection/network error
|
||||
}
|
||||
|
||||
// Log the webhook call
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO webhook_logs (webhook_id, event_type, payload, response_status, error_message)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(webhook.id, eventType, payloadString, responseStatus, errorMessage);
|
||||
} catch (logErr) {
|
||||
console.error('Error logging webhook call:', logErr);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (errorMessage) {
|
||||
console.error(`Webhook ${webhook.id} (${webhook.name}) failed: ${errorMessage} (${duration}ms)`);
|
||||
} else {
|
||||
console.log(`Webhook ${webhook.id} (${webhook.name}) sent successfully: ${responseStatus} (${duration}ms)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a webhook signature
|
||||
* @param {string} signature - The signature from the X-Webhook-Signature header
|
||||
* @param {string} payload - The raw request body as a string
|
||||
* @param {string} secret - The webhook secret
|
||||
* @returns {boolean} - True if signature is valid
|
||||
*/
|
||||
function verifyWebhookSignature(signature, payload, secret) {
|
||||
if (!signature || !signature.startsWith('sha256=')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedSignature = 'sha256=' + crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
|
||||
// Use timing-safe comparison to prevent timing attacks
|
||||
try {
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature)
|
||||
);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
triggerWebhook,
|
||||
verifyWebhookSignature
|
||||
};
|
||||
|
||||
372
backend/utils/websocket-manager.js
Normal file
372
backend/utils/websocket-manager.js
Normal file
@@ -0,0 +1,372 @@
|
||||
const { WebSocketServer } = require('ws');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { JWT_SECRET } = require('../middleware/auth');
|
||||
|
||||
/**
|
||||
* WebSocket Manager for handling real-time session events
|
||||
* Manages client connections, authentication, and event broadcasting
|
||||
*/
|
||||
class WebSocketManager {
|
||||
constructor(server) {
|
||||
this.wss = new WebSocketServer({
|
||||
server,
|
||||
path: '/api/sessions/live'
|
||||
});
|
||||
|
||||
this.clients = new Map(); // Map<ws, clientInfo>
|
||||
this.sessionSubscriptions = new Map(); // Map<sessionId, Set<ws>>
|
||||
|
||||
this.wss.on('connection', (ws, req) => this.handleConnection(ws, req));
|
||||
this.startHeartbeat();
|
||||
|
||||
console.log('[WebSocket] WebSocket server initialized on /api/sessions/live');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new WebSocket connection
|
||||
*/
|
||||
handleConnection(ws, req) {
|
||||
console.log('[WebSocket] New connection from', req.socket.remoteAddress);
|
||||
|
||||
// Initialize client info
|
||||
const clientInfo = {
|
||||
authenticated: false,
|
||||
userId: null,
|
||||
adminName: null,
|
||||
currentPage: null,
|
||||
subscribedSessions: new Set(),
|
||||
lastPing: Date.now()
|
||||
};
|
||||
|
||||
this.clients.set(ws, clientInfo);
|
||||
|
||||
// Handle incoming messages
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
this.handleMessage(ws, message);
|
||||
} catch (err) {
|
||||
console.error('[WebSocket] Failed to parse message:', err);
|
||||
this.sendError(ws, 'Invalid message format');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle connection close
|
||||
ws.on('close', () => {
|
||||
this.removeClient(ws);
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
ws.on('error', (err) => {
|
||||
console.error('[WebSocket] Client error:', err);
|
||||
this.removeClient(ws);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming messages from clients
|
||||
*/
|
||||
handleMessage(ws, message) {
|
||||
const clientInfo = this.clients.get(ws);
|
||||
|
||||
if (!clientInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'auth':
|
||||
this.authenticateClient(ws, message.token);
|
||||
break;
|
||||
|
||||
case 'subscribe':
|
||||
if (!clientInfo.authenticated) {
|
||||
this.sendError(ws, 'Not authenticated');
|
||||
return;
|
||||
}
|
||||
this.subscribeToSession(ws, message.sessionId);
|
||||
break;
|
||||
|
||||
case 'unsubscribe':
|
||||
if (!clientInfo.authenticated) {
|
||||
this.sendError(ws, 'Not authenticated');
|
||||
return;
|
||||
}
|
||||
this.unsubscribeFromSession(ws, message.sessionId);
|
||||
break;
|
||||
|
||||
case 'ping':
|
||||
clientInfo.lastPing = Date.now();
|
||||
this.send(ws, { type: 'pong' });
|
||||
break;
|
||||
|
||||
case 'page_focus':
|
||||
if (!clientInfo.authenticated) {
|
||||
this.sendError(ws, 'Not authenticated');
|
||||
return;
|
||||
}
|
||||
clientInfo.currentPage = message.page || null;
|
||||
this.broadcastPresence();
|
||||
break;
|
||||
|
||||
default:
|
||||
this.sendError(ws, `Unknown message type: ${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a client using JWT token
|
||||
*/
|
||||
authenticateClient(ws, token) {
|
||||
if (!token) {
|
||||
this.sendError(ws, 'Token required', 'auth_error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
const clientInfo = this.clients.get(ws);
|
||||
|
||||
if (clientInfo) {
|
||||
clientInfo.authenticated = true;
|
||||
clientInfo.userId = decoded.role;
|
||||
clientInfo.adminName = decoded.name || null;
|
||||
|
||||
if (!decoded.name) {
|
||||
this.sendError(ws, 'Token missing admin identity, please re-login', 'auth_error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.send(ws, {
|
||||
type: 'auth_success',
|
||||
message: 'Authenticated successfully'
|
||||
});
|
||||
|
||||
console.log('[WebSocket] Client authenticated:', clientInfo.userId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WebSocket] Authentication failed:', err.message);
|
||||
this.sendError(ws, 'Invalid or expired token', 'auth_error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a client to session events
|
||||
*/
|
||||
subscribeToSession(ws, sessionId) {
|
||||
if (!sessionId) {
|
||||
this.sendError(ws, 'Session ID required');
|
||||
return;
|
||||
}
|
||||
|
||||
const clientInfo = this.clients.get(ws);
|
||||
if (!clientInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to session subscriptions
|
||||
if (!this.sessionSubscriptions.has(sessionId)) {
|
||||
this.sessionSubscriptions.set(sessionId, new Set());
|
||||
}
|
||||
|
||||
this.sessionSubscriptions.get(sessionId).add(ws);
|
||||
clientInfo.subscribedSessions.add(sessionId);
|
||||
|
||||
this.send(ws, {
|
||||
type: 'subscribed',
|
||||
sessionId: sessionId,
|
||||
message: `Subscribed to session ${sessionId}`
|
||||
});
|
||||
|
||||
console.log(`[WebSocket] Client subscribed to session ${sessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe a client from session events
|
||||
*/
|
||||
unsubscribeFromSession(ws, sessionId) {
|
||||
const clientInfo = this.clients.get(ws);
|
||||
if (!clientInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from session subscriptions
|
||||
if (this.sessionSubscriptions.has(sessionId)) {
|
||||
this.sessionSubscriptions.get(sessionId).delete(ws);
|
||||
|
||||
// Clean up empty subscription sets
|
||||
if (this.sessionSubscriptions.get(sessionId).size === 0) {
|
||||
this.sessionSubscriptions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
clientInfo.subscribedSessions.delete(sessionId);
|
||||
|
||||
this.send(ws, {
|
||||
type: 'unsubscribed',
|
||||
sessionId: sessionId,
|
||||
message: `Unsubscribed from session ${sessionId}`
|
||||
});
|
||||
|
||||
console.log(`[WebSocket] Client unsubscribed from session ${sessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an event to all clients subscribed to a session
|
||||
*/
|
||||
broadcastEvent(eventType, data, sessionId) {
|
||||
const subscribers = this.sessionSubscriptions.get(sessionId);
|
||||
|
||||
if (!subscribers || subscribers.size === 0) {
|
||||
console.log(`[WebSocket] No subscribers for session ${sessionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: data
|
||||
};
|
||||
|
||||
let sentCount = 0;
|
||||
subscribers.forEach((ws) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
this.send(ws, message);
|
||||
sentCount++;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[WebSocket] Broadcasted ${eventType} to ${sentCount} client(s) for session ${sessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an event to all authenticated clients (not session-specific)
|
||||
* Used for session.started and other global events
|
||||
*/
|
||||
broadcastToAll(eventType, data) {
|
||||
const message = {
|
||||
type: eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: data
|
||||
};
|
||||
|
||||
let sentCount = 0;
|
||||
this.clients.forEach((clientInfo, ws) => {
|
||||
if (clientInfo.authenticated && ws.readyState === ws.OPEN) {
|
||||
this.send(ws, message);
|
||||
sentCount++;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[WebSocket] Broadcasted ${eventType} to ${sentCount} authenticated client(s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a specific client
|
||||
*/
|
||||
send(ws, message) {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error message to a client
|
||||
*/
|
||||
sendError(ws, message, type = 'error') {
|
||||
this.send(ws, {
|
||||
type: type,
|
||||
message: message
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a client and clean up subscriptions
|
||||
*/
|
||||
removeClient(ws) {
|
||||
const clientInfo = this.clients.get(ws);
|
||||
|
||||
if (clientInfo) {
|
||||
// Remove from all session subscriptions
|
||||
clientInfo.subscribedSessions.forEach((sessionId) => {
|
||||
if (this.sessionSubscriptions.has(sessionId)) {
|
||||
this.sessionSubscriptions.get(sessionId).delete(ws);
|
||||
|
||||
// Clean up empty subscription sets
|
||||
if (this.sessionSubscriptions.get(sessionId).size === 0) {
|
||||
this.sessionSubscriptions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.clients.delete(ws);
|
||||
console.log('[WebSocket] Client disconnected and cleaned up');
|
||||
this.broadcastPresence();
|
||||
}
|
||||
}
|
||||
|
||||
broadcastPresence() {
|
||||
const admins = [];
|
||||
this.clients.forEach((info) => {
|
||||
if (info.authenticated && info.adminName && info.currentPage) {
|
||||
admins.push({ name: info.adminName, page: info.currentPage });
|
||||
}
|
||||
});
|
||||
|
||||
const message = {
|
||||
type: 'presence_update',
|
||||
timestamp: new Date().toISOString(),
|
||||
admins
|
||||
};
|
||||
|
||||
this.clients.forEach((info, ws) => {
|
||||
if (info.authenticated && ws.readyState === ws.OPEN) {
|
||||
this.send(ws, message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat to detect dead connections
|
||||
*/
|
||||
startHeartbeat() {
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const timeout = 60000; // 60 seconds
|
||||
|
||||
this.clients.forEach((clientInfo, ws) => {
|
||||
if (now - clientInfo.lastPing > timeout) {
|
||||
console.log('[WebSocket] Client timeout, closing connection');
|
||||
ws.terminate();
|
||||
this.removeClient(ws);
|
||||
}
|
||||
});
|
||||
}, 30000); // Check every 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection statistics
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
totalClients: this.clients.size,
|
||||
authenticatedClients: Array.from(this.clients.values()).filter(c => c.authenticated).length,
|
||||
totalSubscriptions: this.sessionSubscriptions.size,
|
||||
subscriptionDetails: Array.from(this.sessionSubscriptions.entries()).map(([sessionId, subs]) => ({
|
||||
sessionId,
|
||||
subscribers: subs.size
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let instance = null;
|
||||
|
||||
module.exports = {
|
||||
WebSocketManager,
|
||||
getWebSocketManager: () => instance,
|
||||
setWebSocketManager: (manager) => {
|
||||
instance = manager;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,11 +9,14 @@ services:
|
||||
- PORT=5000
|
||||
- NODE_ENV=production
|
||||
- DB_PATH=/app/data/jackbox.db
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- ADMIN_KEY=${ADMIN_KEY:-admin123}
|
||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||
- ADMIN_KEY=${ADMIN_KEY:-}
|
||||
- ADMIN_CONFIG_PATH=${ADMIN_CONFIG_PATH:-}
|
||||
- DEBUG=false
|
||||
volumes:
|
||||
- jackbox-data:/app/data
|
||||
- ./games-list.csv:/app/games-list.csv:ro
|
||||
# - ./backend/config/admins.json:/app/config/admins.json:ro
|
||||
ports:
|
||||
- "5000:5000"
|
||||
networks:
|
||||
|
||||
182
docs/api/README.md
Normal file
182
docs/api/README.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Jackbox Game Picker API
|
||||
|
||||
## Overview
|
||||
|
||||
The API manages Jackbox Party Pack games, runs gaming sessions, tracks popularity via voting, picks games with weighted random selection, and notifies external systems via webhooks and WebSocket.
|
||||
|
||||
## Base URL
|
||||
|
||||
| Environment | Base URL | Notes |
|
||||
|-------------|----------|-------|
|
||||
| Local development | `http://localhost:5000` | Backend direct |
|
||||
| Docker Compose | `http://localhost:3000/api` | Via Vite/Nginx proxy |
|
||||
|
||||
All REST endpoints are prefixed with `/api/` except `/health`.
|
||||
|
||||
## Authentication
|
||||
|
||||
1. **Login**: POST to `/api/auth/login` with JSON body:
|
||||
|
||||
```json
|
||||
{ "key": "<admin-key>" }
|
||||
```
|
||||
|
||||
Returns a JWT token.
|
||||
|
||||
2. **Authorization**: Include the token in requests:
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
3. **Expiry**: Tokens expire in 24 hours.
|
||||
|
||||
### Public Endpoints (no auth required)
|
||||
|
||||
- `GET /api/games`
|
||||
- `GET /api/games/packs`
|
||||
- `GET /api/games/meta/packs`
|
||||
- `GET /api/games/{id}`
|
||||
- `GET /api/sessions`
|
||||
- `GET /api/sessions/active`
|
||||
- `GET /api/sessions/{id}`
|
||||
- `GET /api/sessions/{id}/games`
|
||||
- `GET /api/sessions/{id}/votes`
|
||||
- `GET /api/sessions/{sessionId}/games/{sessionGameId}/status-live`
|
||||
- `GET /api/votes`
|
||||
- `GET /api/stats`
|
||||
- `POST /api/pick`
|
||||
- `GET /health`
|
||||
|
||||
All write and admin operations require authentication.
|
||||
|
||||
## Request/Response Format
|
||||
|
||||
- Request and response bodies use JSON. Set `Content-Type: application/json`.
|
||||
- **Exceptions**:
|
||||
- `GET /api/games/export/csv` returns `text/csv`
|
||||
- `GET /api/sessions/{id}/export` returns `text/plain` or `application/json` depending on `format` query param
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors return:
|
||||
|
||||
```json
|
||||
{ "error": "description" }
|
||||
```
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| 400 | Bad request / validation failure |
|
||||
| 401 | No token provided |
|
||||
| 403 | Invalid or expired token |
|
||||
| 404 | Not found |
|
||||
| 409 | Conflict (e.g. duplicate vote) |
|
||||
| 500 | Server error |
|
||||
|
||||
Global error handler may include additional detail:
|
||||
|
||||
```json
|
||||
{ "error": "Something went wrong!", "message": "<details>" }
|
||||
```
|
||||
|
||||
## Boolean Fields
|
||||
|
||||
SQLite stores booleans as integers (0/1). In request bodies, pass JavaScript booleans (`true`/`false`); the API converts them. In responses, expect `0`/`1` for game and session fields. **Exception**: `Webhook.enabled` returns a JavaScript boolean.
|
||||
|
||||
## Pagination
|
||||
|
||||
Most list endpoints return full result sets. The exception is `GET /api/votes`, which supports pagination via `page` and `limit` query parameters (default: page 1, limit 50, max 100).
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Health
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/health` | No | Health check |
|
||||
|
||||
### Auth
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/auth/login` | No | Authenticate with admin key |
|
||||
| POST | `/api/auth/verify` | Yes | Verify JWT token |
|
||||
|
||||
### Games
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/games` | No | List games with optional filters |
|
||||
| POST | `/api/games` | Yes | Create a new game |
|
||||
| GET | `/api/games/packs` | No | List all packs |
|
||||
| GET | `/api/games/meta/packs` | No | List pack metadata |
|
||||
| GET | `/api/games/export/csv` | Yes | Export games as CSV |
|
||||
| POST | `/api/games/import/csv` | Yes | Import games from CSV |
|
||||
| PATCH | `/api/games/packs/{name}/favor` | Yes | Update pack favor bias |
|
||||
| PATCH | `/api/games/packs/{name}/toggle` | Yes | Enable or disable a pack |
|
||||
| GET | `/api/games/{id}` | No | Get a game by ID |
|
||||
| PUT | `/api/games/{id}` | Yes | Update a game |
|
||||
| DELETE | `/api/games/{id}` | Yes | Delete a game |
|
||||
| PATCH | `/api/games/{id}/toggle` | Yes | Toggle game enabled status |
|
||||
| PATCH | `/api/games/{id}/favor` | Yes | Update game favor bias |
|
||||
|
||||
### Sessions
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/sessions` | No | List all sessions |
|
||||
| POST | `/api/sessions` | Yes | Create a new session |
|
||||
| GET | `/api/sessions/active` | No | Get the active session |
|
||||
| GET | `/api/sessions/{id}` | No | Get a session by ID |
|
||||
| DELETE | `/api/sessions/{id}` | Yes | Delete a session |
|
||||
| POST | `/api/sessions/{id}/close` | Yes | Close a session |
|
||||
| GET | `/api/sessions/{id}/games` | No | List games in a session |
|
||||
| GET | `/api/sessions/{id}/votes` | No | Get per-game vote breakdown for a session |
|
||||
| POST | `/api/sessions/{id}/games` | Yes | Add a game to a session |
|
||||
| POST | `/api/sessions/{id}/chat-import` | Yes | Import chat log for vote processing |
|
||||
| GET | `/api/sessions/{id}/export` | Yes | Export session |
|
||||
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/status` | Yes | Update session game status |
|
||||
| DELETE | `/api/sessions/{sessionId}/games/{sessionGameId}` | Yes | Remove game from session |
|
||||
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/room-code` | Yes | Update room code for session game |
|
||||
| POST | `/api/sessions/{sessionId}/games/{sessionGameId}/start-player-check` | Yes | Start room monitor for player count |
|
||||
| POST | `/api/sessions/{sessionId}/games/{sessionGameId}/stop-player-check` | Yes | Stop room monitor |
|
||||
| GET | `/api/sessions/{sessionId}/games/{sessionGameId}/status-live` | No | Get live game status from shard monitor |
|
||||
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/player-count` | Yes | Update player count for session game |
|
||||
|
||||
### Picker
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/pick` | No | Pick a random game with optional filters |
|
||||
|
||||
### Stats
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/stats` | No | Get aggregate statistics |
|
||||
|
||||
### Votes
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/votes` | No | Paginated vote history with filtering |
|
||||
| POST | `/api/votes/live` | Yes | Record a live vote (up/down) |
|
||||
|
||||
### Webhooks
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/webhooks` | Yes | List all webhooks |
|
||||
| POST | `/api/webhooks` | Yes | Create a webhook |
|
||||
| GET | `/api/webhooks/{id}` | Yes | Get a webhook by ID |
|
||||
| PATCH | `/api/webhooks/{id}` | Yes | Update a webhook |
|
||||
| DELETE | `/api/webhooks/{id}` | Yes | Delete a webhook |
|
||||
| POST | `/api/webhooks/test/{id}` | Yes | Send test webhook |
|
||||
| GET | `/api/webhooks/{id}/logs` | Yes | List webhook delivery logs |
|
||||
|
||||
## Documentation Links
|
||||
|
||||
- [OpenAPI Spec](openapi.yaml)
|
||||
- **Endpoint docs**: [Auth](endpoints/auth.md), [Games](endpoints/games.md), [Sessions](endpoints/sessions.md), [Picker](endpoints/picker.md), [Stats](endpoints/stats.md), [Votes](endpoints/votes.md), [Webhooks](endpoints/webhooks.md)
|
||||
- [WebSocket Protocol](websocket.md)
|
||||
- **Guides**: [Getting Started](guides/getting-started.md), [Session Lifecycle](guides/session-lifecycle.md), [Voting & Popularity](guides/voting-and-popularity.md), [Webhooks & Events](guides/webhooks-and-events.md)
|
||||
135
docs/api/endpoints/auth.md
Normal file
135
docs/api/endpoints/auth.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Auth Endpoints
|
||||
|
||||
Simple admin-key authentication. Single role (admin). No user management. Obtain a JWT from `POST /api/auth/login` and use it as a Bearer token for protected endpoints.
|
||||
|
||||
## Endpoint Summary
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/auth/login` | No | Exchange admin key for JWT |
|
||||
| POST | `/api/auth/verify` | Bearer | Validate token and return user info |
|
||||
|
||||
---
|
||||
|
||||
## POST /api/auth/login
|
||||
|
||||
Exchange an admin key for a JWT. Use the returned token in the `Authorization: Bearer <token>` header for protected routes. Tokens expire after 24 hours.
|
||||
|
||||
### Authentication
|
||||
|
||||
None. This endpoint is public.
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| key | string | Yes | Admin key (configured via `ADMIN_KEY` env) |
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "your-admin-key"
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"message": "Authentication successful",
|
||||
"expiresIn": "24h"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| token | JWT to use in `Authorization: Bearer <token>` |
|
||||
| message | Success message |
|
||||
| expiresIn | Token lifetime |
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 400 | `{ "error": "Admin key is required" }` | `key` field missing |
|
||||
| 401 | `{ "error": "Invalid admin key" }` | Wrong key |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"key": "your-admin-key"}'
|
||||
```
|
||||
|
||||
**Sample response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJ0aW1lc3RhbXAiOjE3MTAwMDAwMDAwLCJpYXQiOjE3MTAwMDAwMDB9.abc123",
|
||||
"message": "Authentication successful",
|
||||
"expiresIn": "24h"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/auth/verify
|
||||
|
||||
Verify that the provided Bearer token is valid and return the decoded user payload.
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
||||
|
||||
### Parameters
|
||||
|
||||
None.
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"user": {
|
||||
"role": "admin",
|
||||
"timestamp": 1710000000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| valid | Always `true` when token is valid |
|
||||
| user.role | User role (always `"admin"`) |
|
||||
| user.timestamp | Unix ms when token was issued |
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 401 | `{ "error": "Access token required" }` | No `Authorization` header or Bearer token |
|
||||
| 403 | `{ "error": "Invalid or expired token" }` | Bad or expired token |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/auth/verify \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
**Sample response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"user": {
|
||||
"role": "admin",
|
||||
"timestamp": 1710000000000
|
||||
}
|
||||
}
|
||||
```
|
||||
627
docs/api/endpoints/games.md
Normal file
627
docs/api/endpoints/games.md
Normal file
@@ -0,0 +1,627 @@
|
||||
# Games Endpoints
|
||||
|
||||
Manage the Jackbox game catalog. Games belong to packs (e.g., "Jackbox Party Pack 7"). Each game has player limits, type, audience support, family-friendliness, and favor bias for weighted selection. Packs can also have favor/disfavor bias to influence the picker.
|
||||
|
||||
## Endpoint Summary
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/games` | No | List games with optional filters |
|
||||
| GET | `/api/games/packs` | No | List all packs |
|
||||
| GET | `/api/games/meta/packs` | No | Pack metadata (counts, plays) |
|
||||
| GET | `/api/games/export/csv` | Bearer | Export games as CSV |
|
||||
| PATCH | `/api/games/packs/{name}/favor` | Bearer | Set pack favor bias |
|
||||
| GET | `/api/games/{id}` | No | Get single game |
|
||||
| POST | `/api/games` | Bearer | Create game |
|
||||
| PUT | `/api/games/{id}` | Bearer | Update game |
|
||||
| DELETE | `/api/games/{id}` | Bearer | Delete game |
|
||||
| PATCH | `/api/games/{id}/toggle` | Bearer | Toggle game enabled status |
|
||||
| PATCH | `/api/games/packs/{name}/toggle` | Bearer | Enable/disable all games in pack |
|
||||
| POST | `/api/games/import/csv` | Bearer | Import games from CSV |
|
||||
| PATCH | `/api/games/{id}/favor` | Bearer | Set game favor bias |
|
||||
|
||||
---
|
||||
|
||||
## GET /api/games
|
||||
|
||||
List all games with optional query filters. Results are ordered by `pack_name`, then `title`.
|
||||
|
||||
### Authentication
|
||||
|
||||
None.
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|-----|------|----------|-------------|
|
||||
| enabled | query | string | No | `"true"` or `"false"` to filter by enabled status |
|
||||
| playerCount | query | integer | No | Filter games where `min_players ≤ count ≤ max_players` |
|
||||
| drawing | query | string | No | `"only"` = `game_type='Drawing'`, `"exclude"` = exclude Drawing |
|
||||
| length | query | string | No | `"short"` (≤15 min or NULL), `"medium"` (16–25 min), `"long"` (>25 min) |
|
||||
| familyFriendly | query | string | No | `"true"` or `"false"` |
|
||||
| pack | query | string | No | Exact `pack_name` match |
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"title": "Quiplash 3",
|
||||
"min_players": 3,
|
||||
"max_players": 8,
|
||||
"length_minutes": 15,
|
||||
"has_audience": 1,
|
||||
"family_friendly": 0,
|
||||
"game_type": "Writing",
|
||||
"secondary_type": null,
|
||||
"play_count": 0,
|
||||
"popularity_score": 0,
|
||||
"enabled": 1,
|
||||
"favor_bias": 0,
|
||||
"created_at": "2024-01-15T12:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 500 | `{ "error": "..." }` | Server error |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl "http://localhost:5000/api/games?playerCount=6&pack=Jackbox%20Party%20Pack%207"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/games/packs
|
||||
|
||||
List all packs with their favor bias.
|
||||
|
||||
### Authentication
|
||||
|
||||
None.
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Jackbox Party Pack 7",
|
||||
"favor_bias": 0,
|
||||
"created_at": "2024-01-15T12:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/games/meta/packs
|
||||
|
||||
Return pack metadata: total game count, enabled count, and total plays per pack.
|
||||
|
||||
### Authentication
|
||||
|
||||
None.
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "Jackbox Party Pack 7",
|
||||
"total_count": 5,
|
||||
"enabled_count": 5,
|
||||
"total_plays": 42
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/games/export/csv
|
||||
|
||||
Export the full game catalog as a CSV file download.
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required.
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
- Content-Type: `text/csv`
|
||||
- Content-Disposition: `attachment; filename="jackbox-games.csv"`
|
||||
- Columns: Pack Name, Title, Min Players, Max Players, Length (minutes), Audience, Family Friendly, Game Type, Secondary Type
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -o jackbox-games.csv "http://localhost:5000/api/games/export/csv" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PATCH /api/games/packs/{name}/favor
|
||||
|
||||
Set favor bias for a pack. Affects weighted random selection in the picker.
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required.
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| name | string | Pack name (exact match, URL-encode if spaces) |
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| favor_bias | integer | Yes | `1` = favor, `-1` = disfavor, `0` = neutral |
|
||||
|
||||
```json
|
||||
{
|
||||
"favor_bias": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Pack favor bias updated successfully",
|
||||
"favor_bias": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 400 | `{ "error": "favor_bias must be 1 (favor), -1 (disfavor), or 0 (neutral)" }` | Invalid value |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X PATCH "http://localhost:5000/api/games/packs/Jackbox%20Party%20Pack%207/favor" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"favor_bias": 1}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/games/{id}
|
||||
|
||||
Get a single game by ID.
|
||||
|
||||
### Authentication
|
||||
|
||||
None.
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| id | integer | Game ID |
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"title": "Quiplash 3",
|
||||
"min_players": 3,
|
||||
"max_players": 8,
|
||||
"length_minutes": 15,
|
||||
"has_audience": 1,
|
||||
"family_friendly": 0,
|
||||
"game_type": "Writing",
|
||||
"secondary_type": null,
|
||||
"play_count": 0,
|
||||
"popularity_score": 0,
|
||||
"enabled": 1,
|
||||
"favor_bias": 0,
|
||||
"created_at": "2024-01-15T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 404 | `{ "error": "Game not found" }` | Invalid ID |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl "http://localhost:5000/api/games/1"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/games
|
||||
|
||||
Create a new game. Pack is created automatically if it does not exist.
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required.
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| pack_name | string | Yes | Pack name (e.g., "Jackbox Party Pack 7") |
|
||||
| title | string | Yes | Game title |
|
||||
| min_players | integer | Yes | Minimum players |
|
||||
| max_players | integer | Yes | Maximum players |
|
||||
| length_minutes | integer | No | Approx. play length |
|
||||
| has_audience | boolean | No | Audience mode supported |
|
||||
| family_friendly | boolean | No | Family-friendly rating |
|
||||
| game_type | string | No | Primary type (e.g., "Writing", "Drawing") |
|
||||
| secondary_type | string | No | Secondary type |
|
||||
|
||||
```json
|
||||
{
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"title": "Quiplash 3",
|
||||
"min_players": 3,
|
||||
"max_players": 8,
|
||||
"length_minutes": 15,
|
||||
"has_audience": true,
|
||||
"family_friendly": false,
|
||||
"game_type": "Writing",
|
||||
"secondary_type": null
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**201 Created**
|
||||
|
||||
Returns the created game object.
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 400 | `{ "error": "Missing required fields" }` | Missing pack_name, title, min_players, or max_players |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:5000/api/games" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"title": "Quiplash 3",
|
||||
"min_players": 3,
|
||||
"max_players": 8,
|
||||
"length_minutes": 15,
|
||||
"has_audience": true,
|
||||
"family_friendly": false,
|
||||
"game_type": "Writing",
|
||||
"secondary_type": null
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PUT /api/games/{id}
|
||||
|
||||
Update a game. All fields are optional; uses COALESCE (only provided fields are updated).
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required.
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| id | integer | Game ID |
|
||||
|
||||
### Request Body
|
||||
|
||||
All fields optional. Include only the fields to update.
|
||||
|
||||
```json
|
||||
{
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"title": "Quiplash 3",
|
||||
"min_players": 3,
|
||||
"max_players": 8,
|
||||
"length_minutes": 20,
|
||||
"has_audience": true,
|
||||
"family_friendly": false,
|
||||
"game_type": "Writing",
|
||||
"secondary_type": null,
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
Returns the updated game object.
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 404 | `{ "error": "Game not found" }` | Invalid ID |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X PUT "http://localhost:5000/api/games/1" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"length_minutes": 20, "enabled": true}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DELETE /api/games/{id}
|
||||
|
||||
Delete a game permanently.
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required.
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| id | integer | Game ID |
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Game deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 404 | `{ "error": "Game not found" }` | Invalid ID |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X DELETE "http://localhost:5000/api/games/1" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PATCH /api/games/{id}/toggle
|
||||
|
||||
Toggle the game's `enabled` field (0↔1). Use to quickly enable/disable a game without a full PUT.
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required.
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| id | integer | Game ID |
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
Returns the updated game object with the flipped `enabled` value.
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 404 | `{ "error": "Game not found" }` | Invalid ID |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X PATCH "http://localhost:5000/api/games/1/toggle" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PATCH /api/games/packs/{name}/toggle
|
||||
|
||||
Enable or disable all games in a pack at once.
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required.
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| name | string | Pack name (URL-encode if spaces) |
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| enabled | boolean | Yes | `true` to enable, `false` to disable |
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Pack enabled successfully",
|
||||
"gamesAffected": 12
|
||||
}
|
||||
```
|
||||
|
||||
Message varies: "Pack enabled successfully" or "Pack disabled successfully" based on the `enabled` value.
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 400 | `{ "error": "enabled status required" }` | Missing `enabled` field |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X PATCH "http://localhost:5000/api/games/packs/Jackbox%20Party%20Pack%207/toggle" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"enabled": true}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/games/import/csv
|
||||
|
||||
Import games from CSV data. Default mode is `append`. Use `"replace"` to delete all existing games before importing.
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required.
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| csvData | string | Yes | Raw CSV content (header + rows) |
|
||||
| mode | string | No | `"append"` (default) or `"replace"` |
|
||||
|
||||
**CSV columns:** Game Pack, Game Title, Min. Players, Max. Players, Length, Audience, Family Friendly?, Game Type, Secondary Type
|
||||
|
||||
```json
|
||||
{
|
||||
"csvData": "Game Pack,Game Title,Min. Players,Max. Players,Length,Audience,Family Friendly?,Game Type,Secondary Type\nJackbox Party Pack 7,Quiplash 3,3,8,15,Yes,No,Writing,\nJackbox Party Pack 7,The Devils and the Details,3,7,25,Yes,No,Strategy,",
|
||||
"mode": "append"
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Successfully imported 5 games",
|
||||
"count": 5,
|
||||
"mode": "append"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 400 | `{ "error": "CSV data required" }` | Missing `csvData` |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:5000/api/games/import/csv" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"csvData": "Game Pack,Game Title,Min. Players,Max. Players,Length,Audience,Family Friendly?,Game Type,Secondary Type\nJackbox Party Pack 7,Quiplash 3,3,8,15,Yes,No,Writing,\nJackbox Party Pack 7,The Devils and the Details,3,7,25,Yes,No,Strategy,",
|
||||
"mode": "append"
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PATCH /api/games/{id}/favor
|
||||
|
||||
Set favor bias for a single game. Affects weighted random selection in the picker.
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required.
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| id | integer | Game ID |
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| favor_bias | integer | Yes | `1` = favor, `-1` = disfavor, `0` = neutral |
|
||||
|
||||
```json
|
||||
{
|
||||
"favor_bias": -1
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Favor bias updated successfully",
|
||||
"favor_bias": -1
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 400 | `{ "error": "favor_bias must be 1 (favor), -1 (disfavor), or 0 (neutral)" }` | Invalid value |
|
||||
| 404 | `{ "error": "Game not found" }` | Invalid ID |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X PATCH "http://localhost:5000/api/games/1/favor" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"favor_bias": -1}'
|
||||
```
|
||||
120
docs/api/endpoints/picker.md
Normal file
120
docs/api/endpoints/picker.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Picker Endpoints
|
||||
|
||||
Weighted random game selection. Picks from enabled games matching your filters, with favor bias affecting probability. Avoids recently played games within a session.
|
||||
|
||||
## Endpoint Summary
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/pick` | No | Pick a random game with filters and repeat avoidance |
|
||||
|
||||
---
|
||||
|
||||
## POST /api/pick
|
||||
|
||||
Select a game using weighted random selection. Filters to only enabled games, applies favor/disfavor bias to influence probability, and optionally excludes recently played games when a session is provided.
|
||||
|
||||
### Authentication
|
||||
|
||||
None.
|
||||
|
||||
### Request Body
|
||||
|
||||
All fields optional. Provide only the filters you want to apply.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| playerCount | integer | No | Filter games where `min_players ≤ count ≤ max_players` |
|
||||
| drawing | string | No | `"only"` = Drawing type only, `"exclude"` = exclude Drawing type |
|
||||
| length | string | No | `"short"` (≤15 min or NULL), `"medium"` (16–25 min), `"long"` (>25 min) |
|
||||
| familyFriendly | boolean | No | Filter by family-friendly rating |
|
||||
| sessionId | integer | No | Session ID for repeat avoidance |
|
||||
| excludePlayed | boolean | No | When `true`, exclude ALL games played in session. Default: exclude last 2 only |
|
||||
|
||||
```json
|
||||
{
|
||||
"playerCount": 6,
|
||||
"drawing": "exclude",
|
||||
"length": "short",
|
||||
"familyFriendly": true,
|
||||
"sessionId": 3,
|
||||
"excludePlayed": false
|
||||
}
|
||||
```
|
||||
|
||||
### Filters
|
||||
|
||||
- **Enabled games only:** Only games with `enabled = 1` are considered.
|
||||
- **playerCount:** Filters games where `min_players ≤ playerCount ≤ max_players`.
|
||||
- **drawing:** `"only"` = games with `game_type = 'Drawing'`; `"exclude"` = games that are not Drawing type.
|
||||
- **length:** `"short"` = ≤15 min (includes NULL); `"medium"` = 16–25 min; `"long"` = >25 min.
|
||||
- **familyFriendly:** `true` or `false` filters by `family_friendly`.
|
||||
|
||||
### Weighted Selection
|
||||
|
||||
- **Game favor_bias:** `1` = 3× weight, `0` = 1× weight, `-1` = 0.2× weight.
|
||||
- **Pack favor_bias:** `1` = 2× weight, `0` = 1× weight, `-1` = 0.3× weight.
|
||||
- Game and pack biases multiply together.
|
||||
|
||||
### Repeat Avoidance (with sessionId)
|
||||
|
||||
- **Default (`excludePlayed: false`):** Excludes the last 2 played games in the session.
|
||||
- **With `excludePlayed: true`:** Excludes ALL games played in the session.
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"game": {
|
||||
"id": 42,
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"title": "Quiplash 3",
|
||||
"min_players": 3,
|
||||
"max_players": 8,
|
||||
"length_minutes": 15,
|
||||
"has_audience": 1,
|
||||
"family_friendly": 0,
|
||||
"game_type": "Writing",
|
||||
"secondary_type": null,
|
||||
"play_count": 0,
|
||||
"popularity_score": 0,
|
||||
"enabled": 1,
|
||||
"favor_bias": 0,
|
||||
"created_at": "2024-01-15T12:00:00.000Z"
|
||||
},
|
||||
"poolSize": 15,
|
||||
"totalEnabled": 17
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| game | Full game object for the selected game |
|
||||
| poolSize | Number of games in the eligible pool after filters |
|
||||
| totalEnabled | Approximate total enabled games (includes excluded when sessionId provided) |
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 404 | `{ "error": "No games match the current filters", "suggestion": "Try adjusting your filters or enabling more games" }` | No games match the filters |
|
||||
| 404 | `{ "error": "All eligible games have been played in this session", "suggestion": "Enable more games or adjust your filters", "recentlyPlayed": [1, 5, 12] }` | All eligible games already played in session (when `excludePlayed: true`) |
|
||||
| 404 | `{ "error": "All eligible games have been played recently", "suggestion": "Enable more games or adjust your filters", "recentlyPlayed": [1, 5] }` | Last 2 games are the only matches (when `excludePlayed: false`) |
|
||||
| 500 | `{ "error": "..." }` | Server error |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:5000/api/pick" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"playerCount": 6,
|
||||
"drawing": "exclude",
|
||||
"length": "short",
|
||||
"familyFriendly": true,
|
||||
"sessionId": 3,
|
||||
"excludePlayed": false
|
||||
}'
|
||||
```
|
||||
1048
docs/api/endpoints/sessions.md
Normal file
1048
docs/api/endpoints/sessions.md
Normal file
File diff suppressed because it is too large
Load Diff
79
docs/api/endpoints/stats.md
Normal file
79
docs/api/endpoints/stats.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Stats Endpoints
|
||||
|
||||
Aggregate statistics about the game library, sessions, and popularity.
|
||||
|
||||
## Endpoint Summary
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/stats` | No | Get aggregate statistics |
|
||||
|
||||
---
|
||||
|
||||
## GET /api/stats
|
||||
|
||||
Return aggregate statistics: game counts, pack count, session counts, total games played, most-played games, and top-rated games.
|
||||
|
||||
### Authentication
|
||||
|
||||
None.
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"games": { "count": 89 },
|
||||
"gamesEnabled": { "count": 75 },
|
||||
"packs": { "count": 9 },
|
||||
"sessions": { "count": 12 },
|
||||
"activeSessions": { "count": 1 },
|
||||
"totalGamesPlayed": { "count": 156 },
|
||||
"mostPlayedGames": [
|
||||
{
|
||||
"id": 42,
|
||||
"title": "Quiplash 3",
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"play_count": 15,
|
||||
"popularity_score": 8,
|
||||
"upvotes": 10,
|
||||
"downvotes": 2
|
||||
}
|
||||
],
|
||||
"topRatedGames": [
|
||||
{
|
||||
"id": 42,
|
||||
"title": "Quiplash 3",
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"play_count": 15,
|
||||
"popularity_score": 8,
|
||||
"upvotes": 10,
|
||||
"downvotes": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| games.count | Total number of games in the library |
|
||||
| gamesEnabled.count | Number of enabled games |
|
||||
| packs.count | Number of distinct packs |
|
||||
| sessions.count | Total sessions (all time) |
|
||||
| activeSessions.count | Sessions with `is_active = 1` |
|
||||
| totalGamesPlayed.count | Total game plays across all sessions |
|
||||
| mostPlayedGames | Top 10 games by `play_count` DESC (only games with `play_count` > 0) |
|
||||
| topRatedGames | Top 10 games by `popularity_score` DESC (only games with `popularity_score` > 0) |
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 500 | `{ "error": "..." }` | Server error |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl "http://localhost:5000/api/stats"
|
||||
```
|
||||
152
docs/api/endpoints/votes.md
Normal file
152
docs/api/endpoints/votes.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# 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.
|
||||
|
||||
## Endpoint Summary
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/votes` | None | Paginated vote history with filtering |
|
||||
| POST | `/api/votes/live` | Bearer | Submit a live vote (up/down) |
|
||||
|
||||
---
|
||||
|
||||
## GET /api/votes
|
||||
|
||||
Paginated vote history with filtering. Use query parameters to filter by session, game, username, or vote type.
|
||||
|
||||
### Authentication
|
||||
|
||||
None.
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| session_id | int | No | — | Filter by session ID |
|
||||
| game_id | int | No | — | Filter by game ID |
|
||||
| username | string | No | — | Filter by voter username |
|
||||
| vote_type | string | No | — | `"up"` or `"down"` |
|
||||
| page | int | No | 1 | Page number |
|
||||
| limit | int | No | 50 | Items per page (max 100) |
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
Results are ordered by `timestamp DESC`. The `vote_type` field is returned as `"up"` or `"down"` (not raw integers).
|
||||
|
||||
```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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 400 | `{ "error": "..." }` | Invalid `session_id`, `game_id`, or `vote_type` |
|
||||
| 200 | `{ "votes": [], "pagination": { "page": 1, "limit": 50, "total": 0, "total_pages": 0 } }` | No results match the filters |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| 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 |
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "viewer123",
|
||||
"vote": "up",
|
||||
"timestamp": "2026-03-15T20:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 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).
|
||||
- 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.
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Vote recorded successfully",
|
||||
"session": { "id": 3, "games_played": 5 },
|
||||
"game": {
|
||||
"id": 42,
|
||||
"title": "Quiplash 3",
|
||||
"upvotes": 11,
|
||||
"downvotes": 2,
|
||||
"popularity_score": 9
|
||||
},
|
||||
"vote": {
|
||||
"username": "viewer123",
|
||||
"type": "up",
|
||||
"timestamp": "2026-03-15T20:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 400 | `{ "error": "Missing required fields: username, vote, timestamp" }` | Missing required fields |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
```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"
|
||||
}'
|
||||
```
|
||||
382
docs/api/endpoints/webhooks.md
Normal file
382
docs/api/endpoints/webhooks.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# Webhooks Endpoints
|
||||
|
||||
HTTP callback endpoints for external integrations. Register webhook URLs to receive notifications about events like game additions. All endpoints require authentication.
|
||||
|
||||
## Endpoint Summary
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/webhooks` | Bearer | List all webhooks |
|
||||
| GET | `/api/webhooks/{id}` | Bearer | Get single webhook |
|
||||
| POST | `/api/webhooks` | Bearer | Create webhook |
|
||||
| PATCH | `/api/webhooks/{id}` | Bearer | Update webhook |
|
||||
| DELETE | `/api/webhooks/{id}` | Bearer | Delete webhook |
|
||||
| POST | `/api/webhooks/test/{id}` | Bearer | Send test event |
|
||||
| GET | `/api/webhooks/{id}/logs` | Bearer | Get webhook delivery logs |
|
||||
|
||||
---
|
||||
|
||||
## GET /api/webhooks
|
||||
|
||||
List all registered webhooks. `secret` is not included in responses. `events` is returned as a parsed array. `enabled` is returned as a boolean.
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required.
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Discord Bot",
|
||||
"url": "https://example.com/webhook",
|
||||
"events": ["game.added"],
|
||||
"enabled": true,
|
||||
"created_at": "2024-01-15T12:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Note: `secret` is never returned.
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 401 | `{ "error": "Access token required" }` | No Bearer token |
|
||||
| 403 | `{ "error": "Invalid or expired token" }` | Bad or expired token |
|
||||
| 500 | `{ "error": "..." }` | Server error |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl "http://localhost:5000/api/webhooks" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/webhooks/{id}
|
||||
|
||||
Get a single webhook by ID.
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required.
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| id | integer | Webhook ID |
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Discord Bot",
|
||||
"url": "https://example.com/webhook",
|
||||
"events": ["game.added"],
|
||||
"enabled": true,
|
||||
"created_at": "2024-01-15T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 404 | `{ "error": "Webhook not found" }` | Invalid ID |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl "http://localhost:5000/api/webhooks/1" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/webhooks
|
||||
|
||||
Create a new webhook.
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required.
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| name | string | Yes | Display name for the webhook |
|
||||
| url | string | Yes | Callback URL (must be valid) |
|
||||
| secret | string | Yes | Secret for signing payloads |
|
||||
| events | array | Yes | Event types to subscribe to (e.g., `["game.added"]`) |
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Discord Bot",
|
||||
"url": "https://example.com/webhook",
|
||||
"secret": "mysecret123",
|
||||
"events": ["game.added"]
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**201 Created**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Discord Bot",
|
||||
"url": "https://example.com/webhook",
|
||||
"events": ["game.added"],
|
||||
"enabled": true,
|
||||
"created_at": "2024-01-15T12:00:00.000Z",
|
||||
"message": "Webhook created successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 400 | `{ "error": "Missing required fields: name, url, secret, events" }` | Missing fields |
|
||||
| 400 | `{ "error": "events must be an array" }` | `events` is not an array |
|
||||
| 400 | `{ "error": "Invalid URL format" }` | URL validation failed |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:5000/api/webhooks" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Discord Bot",
|
||||
"url": "https://example.com/webhook",
|
||||
"secret": "mysecret123",
|
||||
"events": ["game.added"]
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PATCH /api/webhooks/{id}
|
||||
|
||||
Update an existing webhook. At least one field must be provided.
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required.
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| id | integer | Webhook ID |
|
||||
|
||||
### Request Body
|
||||
|
||||
All fields optional. Include only the fields to update.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| name | string | No | Display name |
|
||||
| url | string | No | Callback URL (must be valid) |
|
||||
| secret | string | No | New secret |
|
||||
| events | array | No | Event types (must be array) |
|
||||
| enabled | boolean | No | Enable or disable the webhook |
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Discord Bot Updated",
|
||||
"url": "https://example.com/webhook-v2",
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Discord Bot Updated",
|
||||
"url": "https://example.com/webhook-v2",
|
||||
"events": ["game.added"],
|
||||
"enabled": true,
|
||||
"created_at": "2024-01-15T12:00:00.000Z",
|
||||
"message": "Webhook updated successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 400 | `{ "error": "No fields to update" }` | No fields in body |
|
||||
| 400 | `{ "error": "Invalid URL format" }` | Invalid URL |
|
||||
| 400 | `{ "error": "events must be an array" }` | `events` not an array |
|
||||
| 404 | `{ "error": "Webhook not found" }` | Invalid ID |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X PATCH "http://localhost:5000/api/webhooks/5" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Discord Bot Updated", "enabled": true}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DELETE /api/webhooks/{id}
|
||||
|
||||
Delete a webhook. Cascades to `webhook_logs` (logs are deleted).
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required.
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| id | integer | Webhook ID |
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Webhook deleted successfully",
|
||||
"webhookId": 5
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 404 | `{ "error": "Webhook not found" }` | Invalid ID |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X DELETE "http://localhost:5000/api/webhooks/5" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/webhooks/test/{id}
|
||||
|
||||
Send a test `game.added` event with dummy data to the webhook URL. Delivery runs asynchronously; check `webhook_logs` for status.
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required.
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| id | integer | Webhook ID |
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Test webhook sent",
|
||||
"note": "Check webhook_logs table for delivery status"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 404 | `{ "error": "Webhook not found" }` | Invalid ID |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:5000/api/webhooks/test/5" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/webhooks/{id}/logs
|
||||
|
||||
Get delivery logs for a webhook. Payload is parsed from JSON string to object.
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token required.
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| id | integer | Webhook ID |
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|-----|------|----------|-------------|
|
||||
| limit | query | integer | No | Max number of logs (default: 50) |
|
||||
|
||||
### Response
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"webhook_id": 5,
|
||||
"event_type": "game.added",
|
||||
"payload": {
|
||||
"session": { "id": 3, "is_active": true, "games_played": 2 },
|
||||
"game": {
|
||||
"id": 42,
|
||||
"title": "Quiplash 3",
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"min_players": 3,
|
||||
"max_players": 8,
|
||||
"manually_added": false
|
||||
}
|
||||
},
|
||||
"response_status": 200,
|
||||
"error_message": null,
|
||||
"created_at": "2024-01-15T12:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Body | When |
|
||||
|--------|------|------|
|
||||
| 500 | `{ "error": "..." }` | Server error |
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl "http://localhost:5000/api/webhooks/5/logs?limit=20" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
316
docs/api/guides/getting-started.md
Normal file
316
docs/api/guides/getting-started.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# Getting Started
|
||||
|
||||
A narrative walkthrough of the minimum viable integration path. Use this guide to go from zero to a completed game night session using the Jackbox Game Picker API.
|
||||
|
||||
**Prerequisites:** API running locally (`http://localhost:5000`), admin key set via `ADMIN_KEY` environment variable.
|
||||
|
||||
---
|
||||
|
||||
## 1. Health Check
|
||||
|
||||
Verify the API is running before anything else. The health endpoint requires no authentication.
|
||||
|
||||
**Why:** Quick sanity check. If this fails, nothing else will work.
|
||||
|
||||
```bash
|
||||
curl http://localhost:5000/health
|
||||
```
|
||||
|
||||
**Sample response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Jackbox Game Picker API is running"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Authenticate
|
||||
|
||||
Exchange your admin key for a JWT. You'll use this token for all write operations (creating sessions, adding games, closing sessions).
|
||||
|
||||
**Why:** Creating sessions, adding games to them, and closing sessions require authentication. The picker and game listings are public, but session management is not.
|
||||
|
||||
See [Auth endpoints](../endpoints/auth.md) for full details.
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST http://localhost:5000/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"key": "your-admin-key"}' | jq -r '.token')
|
||||
```
|
||||
|
||||
Or capture the full response:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"key": "your-admin-key"}'
|
||||
```
|
||||
|
||||
**Sample response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJ0aW1lc3RhbXAiOjE3MTAwMDAwMDAwLCJpYXQiOjE3MTAwMDAwMDB9.abc123",
|
||||
"message": "Authentication successful",
|
||||
"expiresIn": "24h"
|
||||
}
|
||||
```
|
||||
|
||||
Store `token` in `$TOKEN` for the remaining steps. Tokens expire after 24 hours.
|
||||
|
||||
---
|
||||
|
||||
## 3. Browse Games
|
||||
|
||||
List available games. Use query parameters to narrow the catalog—for example, `playerCount` filters to games that support that many players.
|
||||
|
||||
**Why:** Know what's in the catalog before you pick. Filtering by player count ensures you only see games you can actually play.
|
||||
|
||||
See [Games endpoints](../endpoints/games.md) for all filters.
|
||||
|
||||
```bash
|
||||
curl "http://localhost:5000/api/games?playerCount=6"
|
||||
```
|
||||
|
||||
**Sample response (200 OK):**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"title": "Quiplash 3",
|
||||
"min_players": 3,
|
||||
"max_players": 8,
|
||||
"length_minutes": 15,
|
||||
"has_audience": 1,
|
||||
"family_friendly": 0,
|
||||
"game_type": "Writing",
|
||||
"secondary_type": null,
|
||||
"play_count": 0,
|
||||
"popularity_score": 0,
|
||||
"enabled": 1,
|
||||
"favor_bias": 0,
|
||||
"created_at": "2024-01-15T12:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"title": "The Devils and the Details",
|
||||
"min_players": 3,
|
||||
"max_players": 7,
|
||||
"length_minutes": 25,
|
||||
"has_audience": 1,
|
||||
"family_friendly": 0,
|
||||
"game_type": "Strategy",
|
||||
"secondary_type": null,
|
||||
"play_count": 0,
|
||||
"popularity_score": 0,
|
||||
"enabled": 1,
|
||||
"favor_bias": 0,
|
||||
"created_at": "2024-01-15T12:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Pick a Game
|
||||
|
||||
Get a weighted random game based on your filters. The picker considers favor/disfavor bias and can avoid recently played games when a session is provided.
|
||||
|
||||
**Why:** Instead of manually choosing, let the API pick a game that fits your player count, length, and other preferences. Use the same filters you used to browse.
|
||||
|
||||
See [Picker endpoint](../endpoints/picker.md) for all options.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/pick \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"playerCount": 6}'
|
||||
```
|
||||
|
||||
**Sample response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"game": {
|
||||
"id": 1,
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"title": "Quiplash 3",
|
||||
"min_players": 3,
|
||||
"max_players": 8,
|
||||
"length_minutes": 15,
|
||||
"has_audience": 1,
|
||||
"family_friendly": 0,
|
||||
"game_type": "Writing",
|
||||
"secondary_type": null,
|
||||
"play_count": 0,
|
||||
"popularity_score": 0,
|
||||
"enabled": 1,
|
||||
"favor_bias": 0,
|
||||
"created_at": "2024-01-15T12:00:00.000Z"
|
||||
},
|
||||
"poolSize": 12,
|
||||
"totalEnabled": 17
|
||||
}
|
||||
```
|
||||
|
||||
Save the `game.id` (e.g. `1`) — you'll use it when adding the game to the session.
|
||||
|
||||
---
|
||||
|
||||
## 5. Start a Session
|
||||
|
||||
Create a new gaming session. Only one session can be active at a time. Use notes to label the night (e.g., "Friday game night").
|
||||
|
||||
**Why:** Sessions track which games you played, when, and support voting and room monitoring. Starting a session marks the beginning of your game night.
|
||||
|
||||
See [Sessions endpoints](../endpoints/sessions.md) for full details.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/sessions \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"notes": "Friday game night"}'
|
||||
```
|
||||
|
||||
**Sample response (201 Created):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 5,
|
||||
"notes": "Friday game night",
|
||||
"is_active": 1,
|
||||
"created_at": "2026-03-15T19:00:00.000Z",
|
||||
"closed_at": null
|
||||
}
|
||||
```
|
||||
|
||||
Save the `id` (e.g. `5`) — you'll use it to add games and close the session.
|
||||
|
||||
---
|
||||
|
||||
## 6. Add the Picked Game
|
||||
|
||||
Add the game you picked (step 4) to the session you created (step 5). You can optionally pass a room code once the game is running.
|
||||
|
||||
**Why:** Adding a game to the session records that you played it, increments play counts, and enables voting and room monitoring. Use `game_id` from the pick response.
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:5000/api/sessions/5/games" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"game_id": 1, "room_code": "ABCD"}'
|
||||
```
|
||||
|
||||
Replace `5` with your session ID and `1` with the `game.id` from the pick response.
|
||||
|
||||
**Sample response (201 Created):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 14,
|
||||
"session_id": 5,
|
||||
"game_id": 1,
|
||||
"manually_added": 0,
|
||||
"status": "playing",
|
||||
"room_code": "ABCD",
|
||||
"played_at": "2026-03-15T20:30:00.000Z",
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"title": "Quiplash 3",
|
||||
"game_type": "Writing",
|
||||
"min_players": 3,
|
||||
"max_players": 8
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Close the Session
|
||||
|
||||
When the game night is over, close the session. Any games still marked `playing` are automatically marked `played`.
|
||||
|
||||
**Why:** Closing the session finalizes it, frees the "active session" slot for the next night, and triggers any end-of-session webhooks or WebSocket events.
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:5000/api/sessions/5/close" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"notes": "Great session!"}'
|
||||
```
|
||||
|
||||
Replace `5` with your session ID.
|
||||
|
||||
**Sample response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 5,
|
||||
"notes": "Great session!",
|
||||
"is_active": 0,
|
||||
"created_at": "2026-03-15T19:00:00.000Z",
|
||||
"closed_at": "2026-03-15T23:30:00.000Z",
|
||||
"games_played": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Step | Endpoint | Auth |
|
||||
|------|----------|------|
|
||||
| 1 | `GET /health` | No |
|
||||
| 2 | `POST /api/auth/login` | No |
|
||||
| 3 | `GET /api/games?playerCount=6` | No |
|
||||
| 4 | `POST /api/pick` | No |
|
||||
| 5 | `POST /api/sessions` | Bearer |
|
||||
| 6 | `POST /api/sessions/{id}/games` | Bearer |
|
||||
| 7 | `POST /api/sessions/{id}/close` | Bearer |
|
||||
|
||||
---
|
||||
|
||||
## Full Copy-Paste Flow
|
||||
|
||||
```bash
|
||||
# 1. Health check
|
||||
curl http://localhost:5000/health
|
||||
|
||||
# 2. Get token (replace with your actual admin key)
|
||||
TOKEN=$(curl -s -X POST http://localhost:5000/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"key": "your-admin-key"}' | jq -r '.token')
|
||||
|
||||
# 3. Browse games for 6 players
|
||||
curl "http://localhost:5000/api/games?playerCount=6"
|
||||
|
||||
# 4. Pick a game for 6 players
|
||||
PICK=$(curl -s -X POST http://localhost:5000/api/pick \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"playerCount": 6}')
|
||||
GAME_ID=$(echo $PICK | jq -r '.game.id')
|
||||
|
||||
# 5. Start session
|
||||
SESSION=$(curl -s -X POST http://localhost:5000/api/sessions \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"notes": "Friday game night"}')
|
||||
SESSION_ID=$(echo $SESSION | jq -r '.id')
|
||||
|
||||
# 6. Add picked game to session
|
||||
curl -X POST "http://localhost:5000/api/sessions/$SESSION_ID/games" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"game_id\": $GAME_ID, \"room_code\": \"ABCD\"}"
|
||||
|
||||
# 7. Close session when done
|
||||
curl -X POST "http://localhost:5000/api/sessions/$SESSION_ID/close" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"notes": "Great session!"}'
|
||||
```
|
||||
|
||||
This assumes `jq` is installed for JSON parsing. Without it, extract IDs manually from the JSON responses.
|
||||
287
docs/api/guides/session-lifecycle.md
Normal file
287
docs/api/guides/session-lifecycle.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# Session Lifecycle Guide
|
||||
|
||||
This guide walks through the full lifecycle of a Jackbox gaming session—from creation through closing and deletion—with narrative explanations, behavior notes, and curl examples.
|
||||
|
||||
**Base URL:** `http://localhost:5000`
|
||||
**Authentication:** All write operations require a Bearer token. Set `TOKEN` in your shell and use `-H "Authorization: Bearer $TOKEN"` in curl examples.
|
||||
|
||||
---
|
||||
|
||||
## 1. Creating a Session
|
||||
|
||||
Only **one active session** can exist at a time. If an active session already exists, you must close it before creating a new one.
|
||||
|
||||
Notes are optional; they help you remember what a session was for (e.g., "Friday game night", "Birthday party").
|
||||
|
||||
Creating a session triggers a **`session.started`** WebSocket event broadcast to all authenticated clients. See [Real-time updates via WebSocket](#9-real-time-updates-via-websocket) for details.
|
||||
|
||||
**Endpoint:** [POST /api/sessions](../endpoints/sessions.md#post-apisessions)
|
||||
|
||||
```bash
|
||||
# Create a session with notes
|
||||
curl -X POST "http://localhost:5000/api/sessions" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"notes": "Friday game night"}'
|
||||
|
||||
# Create a session without notes (body can be empty)
|
||||
curl -X POST "http://localhost:5000/api/sessions" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
```
|
||||
|
||||
**Response (201 Created):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 5,
|
||||
"notes": "Friday game night",
|
||||
"is_active": 1,
|
||||
"created_at": "2026-03-15T19:00:00.000Z",
|
||||
"closed_at": null
|
||||
}
|
||||
```
|
||||
|
||||
If an active session already exists, you receive `400` with a message like `"An active session already exists. Please close it before creating a new one."` and an `activeSessionId` in the response.
|
||||
|
||||
---
|
||||
|
||||
## 2. Adding Games
|
||||
|
||||
You can add games in two ways: via the **picker** (weighted random selection) or **manually** by specifying a game ID.
|
||||
|
||||
### Via the Picker
|
||||
|
||||
First, use [POST /api/pick](../endpoints/picker.md#post-apipick) to select a game with filters and repeat avoidance. Then add that game to the session.
|
||||
|
||||
```bash
|
||||
# 1. Pick a game (optionally filter by player count, session for repeat avoidance)
|
||||
GAME=$(curl -s -X POST "http://localhost:5000/api/pick" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"playerCount": 6, "sessionId": 5}' | jq -r '.game.id')
|
||||
|
||||
# 2. Add the picked game to the session
|
||||
curl -X POST "http://localhost:5000/api/sessions/5/games" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"game_id\": $GAME, \"manually_added\": false}"
|
||||
```
|
||||
|
||||
### Manual Addition
|
||||
|
||||
Add a game directly by its `game_id` (from the games catalog):
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:5000/api/sessions/5/games" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"game_id": 42, "manually_added": true, "room_code": "ABCD"}'
|
||||
```
|
||||
|
||||
**Endpoint:** [POST /api/sessions/{id}/games](../endpoints/sessions.md#post-apisessionsidgames)
|
||||
|
||||
### Side Effects of Adding a Game
|
||||
|
||||
When you add a game to an active session, several things happen automatically:
|
||||
|
||||
1. **Previous `playing` games** are auto-transitioned to **`played`**. At most one game is `playing` at a time.
|
||||
2. The game's **`play_count`** is incremented in the catalog.
|
||||
3. The **`game.added`** webhook is fired (if you have webhooks configured) and a **`game.added`** WebSocket event is broadcast to session subscribers.
|
||||
4. If you provide a **`room_code`**, the room monitor is **auto-started** for player count tracking.
|
||||
|
||||
Newly added games start with status **`playing`**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tracking Game Status
|
||||
|
||||
Each game in a session has a status: **`playing`**, **`played`**, or **`skipped`**.
|
||||
|
||||
| Status | Meaning |
|
||||
|----------|-------------------------------------------|
|
||||
| `playing`| Currently being played (at most one at a time) |
|
||||
| `played` | Finished playing |
|
||||
| `skipped`| Skipped (e.g., technical issues); stays skipped |
|
||||
|
||||
**Behavior:** When you change a game's status to **`playing`**, any other games with status `playing` are automatically set to **`played`**. Skipped games are never auto-transitioned; they remain `skipped`.
|
||||
|
||||
**Endpoint:** [PATCH /api/sessions/{sessionId}/games/{sessionGameId}/status](../endpoints/sessions.md#patch-apisessionssessionidgamessessiongameidstatus)
|
||||
|
||||
**Important:** In session game sub-routes, `sessionGameId` refers to **`session_games.id`** (the row in the `session_games` table), **not** `games.id`. When listing session games with `GET /api/sessions/{id}/games`, the `id` field in each object is the `session_games.id`.
|
||||
|
||||
```bash
|
||||
# Mark a game as played (sessionGameId 14, not game_id)
|
||||
curl -X PATCH "http://localhost:5000/api/sessions/5/games/14/status" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "played"}'
|
||||
|
||||
# Mark a game as playing (others playing → played)
|
||||
curl -X PATCH "http://localhost:5000/api/sessions/5/games/14/status" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "playing"}'
|
||||
|
||||
# Mark a game as skipped
|
||||
curl -X PATCH "http://localhost:5000/api/sessions/5/games/14/status" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "skipped"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Room Codes
|
||||
|
||||
Room codes are 4-character strings used by Jackbox games for lobby entry. Valid format: exactly 4 characters, uppercase letters (A–Z) and digits (0–9) only. Example: `ABCD`, `XY9Z`.
|
||||
|
||||
A room code enables **room monitoring** for player count. You can set or update it when adding a game or via a dedicated PATCH endpoint.
|
||||
|
||||
**Endpoint:** [PATCH /api/sessions/{sessionId}/games/{sessionGameId}/room-code](../endpoints/sessions.md#patch-apisessionssessionidgamessessiongameidroom-code)
|
||||
|
||||
```bash
|
||||
# Set room code when adding a game
|
||||
curl -X POST "http://localhost:5000/api/sessions/5/games" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"game_id": 42, "room_code": "ABCD"}'
|
||||
|
||||
# Update room code later
|
||||
curl -X PATCH "http://localhost:5000/api/sessions/5/games/14/room-code" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"room_code": "XY9Z"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Player Count Monitoring
|
||||
|
||||
For games with a room code, you can track how many players join. The room monitor polls the Jackbox lobby to detect player count changes.
|
||||
|
||||
- **Start monitoring:** [POST /api/sessions/{sessionId}/games/{sessionGameId}/start-player-check](../endpoints/sessions.md#post-apisessionssessionidgamessessiongameidstart-player-check)
|
||||
- **Stop monitoring:** [POST /api/sessions/{sessionId}/games/{sessionGameId}/stop-player-check](../endpoints/sessions.md#post-apisessionssessionidgamessessiongameidstop-player-check)
|
||||
- **Manual update:** [PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count](../endpoints/sessions.md#patch-apisessionssessionidgamessessiongameidplayer-count)
|
||||
|
||||
When the player count changes (via room monitor or manual update), a **`player-count.updated`** WebSocket event is broadcast to session subscribers.
|
||||
|
||||
```bash
|
||||
# Start room monitor (game must have a room code)
|
||||
curl -X POST "http://localhost:5000/api/sessions/5/games/14/start-player-check" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Manually set player count
|
||||
curl -X PATCH "http://localhost:5000/api/sessions/5/games/14/player-count" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"player_count": 6}'
|
||||
|
||||
# Stop monitoring
|
||||
curl -X POST "http://localhost:5000/api/sessions/5/games/14/stop-player-check" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Closing Sessions
|
||||
|
||||
Closing a session marks it as inactive. The API:
|
||||
|
||||
1. Auto-finalizes all games with status **`playing`** to **`played`**
|
||||
2. Sets `closed_at` and `is_active = 0`
|
||||
3. Triggers a **`session.ended`** WebSocket broadcast to session subscribers
|
||||
|
||||
You can add or update session notes in the close request body.
|
||||
|
||||
**Endpoint:** [POST /api/sessions/{id}/close](../endpoints/sessions.md#post-apisessionsidclose)
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:5000/api/sessions/5/close" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"notes": "Great session!"}'
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 5,
|
||||
"notes": "Great session!",
|
||||
"is_active": 0,
|
||||
"created_at": "2026-03-15T19:00:00.000Z",
|
||||
"closed_at": "2026-03-15T23:30:00.000Z",
|
||||
"games_played": 4
|
||||
}
|
||||
```
|
||||
|
||||
You cannot add games to a closed session.
|
||||
|
||||
---
|
||||
|
||||
## 7. Exporting Session Data
|
||||
|
||||
Export a session in two formats: **JSON** (structured) or **TXT** (human-readable).
|
||||
|
||||
**Endpoint:** [GET /api/sessions/{id}/export](../endpoints/sessions.md#get-apisessionsidexport)
|
||||
|
||||
- **JSON** (`?format=json`): Includes `session`, `games`, and `chat_logs` as structured data. Useful for archival or integrations.
|
||||
- **TXT** (default): Human-readable plaintext with headers and sections.
|
||||
|
||||
```bash
|
||||
# Export as JSON
|
||||
curl -o session-5.json "http://localhost:5000/api/sessions/5/export?format=json" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Export as TXT (default)
|
||||
curl -o session-5.txt "http://localhost:5000/api/sessions/5/export" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Deleting Sessions
|
||||
|
||||
Sessions must be **closed** before deletion. Active sessions cannot be deleted.
|
||||
|
||||
Deletion **cascades** to related data:
|
||||
|
||||
- `session_games` rows are deleted
|
||||
- `chat_logs` rows are deleted
|
||||
|
||||
**Endpoint:** [DELETE /api/sessions/{id}](../endpoints/sessions.md#delete-apisessionsid)
|
||||
|
||||
```bash
|
||||
curl -X DELETE "http://localhost:5000/api/sessions/5" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Session deleted successfully",
|
||||
"sessionId": 5
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Real-time Updates via WebSocket
|
||||
|
||||
The API provides real-time updates over WebSocket for session events: `session.started`, `game.added`, `session.ended`, `player-count.updated`, and `vote.received`. Connect to `/api/sessions/live`, authenticate with your JWT, and subscribe to session IDs to receive these events without polling.
|
||||
|
||||
For connection setup, message types, and event payloads, see [WebSocket Protocol](../websocket.md).
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: sessionGameId vs game_id
|
||||
|
||||
| Context | ID meaning | Example |
|
||||
|---------|------------|---------|
|
||||
| `POST /api/sessions/{id}/games` body | `game_id` = catalog `games.id` | `{"game_id": 42}` |
|
||||
| `GET /api/sessions/{id}/games` response `id` | `session_games.id` | Use `14` in sub-routes |
|
||||
| `PATCH .../games/{sessionGameId}/status` | `sessionGameId` = `session_games.id` | `/sessions/5/games/14/status` |
|
||||
|
||||
When in doubt: session game sub-routes use **`session_games.id`**, not `games.id`.
|
||||
218
docs/api/guides/voting-and-popularity.md
Normal file
218
docs/api/guides/voting-and-popularity.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Voting and Popularity
|
||||
|
||||
A narrative guide to how the Jackbox Game Picker handles community voting and game popularity. This system lets viewers and stream chat influence which games rise to the top—without directly controlling the random picker.
|
||||
|
||||
---
|
||||
|
||||
## 1. How Popularity Works
|
||||
|
||||
Every game has a **popularity score** stored in the database:
|
||||
|
||||
```
|
||||
popularity_score = upvotes - downvotes
|
||||
```
|
||||
|
||||
The score is computed from `upvotes` and `downvotes` and persisted per game. As votes accumulate across sessions, the score reflects community sentiment over time.
|
||||
|
||||
**Important:** Popularity is used for **rankings** (e.g., "top rated games" in stats) but **does not directly affect picker weights**. The random picker uses favor bias, not popularity, when selecting games.
|
||||
|
||||
---
|
||||
|
||||
## 2. Favor Bias vs Popularity
|
||||
|
||||
Two separate systems govern how games are treated:
|
||||
|
||||
| Aspect | **Favor Bias** | **Popularity** |
|
||||
|--------|----------------|----------------|
|
||||
| Who controls it | Admin (via API) | Community (via votes) |
|
||||
| Values | `-1` (disfavor), `0` (neutral), `1` (favor) | `upvotes - downvotes` (unbounded) |
|
||||
| Affects picker? | Yes — directly changes weights | No |
|
||||
| Purpose | Manual curation; push/penalize specific games | Community sentiment; rankings |
|
||||
|
||||
**Favor bias** affects picker probability directly. Setting `favor_bias` to `1` on a game boosts its weight; `-1` reduces it. See [Games favor endpoint](../endpoints/games.md#patch-apigamesidfavor) and [Picker weighted selection](../endpoints/picker.md#weighted-selection).
|
||||
|
||||
**Popularity** is driven entirely by viewer votes. It surfaces in stats (e.g., `topRatedGames`) and session game lists, but the picker does not read it. These systems are independent.
|
||||
|
||||
---
|
||||
|
||||
## 3. Two Voting Mechanisms
|
||||
|
||||
The API supports two ways to record votes: batch chat import (after the fact) and live votes (real-time from bots).
|
||||
|
||||
### Chat Import (Batch, After-the-Fact)
|
||||
|
||||
Collect Twitch or YouTube chat logs containing `thisgame++` (upvote) and `thisgame--` (downvote), then submit them in bulk.
|
||||
|
||||
**Flow:**
|
||||
1. Export chat logs with `username`, `message`, and `timestamp` for each message.
|
||||
2. Filter or pass messages; the API parses `thisgame++` and `thisgame--` from the `message` field.
|
||||
3. POST to `POST /api/sessions/{id}/chat-import` with a `chatData` array of `{ username, message, timestamp }`.
|
||||
4. The API matches each vote’s timestamp to the game that was playing at that time (using `played_at` intervals).
|
||||
5. Votes are deduplicated by SHA-256 hash of `username:message:timestamp`.
|
||||
6. Response includes `votesByGame` breakdown and `debug` info (e.g., session timeline, vote matches).
|
||||
|
||||
See [Sessions chat-import endpoint](../endpoints/sessions.md#post-apisessionsidchat-import).
|
||||
|
||||
### Live Votes (Real-Time, from Bots)
|
||||
|
||||
A bot sends individual votes during the stream. Each vote is processed immediately.
|
||||
|
||||
**Flow:**
|
||||
1. Bot detects `thisgame++` or `thisgame--` (or equivalent) in chat.
|
||||
2. Bot sends `POST /api/votes/live` with `{ username, vote, timestamp }`.
|
||||
3. `vote` must be `"up"` or `"down"`.
|
||||
4. `timestamp` must be ISO 8601 (e.g., `2026-03-15T20:30:00Z`).
|
||||
5. The API finds the active session and matches the vote timestamp to the game playing at that time.
|
||||
6. **Deduplication:** Votes from the same username within 1 second are rejected with `409 Conflict`.
|
||||
|
||||
See [Votes live endpoint](../endpoints/votes.md#post-apivoteslive).
|
||||
|
||||
**Real-time tracking:** Live votes also broadcast a `vote.received` WebSocket event to all clients subscribed to the active session. This enables stream overlays and bots to react to votes in real-time without polling. See [WebSocket vote.received](../websocket.md#votereceived).
|
||||
|
||||
---
|
||||
|
||||
## 3b. Querying Vote Data
|
||||
|
||||
Two endpoints expose vote data for reading:
|
||||
|
||||
- **`GET /api/sessions/{id}/votes`** — Per-game vote breakdown for a session. Returns aggregated `upvotes`, `downvotes`, `net_score`, and `total_votes` per game. See [Sessions votes endpoint](../endpoints/sessions.md#get-apisessionsidvotes).
|
||||
- **`GET /api/votes`** — Paginated global vote history with filtering by `session_id`, `game_id`, `username`, and `vote_type`. Returns individual vote records. See [Votes list endpoint](../endpoints/votes.md#get-apivotes).
|
||||
|
||||
---
|
||||
|
||||
## 4. Timestamp Matching Explained
|
||||
|
||||
Games in a session have a `played_at` timestamp. A vote’s timestamp determines which game it belongs to.
|
||||
|
||||
**Rule:** A vote belongs to the game whose `played_at` is the **most recent one before** the vote timestamp.
|
||||
|
||||
Example session timeline:
|
||||
|
||||
- Game A: `played_at` 20:00
|
||||
- Game B: `played_at` 20:15
|
||||
- Game C: `played_at` 20:30
|
||||
|
||||
- Vote at 20:10 → Game A (last `played_at` before 20:10)
|
||||
- Vote at 20:20 → Game B
|
||||
- Vote at 20:45 → Game C (last game in session; captures all votes after it started)
|
||||
|
||||
The **last game** in the session captures all votes that occur after its `played_at`.
|
||||
|
||||
---
|
||||
|
||||
## 5. How Stats Reflect Popularity
|
||||
|
||||
`GET /api/stats` returns aggregate statistics, including:
|
||||
|
||||
- **mostPlayedGames** — top 10 by `play_count` (games with `play_count` > 0).
|
||||
- **topRatedGames** — top 10 by `popularity_score` (games with `popularity_score` > 0).
|
||||
|
||||
Both are limited to the top 10 and exclude games with score/count ≤ 0. See [Stats endpoint](../endpoints/stats.md).
|
||||
|
||||
---
|
||||
|
||||
## 6. Example Requests
|
||||
|
||||
### Chat Import
|
||||
|
||||
Import a batch of chat messages for session `5`:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:5000/api/sessions/5/chat-import" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"chatData": [
|
||||
{
|
||||
"username": "viewer1",
|
||||
"message": "thisgame++",
|
||||
"timestamp": "2026-03-15T20:30:00Z"
|
||||
},
|
||||
{
|
||||
"username": "viewer2",
|
||||
"message": "thisgame--",
|
||||
"timestamp": "2026-03-15T20:31:00Z"
|
||||
},
|
||||
{
|
||||
"username": "viewer3",
|
||||
"message": "thisgame++",
|
||||
"timestamp": "2026-03-15T20:32:00Z"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
**Sample response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Chat log imported and processed successfully",
|
||||
"messagesImported": 3,
|
||||
"duplicatesSkipped": 0,
|
||||
"votesProcessed": 3,
|
||||
"votesByGame": {
|
||||
"42": {
|
||||
"title": "Quiplash 3",
|
||||
"upvotes": 2,
|
||||
"downvotes": 1
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
"sessionGamesTimeline": [
|
||||
{
|
||||
"title": "Quiplash 3",
|
||||
"played_at": "2026-03-15T20:00:00.000Z",
|
||||
"played_at_ms": 1742068800000
|
||||
}
|
||||
],
|
||||
"voteMatches": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Live Vote
|
||||
|
||||
Submit a single live vote (requires active session):
|
||||
|
||||
```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"
|
||||
}'
|
||||
```
|
||||
|
||||
**Sample response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Vote recorded successfully",
|
||||
"session": { "id": 3, "games_played": 5 },
|
||||
"game": {
|
||||
"id": 42,
|
||||
"title": "Quiplash 3",
|
||||
"upvotes": 11,
|
||||
"downvotes": 2,
|
||||
"popularity_score": 9
|
||||
},
|
||||
"vote": {
|
||||
"username": "viewer123",
|
||||
"type": "up",
|
||||
"timestamp": "2026-03-15T20:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Sessions endpoints](../endpoints/sessions.md) — chat import, session games, `played_at`
|
||||
- [Votes endpoints](../endpoints/votes.md) — live votes, deduplication, errors
|
||||
- [Stats endpoints](../endpoints/stats.md) — `mostPlayedGames`, `topRatedGames`
|
||||
- [Picker endpoints](../endpoints/picker.md) — weighted selection, favor bias (no popularity)
|
||||
- [Games endpoints](../endpoints/games.md) — favor bias per game and pack
|
||||
217
docs/api/guides/webhooks-and-events.md
Normal file
217
docs/api/guides/webhooks-and-events.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# Webhooks and Events
|
||||
|
||||
A narrative guide to the Jackbox Game Picker event notification system: webhooks (HTTP callbacks) and WebSocket (persistent real-time connections). Both deliver event data about session and game activity.
|
||||
|
||||
---
|
||||
|
||||
## 1. Two Notification Systems
|
||||
|
||||
The API offers two complementary ways to receive event notifications:
|
||||
|
||||
| System | Model | Best for |
|
||||
|--------|-------|----------|
|
||||
| **Webhooks** | HTTP POST callbacks to your URL | Server-to-server, external integrations |
|
||||
| **WebSocket** | Persistent bidirectional connection | Real-time UIs, dashboards, live tools |
|
||||
|
||||
Both systems emit the same kinds of events (e.g. `game.added`) but differ in how they deliver them.
|
||||
|
||||
---
|
||||
|
||||
## 2. When to Use Which
|
||||
|
||||
### Use Webhooks when:
|
||||
|
||||
- **Server-to-server** — Discord bots, Slack, logging pipelines, external APIs
|
||||
- **Stateless** — Your endpoint receives a POST, processes it, and returns. No long-lived connection
|
||||
- **Behind firewalls** — Your server can receive HTTP but may not hold open WebSocket connections
|
||||
- **Async delivery** — You’re fine with HTTP round-trip latency and want delivery logged and auditable
|
||||
|
||||
### Use WebSocket when:
|
||||
|
||||
- **Real-time UI** — Dashboards, admin panels, live session viewers
|
||||
- **Instant updates** — You need push-style notifications with minimal latency
|
||||
- **Persistent connection** — Your app keeps a live connection and subscribes to specific sessions
|
||||
- **Best-effort is fine** — WebSocket is push-only; there’s no built-in delivery log for events
|
||||
|
||||
---
|
||||
|
||||
## 3. Webhook Setup
|
||||
|
||||
Webhooks are registered via the REST API. See [Webhooks endpoints](../endpoints/webhooks.md) for full CRUD details.
|
||||
|
||||
### Create a Webhook
|
||||
|
||||
`POST /api/webhooks` with:
|
||||
|
||||
- `name` — Display name (e.g. `"Discord Bot"`)
|
||||
- `url` — Callback URL (must be a valid HTTP/HTTPS URL)
|
||||
- `secret` — Shared secret for signing payloads (HMAC-SHA256)
|
||||
- `events` — Array of event types that trigger this webhook (e.g. `["game.added"]`)
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:5000/api/webhooks" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Discord Bot",
|
||||
"url": "https://my-server.com/webhooks/jackbox",
|
||||
"secret": "mysecret123",
|
||||
"events": ["game.added"]
|
||||
}'
|
||||
```
|
||||
|
||||
The `events` array defines which events fire this webhook. Currently, the codebase triggers webhooks for **`game.added`** when a game is added to a session. The `triggerWebhook` function in `backend/utils/webhooks.js` is invoked from `sessions.js` on that event.
|
||||
|
||||
### Update, Enable/Disable, Delete
|
||||
|
||||
- **Update:** `PATCH /api/webhooks/{id}` — Change `name`, `url`, `secret`, `events`, or `enabled`
|
||||
- **Disable:** `PATCH /api/webhooks/{id}` with `"enabled": false` — Stops delivery without deleting config
|
||||
- **Delete:** `DELETE /api/webhooks/{id}` — Removes webhook and its logs
|
||||
|
||||
---
|
||||
|
||||
## 4. Webhook Delivery
|
||||
|
||||
### How it works
|
||||
|
||||
When an event occurs (e.g. a game is added), the server:
|
||||
|
||||
1. Finds all enabled webhooks subscribed to that event
|
||||
2. Sends an async HTTP POST to each webhook URL
|
||||
3. Logs each delivery attempt in `webhook_logs` (status, error, payload)
|
||||
|
||||
### Payload format
|
||||
|
||||
Each POST body is JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "game.added",
|
||||
"timestamp": "2026-03-15T20:30:00.000Z",
|
||||
"data": {
|
||||
"session": { "id": 3, "is_active": true, "games_played": 2 },
|
||||
"game": {
|
||||
"id": 42,
|
||||
"title": "Quiplash 3",
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"min_players": 3,
|
||||
"max_players": 8,
|
||||
"manually_added": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Headers include:
|
||||
|
||||
- `Content-Type: application/json`
|
||||
- `X-Webhook-Event: game.added`
|
||||
- `X-Webhook-Signature: sha256=<hmac>` — Use your `secret` to verify the payload
|
||||
|
||||
### View delivery logs
|
||||
|
||||
`GET /api/webhooks/{id}/logs` returns recent delivery attempts (status, error message, payload).
|
||||
|
||||
### Test a webhook
|
||||
|
||||
`POST /api/webhooks/test/{id}` sends a dummy `game.added` event to the webhook URL. Delivery runs asynchronously; check logs for status.
|
||||
|
||||
---
|
||||
|
||||
## 5. WebSocket Events
|
||||
|
||||
The WebSocket server runs at `/api/sessions/live` on the same host and port as the HTTP API. See [WebSocket protocol](../websocket.md) for connection, authentication, and subscription details.
|
||||
|
||||
### Event types and audience
|
||||
|
||||
| Event | Broadcast to | Triggered by |
|
||||
|-------|--------------|--------------|
|
||||
| `session.started` | All authenticated clients | `POST /api/sessions` |
|
||||
| `game.added` | Session subscribers | `POST /api/sessions/{id}/games` |
|
||||
| `session.ended` | Session subscribers | `POST /api/sessions/{id}/close` |
|
||||
| `player-count.updated` | Session subscribers | `PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count` |
|
||||
| `vote.received` | Session subscribers | `POST /api/votes/live` (live votes only, not chat-import) |
|
||||
|
||||
`session.started` goes to every authenticated client. The others go only to clients that have subscribed to the relevant session via `{ "type": "subscribe", "sessionId": 3 }`.
|
||||
|
||||
### Envelope format
|
||||
|
||||
All events use this envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "<event-type>",
|
||||
"timestamp": "2026-03-15T20:30:00.000Z",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
`data` contains event-specific fields (session, game, player count, etc.) as described in [WebSocket protocol](../websocket.md).
|
||||
|
||||
---
|
||||
|
||||
## 6. Comparison
|
||||
|
||||
| Feature | Webhooks | WebSocket |
|
||||
|---------|----------|-----------|
|
||||
| **Connection** | Stateless HTTP | Persistent |
|
||||
| **Auth** | Secret in config | JWT per connection |
|
||||
| **Events** | `game.added` | `session.started`, `game.added`, `session.ended`, `player-count.updated`, `vote.received` |
|
||||
| **Latency** | Higher (HTTP round trip) | Lower (push) |
|
||||
| **Reliability** | Logged, auditable | Best-effort |
|
||||
|
||||
---
|
||||
|
||||
## 7. Example: Discord Bot
|
||||
|
||||
Use a webhook to post game additions to a Discord channel. You’ll need:
|
||||
|
||||
1. A webhook created in the Game Picker API pointing to your server
|
||||
2. A small server that receives the webhook and forwards to Discord’s Incoming Webhook
|
||||
|
||||
**Webhook receiver (Node.js):**
|
||||
|
||||
```javascript
|
||||
const crypto = require('crypto');
|
||||
|
||||
app.post('/webhooks/jackbox', express.json(), (req, res) => {
|
||||
const signature = req.headers['x-webhook-signature'];
|
||||
const payload = JSON.stringify(req.body);
|
||||
|
||||
// Verify HMAC-SHA256 using your webhook secret
|
||||
const expected = 'sha256=' + crypto
|
||||
.createHmac('sha256', process.env.WEBHOOK_SECRET)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
|
||||
if (signature !== expected) {
|
||||
return res.status(401).send('Invalid signature');
|
||||
}
|
||||
|
||||
if (req.body.event === 'game.added') {
|
||||
const { session, game } = req.body.data;
|
||||
const discordPayload = {
|
||||
content: `🎮 **${game.title}** added to session #${session.id} (${game.min_players}-${game.max_players} players)`
|
||||
};
|
||||
|
||||
fetch(process.env.DISCORD_WEBHOOK_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(discordPayload)
|
||||
}).catch(err => console.error('Discord post failed:', err));
|
||||
}
|
||||
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
```
|
||||
|
||||
Register the Game Picker webhook with your server’s URL (e.g. `https://my-bot.example.com/webhooks/jackbox`), set `events` to `["game.added"]`, and use the same `secret` in your server’s `WEBHOOK_SECRET`.
|
||||
|
||||
---
|
||||
|
||||
## Cross-references
|
||||
|
||||
- **[Webhooks endpoints](../endpoints/webhooks.md)** — Full CRUD, request/response schemas, errors
|
||||
- **[WebSocket protocol](../websocket.md)** — Connection, auth, subscriptions, event payloads
|
||||
1799
docs/api/openapi.yaml
Normal file
1799
docs/api/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
598
docs/api/websocket.md
Normal file
598
docs/api/websocket.md
Normal file
@@ -0,0 +1,598 @@
|
||||
# WebSocket Protocol
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The WebSocket API provides real-time updates for Jackbox gaming sessions. Use it to:
|
||||
|
||||
- Receive notifications when sessions start, end, or when games are added
|
||||
- Monitor Jackbox room state in real-time (lobby, player joins, game start/end)
|
||||
- Track player counts automatically via shard connection
|
||||
- Receive live vote updates (upvotes/downvotes) as viewers vote
|
||||
- Avoid polling REST endpoints for session state changes
|
||||
|
||||
The WebSocket server runs on the same host and port as the HTTP API. Connect to `/api/sessions/live` to establish a live connection.
|
||||
|
||||
---
|
||||
|
||||
## 2. Connection Setup
|
||||
|
||||
**URL:** `ws://host:port/api/sessions/live`
|
||||
|
||||
- Use `ws://` for HTTP and `wss://` for HTTPS
|
||||
- No query parameters are required
|
||||
- Connection can be established without authentication (auth happens via a message after connect)
|
||||
|
||||
**JavaScript example:**
|
||||
|
||||
```javascript
|
||||
const host = 'localhost';
|
||||
const port = 5000;
|
||||
const protocol = 'ws';
|
||||
const ws = new WebSocket(`${protocol}://${host}:${port}/api/sessions/live`);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('Connected');
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Authentication
|
||||
|
||||
Authentication is required for subscribing to sessions and for receiving most events. Send your JWT token in an `auth` message after connecting.
|
||||
|
||||
**Send (client → server):**
|
||||
```json
|
||||
{ "type": "auth", "token": "<jwt>" }
|
||||
```
|
||||
|
||||
**Success response:**
|
||||
```json
|
||||
{ "type": "auth_success", "message": "Authenticated successfully" }
|
||||
```
|
||||
|
||||
**Failure responses:**
|
||||
```json
|
||||
{ "type": "auth_error", "message": "Invalid or expired token" }
|
||||
```
|
||||
```json
|
||||
{ "type": "auth_error", "message": "Token required" }
|
||||
```
|
||||
|
||||
**JavaScript example:**
|
||||
|
||||
```javascript
|
||||
// After opening the connection...
|
||||
ws.send(JSON.stringify({
|
||||
type: 'auth',
|
||||
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||
}));
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'auth_success') {
|
||||
console.log('Authenticated');
|
||||
} else if (msg.type === 'auth_error') {
|
||||
console.error('Auth failed:', msg.message);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Obtain a JWT by calling `POST /api/auth/login` with your admin key.
|
||||
|
||||
---
|
||||
|
||||
## 4. Message Types — Client to Server
|
||||
|
||||
| Type | Required Fields | Description |
|
||||
|-------------|-----------------|--------------------------------------|
|
||||
| `auth` | `token` | Authenticate with a JWT |
|
||||
| `subscribe` | `sessionId` | Subscribe to a session's events |
|
||||
| `unsubscribe`| `sessionId` | Unsubscribe from a session |
|
||||
| `ping` | — | Heartbeat; server responds with `pong` |
|
||||
|
||||
### auth
|
||||
```json
|
||||
{ "type": "auth", "token": "<jwt>" }
|
||||
```
|
||||
|
||||
### subscribe
|
||||
Must be authenticated. You can subscribe to multiple sessions.
|
||||
|
||||
```json
|
||||
{ "type": "subscribe", "sessionId": 3 }
|
||||
```
|
||||
|
||||
### unsubscribe
|
||||
Must be authenticated.
|
||||
|
||||
```json
|
||||
{ "type": "unsubscribe", "sessionId": 3 }
|
||||
```
|
||||
|
||||
### ping
|
||||
```json
|
||||
{ "type": "ping" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Message Types — Server to Client
|
||||
|
||||
| Type | Description |
|
||||
|---------------|------------------------------------------|
|
||||
| `auth_success`| Authentication succeeded |
|
||||
| `auth_error` | Authentication failed |
|
||||
| `subscribed` | Successfully subscribed to a session |
|
||||
| `unsubscribed`| Successfully unsubscribed from a session |
|
||||
| `pong` | Response to client `ping` |
|
||||
| `error` | General error (e.g., not authenticated) |
|
||||
| `session.started` | New session created (broadcast to all authenticated clients) |
|
||||
| `game.added` | Game added to a session (broadcast to subscribers) |
|
||||
| `session.ended` | Session closed (broadcast to subscribers) |
|
||||
| `room.connected` | Shard connected to Jackbox room (broadcast to subscribers) |
|
||||
| `lobby.player-joined` | Player joined the Jackbox lobby (broadcast to subscribers) |
|
||||
| `lobby.updated` | Lobby state changed (broadcast to subscribers) |
|
||||
| `game.started` | Game transitioned to Gameplay (broadcast to subscribers) |
|
||||
| `game.ended` | Game finished (broadcast to subscribers) |
|
||||
| `room.disconnected` | Shard lost connection to Jackbox room (broadcast to subscribers) |
|
||||
| `game.status` | Periodic game state heartbeat every 20s (broadcast to subscribers) |
|
||||
| `player-count.updated` | Manual player count override (broadcast to subscribers) |
|
||||
| `vote.received` | Live vote recorded (broadcast to subscribers) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Event Reference
|
||||
|
||||
All server-sent events use this envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "<event-type>",
|
||||
"timestamp": "2026-03-15T20:30:00.000Z",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### session.started
|
||||
|
||||
- **Broadcast to:** All authenticated clients (not session-specific)
|
||||
- **Triggered by:** `POST /api/sessions` (creating a new session)
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"session": {
|
||||
"id": 3,
|
||||
"is_active": 1,
|
||||
"created_at": "2026-03-15T20:00:00",
|
||||
"notes": "Friday game night"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### game.added
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** `POST /api/sessions/{id}/games` (adding a game)
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"session": {
|
||||
"id": 3,
|
||||
"is_active": true,
|
||||
"games_played": 5
|
||||
},
|
||||
"game": {
|
||||
"id": 42,
|
||||
"title": "Quiplash 3",
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"min_players": 3,
|
||||
"max_players": 8,
|
||||
"manually_added": false,
|
||||
"room_code": "ABCD"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### session.ended
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** `POST /api/sessions/{id}/close` (closing a session)
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"session": {
|
||||
"id": 3,
|
||||
"is_active": 0,
|
||||
"games_played": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### room.connected
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** Shard WebSocket successfully connecting to a Jackbox room (after `POST .../start-player-check` or adding a game with a room code)
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"appTag": "drawful2international",
|
||||
"maxPlayers": 8,
|
||||
"playerCount": 2,
|
||||
"players": ["Alice", "Bob"],
|
||||
"lobbyState": "CanStart",
|
||||
"gameState": "Lobby"
|
||||
}
|
||||
```
|
||||
|
||||
### lobby.player-joined
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** A new player joining the Jackbox room lobby (detected via `textDescriptions` entity updates or `client/connected` messages)
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"playerName": "Charlie",
|
||||
"playerCount": 3,
|
||||
"players": ["Alice", "Bob", "Charlie"],
|
||||
"maxPlayers": 8
|
||||
}
|
||||
```
|
||||
|
||||
### lobby.updated
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** Lobby state change in the Jackbox room (e.g., enough players to start, countdown started)
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"lobbyState": "Countdown",
|
||||
"gameCanStart": true,
|
||||
"gameIsStarting": true,
|
||||
"playerCount": 4
|
||||
}
|
||||
```
|
||||
|
||||
### game.started
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** Jackbox game transitioning from Lobby to Gameplay state
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"playerCount": 4,
|
||||
"players": ["Alice", "Bob", "Charlie", "Diana"],
|
||||
"maxPlayers": 8
|
||||
}
|
||||
```
|
||||
|
||||
### game.ended
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** Jackbox game finishing (`gameFinished: true`) or room closing
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"playerCount": 4,
|
||||
"players": ["Alice", "Bob", "Charlie", "Diana"]
|
||||
}
|
||||
```
|
||||
|
||||
### room.disconnected
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** Shard losing connection to the Jackbox room (room closed, connection failed, manually stopped)
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"reason": "room_closed",
|
||||
"finalPlayerCount": 4
|
||||
}
|
||||
```
|
||||
|
||||
Possible `reason` values: `room_closed`, `room_not_found`, `connection_failed`, `role_rejected`, `manually_stopped`.
|
||||
|
||||
### game.status
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** Periodic 20-second heartbeat from an active shard monitor. Also available on demand via `GET /api/sessions/{sessionId}/games/{sessionGameId}/status-live`.
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"appTag": "drawful2international",
|
||||
"maxPlayers": 8,
|
||||
"playerCount": 4,
|
||||
"players": ["Alice", "Bob", "Charlie", "Diana"],
|
||||
"lobbyState": "CanStart",
|
||||
"gameState": "Lobby",
|
||||
"gameStarted": false,
|
||||
"gameFinished": false,
|
||||
"monitoring": true
|
||||
}
|
||||
```
|
||||
|
||||
### player-count.updated
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** `PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count` (manual override only)
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": "3",
|
||||
"gameId": "7",
|
||||
"playerCount": 6,
|
||||
"status": "completed"
|
||||
}
|
||||
```
|
||||
|
||||
### vote.received
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** `POST /api/votes/live` (recording a live vote). Only fires for live votes, NOT chat-import.
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Error Handling
|
||||
|
||||
| Type | Message | When |
|
||||
|--------------|----------------------------------------|-----------------------------------------|
|
||||
| `error` | `Not authenticated` | subscribe/unsubscribe without auth |
|
||||
| `error` | `Session ID required` | subscribe without `sessionId` |
|
||||
| `error` | `Unknown message type: foo` | Unknown `type` in client message |
|
||||
| `error` | `Invalid message format` | Unparseable or non-JSON message |
|
||||
| `auth_error` | `Token required` | auth without token |
|
||||
| `auth_error` | `Invalid or expired token` | auth with invalid/expired JWT |
|
||||
|
||||
---
|
||||
|
||||
## 8. Heartbeat and Timeout
|
||||
|
||||
- **Client → Server:** Send `{ "type": "ping" }` periodically
|
||||
- **Server → Client:** Responds with `{ "type": "pong" }`
|
||||
- **Timeout:** If no ping is received for **60 seconds**, the server terminates the connection
|
||||
- **Server check:** The server checks for stale connections every **30 seconds**
|
||||
|
||||
Implement a heartbeat on the client to keep the connection alive:
|
||||
|
||||
```javascript
|
||||
let pingInterval;
|
||||
|
||||
function startHeartbeat() {
|
||||
pingInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 30000); // every 30 seconds
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
startHeartbeat();
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
clearInterval(pingInterval);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Reconnection
|
||||
|
||||
The server does **not** maintain state across disconnects. After reconnecting:
|
||||
|
||||
1. **Re-authenticate** with an `auth` message
|
||||
2. **Re-subscribe** to any sessions you were tracking
|
||||
|
||||
Implement exponential backoff for reconnection attempts:
|
||||
|
||||
```javascript
|
||||
let reconnectAttempts = 0;
|
||||
const maxReconnectAttempts = 10;
|
||||
const baseDelay = 1000;
|
||||
|
||||
function connect() {
|
||||
const ws = new WebSocket('ws://localhost:5000/api/sessions/live');
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempts = 0;
|
||||
ws.send(JSON.stringify({ type: 'auth', token: jwt }));
|
||||
// After auth_success, re-subscribe to sessions...
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (reconnectAttempts < maxReconnectAttempts) {
|
||||
const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts), 60000);
|
||||
reconnectAttempts++;
|
||||
setTimeout(connect, delay);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Complete Example
|
||||
|
||||
Full session lifecycle from connect to disconnect:
|
||||
|
||||
```javascript
|
||||
const JWT = 'your-jwt-token';
|
||||
const WS_URL = 'ws://localhost:5000/api/sessions/live';
|
||||
|
||||
const ws = new WebSocket(WS_URL);
|
||||
let pingInterval;
|
||||
let subscribedSessions = new Set();
|
||||
|
||||
function send(msg) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('Connected');
|
||||
send({ type: 'auth', token: JWT });
|
||||
|
||||
pingInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
send({ type: 'ping' });
|
||||
}
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'auth_success':
|
||||
console.log('Authenticated');
|
||||
send({ type: 'subscribe', sessionId: 3 });
|
||||
break;
|
||||
|
||||
case 'auth_error':
|
||||
console.error('Auth failed:', msg.message);
|
||||
break;
|
||||
|
||||
case 'subscribed':
|
||||
subscribedSessions.add(msg.sessionId);
|
||||
console.log('Subscribed to session', msg.sessionId);
|
||||
break;
|
||||
|
||||
case 'unsubscribed':
|
||||
subscribedSessions.delete(msg.sessionId);
|
||||
console.log('Unsubscribed from session', msg.sessionId);
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// Heartbeat acknowledged
|
||||
break;
|
||||
|
||||
case 'session.started':
|
||||
console.log('New session:', msg.data.session);
|
||||
break;
|
||||
|
||||
case 'game.added':
|
||||
console.log('Game added:', msg.data.game.title, 'to session', msg.data.session.id);
|
||||
break;
|
||||
|
||||
case 'session.ended':
|
||||
console.log('Session ended:', msg.data.session.id);
|
||||
subscribedSessions.delete(msg.data.session.id);
|
||||
break;
|
||||
|
||||
case 'room.connected':
|
||||
console.log('Room connected:', msg.data.roomCode, '- players:', msg.data.players.join(', '));
|
||||
break;
|
||||
|
||||
case 'lobby.player-joined':
|
||||
console.log('Player joined:', msg.data.playerName, '- count:', msg.data.playerCount);
|
||||
break;
|
||||
|
||||
case 'lobby.updated':
|
||||
console.log('Lobby:', msg.data.lobbyState);
|
||||
break;
|
||||
|
||||
case 'game.started':
|
||||
console.log('Game started with', msg.data.playerCount, 'players');
|
||||
break;
|
||||
|
||||
case 'game.ended':
|
||||
console.log('Game ended with', msg.data.playerCount, 'players');
|
||||
break;
|
||||
|
||||
case 'room.disconnected':
|
||||
console.log('Room disconnected:', msg.data.reason);
|
||||
break;
|
||||
|
||||
case 'game.status':
|
||||
console.log('Status heartbeat:', msg.data.roomCode, '- players:', msg.data.playerCount, '- state:', msg.data.gameState);
|
||||
break;
|
||||
|
||||
case 'player-count.updated':
|
||||
console.log('Player count:', msg.data.playerCount, 'for game', msg.data.gameId);
|
||||
break;
|
||||
|
||||
case 'vote.received':
|
||||
console.log('Vote:', msg.data.vote.type, 'from', msg.data.vote.username, 'for', msg.data.game.title, '- totals:', msg.data.totals);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
case 'auth_error':
|
||||
console.error('Error:', msg.message);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Unknown message:', msg);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => console.error('WebSocket error:', err);
|
||||
ws.onclose = () => {
|
||||
clearInterval(pingInterval);
|
||||
console.log('Disconnected');
|
||||
};
|
||||
|
||||
// Later: unsubscribe and close
|
||||
function disconnect() {
|
||||
subscribedSessions.forEach((sessionId) => {
|
||||
send({ type: 'unsubscribe', sessionId });
|
||||
});
|
||||
ws.close();
|
||||
}
|
||||
```
|
||||
514
docs/archive/API_QUICK_REFERENCE.md
Normal file
514
docs/archive/API_QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,514 @@
|
||||
# API Quick Reference
|
||||
|
||||
Quick reference for Live Voting, WebSocket, and Webhook endpoints.
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
http://localhost:5000/api
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints require JWT authentication:
|
||||
|
||||
```
|
||||
Authorization: Bearer YOUR_JWT_TOKEN
|
||||
```
|
||||
|
||||
Get token via:
|
||||
```bash
|
||||
POST /api/auth/login
|
||||
Body: { "key": "YOUR_ADMIN_KEY" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
### Connect to WebSocket
|
||||
|
||||
```
|
||||
ws://localhost:5000/api/sessions/live
|
||||
```
|
||||
|
||||
**Message Protocol**:
|
||||
|
||||
```json
|
||||
// Authenticate
|
||||
{ "type": "auth", "token": "YOUR_JWT_TOKEN" }
|
||||
|
||||
// Subscribe to session
|
||||
{ "type": "subscribe", "sessionId": 123 }
|
||||
|
||||
// Unsubscribe
|
||||
{ "type": "unsubscribe", "sessionId": 123 }
|
||||
|
||||
// Heartbeat
|
||||
{ "type": "ping" }
|
||||
```
|
||||
|
||||
**Server Events**:
|
||||
|
||||
```json
|
||||
// Auth success
|
||||
{ "type": "auth_success", "message": "..." }
|
||||
|
||||
// Subscribed
|
||||
{ "type": "subscribed", "sessionId": 123 }
|
||||
|
||||
// Session started
|
||||
{
|
||||
"type": "session.started",
|
||||
"timestamp": "2025-11-01T...",
|
||||
"data": {
|
||||
"session": { "id": 123, "is_active": 1, "created_at": "...", "notes": "..." }
|
||||
}
|
||||
}
|
||||
|
||||
// Game added
|
||||
{
|
||||
"type": "game.added",
|
||||
"timestamp": "2025-11-01T...",
|
||||
"data": {
|
||||
"session": { "id": 123, "is_active": true, "games_played": 5 },
|
||||
"game": { "id": 45, "title": "Fibbage 4", ... }
|
||||
}
|
||||
}
|
||||
|
||||
// Session ended
|
||||
{
|
||||
"type": "session.ended",
|
||||
"timestamp": "2025-11-01T...",
|
||||
"data": {
|
||||
"session": { "id": 123, "is_active": 0, "games_played": 5 }
|
||||
}
|
||||
}
|
||||
|
||||
// Vote received (live votes only, not chat-import)
|
||||
{
|
||||
"type": "vote.received",
|
||||
"timestamp": "2025-11-01T...",
|
||||
"data": {
|
||||
"sessionId": 123,
|
||||
"game": { "id": 42, "title": "Quiplash 3", "pack_name": "Party Pack 7" },
|
||||
"vote": { "username": "viewer123", "type": "up", "timestamp": "2025-11-01T20:30:00Z" },
|
||||
"totals": { "upvotes": 14, "downvotes": 3, "popularity_score": 11 }
|
||||
}
|
||||
}
|
||||
|
||||
// Pong
|
||||
{ "type": "pong" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Votes
|
||||
|
||||
### Get Vote History
|
||||
|
||||
```http
|
||||
GET /api/votes?session_id=5&game_id=42&username=viewer123&vote_type=up&page=1&limit=50
|
||||
```
|
||||
|
||||
**Response (200)**:
|
||||
```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": "2025-11-01T20:30:00.000Z",
|
||||
"created_at": "2025-11-01T20:30:01.000Z"
|
||||
}
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 50, "total": 237, "total_pages": 5 }
|
||||
}
|
||||
```
|
||||
|
||||
All query parameters are optional. No authentication required.
|
||||
|
||||
### Submit Live Vote
|
||||
|
||||
```http
|
||||
POST /api/votes/live
|
||||
```
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"username": "string",
|
||||
"vote": "up" | "down",
|
||||
"timestamp": "2025-11-01T20:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response (200)**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Vote recorded successfully",
|
||||
"session": { "id": 123, "games_played": 5 },
|
||||
"game": {
|
||||
"id": 45,
|
||||
"title": "Fibbage 4",
|
||||
"upvotes": 46,
|
||||
"downvotes": 3,
|
||||
"popularity_score": 43
|
||||
},
|
||||
"vote": {
|
||||
"username": "TestUser",
|
||||
"type": "up",
|
||||
"timestamp": "2025-11-01T20:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses**:
|
||||
- `400` - Invalid payload
|
||||
- `404` - No active session or timestamp doesn't match any game
|
||||
- `409` - Duplicate vote (within 1 second)
|
||||
|
||||
---
|
||||
|
||||
## Webhooks
|
||||
|
||||
### List Webhooks
|
||||
|
||||
```http
|
||||
GET /api/webhooks
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Kosmi Bot",
|
||||
"url": "http://bot-url/webhook",
|
||||
"events": ["game.added"],
|
||||
"enabled": true,
|
||||
"created_at": "2025-11-01T20:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Get Single Webhook
|
||||
|
||||
```http
|
||||
GET /api/webhooks/:id
|
||||
```
|
||||
|
||||
### Create Webhook
|
||||
|
||||
```http
|
||||
POST /api/webhooks
|
||||
```
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"name": "Kosmi Bot",
|
||||
"url": "http://bot-url/webhook",
|
||||
"secret": "your_shared_secret",
|
||||
"events": ["game.added"]
|
||||
}
|
||||
```
|
||||
|
||||
**Response (201)**:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Kosmi Bot",
|
||||
"url": "http://bot-url/webhook",
|
||||
"events": ["game.added"],
|
||||
"enabled": true,
|
||||
"created_at": "2025-11-01T20:00:00Z",
|
||||
"message": "Webhook created successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### Update Webhook
|
||||
|
||||
```http
|
||||
PATCH /api/webhooks/:id
|
||||
```
|
||||
|
||||
**Request Body** (all fields optional):
|
||||
```json
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"url": "http://new-url/webhook",
|
||||
"secret": "new_secret",
|
||||
"events": ["game.added"],
|
||||
"enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Webhook
|
||||
|
||||
```http
|
||||
DELETE /api/webhooks/:id
|
||||
```
|
||||
|
||||
**Response (200)**:
|
||||
```json
|
||||
{
|
||||
"message": "Webhook deleted successfully",
|
||||
"webhookId": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Test Webhook
|
||||
|
||||
```http
|
||||
POST /api/webhooks/test/:id
|
||||
```
|
||||
|
||||
Sends a test `game.added` event to verify webhook is working.
|
||||
|
||||
**Response (200)**:
|
||||
```json
|
||||
{
|
||||
"message": "Test webhook sent",
|
||||
"note": "Check webhook_logs table for delivery status"
|
||||
}
|
||||
```
|
||||
|
||||
### Get Webhook Logs
|
||||
|
||||
```http
|
||||
GET /api/webhooks/:id/logs?limit=50
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"webhook_id": 1,
|
||||
"event_type": "game.added",
|
||||
"payload": { /* full payload */ },
|
||||
"response_status": 200,
|
||||
"error_message": null,
|
||||
"created_at": "2025-11-01T20:30:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhook Payloads
|
||||
|
||||
### Event: `session.started`
|
||||
|
||||
Sent when a new session is created.
|
||||
|
||||
**Headers**:
|
||||
- `Content-Type: application/json`
|
||||
- `X-Webhook-Signature: sha256=<hmac_signature>`
|
||||
- `X-Webhook-Event: session.started`
|
||||
- `User-Agent: Jackbox-Game-Picker-Webhook/1.0`
|
||||
|
||||
**Payload**:
|
||||
```json
|
||||
{
|
||||
"event": "session.started",
|
||||
"timestamp": "2025-11-01T20:00:00Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 123,
|
||||
"is_active": 1,
|
||||
"created_at": "2025-11-01T20:00:00Z",
|
||||
"notes": "Friday Game Night"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event: `game.added`
|
||||
|
||||
Sent when a game is added to an active session.
|
||||
|
||||
**Headers**:
|
||||
- `Content-Type: application/json`
|
||||
- `X-Webhook-Signature: sha256=<hmac_signature>`
|
||||
- `X-Webhook-Event: game.added`
|
||||
- `User-Agent: Jackbox-Game-Picker-Webhook/1.0`
|
||||
|
||||
**Payload**:
|
||||
```json
|
||||
{
|
||||
"event": "game.added",
|
||||
"timestamp": "2025-11-01T20:30:00Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 123,
|
||||
"is_active": true,
|
||||
"games_played": 5
|
||||
},
|
||||
"game": {
|
||||
"id": 45,
|
||||
"title": "Fibbage 4",
|
||||
"pack_name": "The Jackbox Party Pack 9",
|
||||
"min_players": 2,
|
||||
"max_players": 8,
|
||||
"manually_added": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event: `session.ended`
|
||||
|
||||
Sent when a session is closed/ended.
|
||||
|
||||
**Headers**:
|
||||
- `Content-Type: application/json`
|
||||
- `X-Webhook-Signature: sha256=<hmac_signature>`
|
||||
- `X-Webhook-Event: session.ended`
|
||||
- `User-Agent: Jackbox-Game-Picker-Webhook/1.0`
|
||||
|
||||
**Payload**:
|
||||
```json
|
||||
{
|
||||
"event": "session.ended",
|
||||
"timestamp": "2025-11-01T20:30:00Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 123,
|
||||
"is_active": 0,
|
||||
"games_played": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## cURL Examples
|
||||
|
||||
### Submit Vote
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:5000/api/votes/live" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "TestUser",
|
||||
"vote": "up",
|
||||
"timestamp": "2025-11-01T20:30:00Z"
|
||||
}'
|
||||
```
|
||||
|
||||
### Create Webhook
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:5000/api/webhooks" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Kosmi Bot",
|
||||
"url": "http://localhost:3001/webhook/jackbox",
|
||||
"secret": "test_secret_123",
|
||||
"events": ["game.added"]
|
||||
}'
|
||||
```
|
||||
|
||||
### Test Webhook
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:5000/api/webhooks/test/1" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
### View Webhook Logs
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:5000/api/webhooks/1/logs?limit=10" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhook Signature Verification
|
||||
|
||||
**Node.js Example**:
|
||||
|
||||
```javascript
|
||||
const crypto = require('crypto');
|
||||
|
||||
function verifyWebhookSignature(signature, payload, secret) {
|
||||
if (!signature || !signature.startsWith('sha256=')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedSignature = 'sha256=' + crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(JSON.stringify(payload))
|
||||
.digest('hex');
|
||||
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature)
|
||||
);
|
||||
}
|
||||
|
||||
// In your webhook endpoint:
|
||||
app.post('/webhook/jackbox', (req, res) => {
|
||||
const signature = req.headers['x-webhook-signature'];
|
||||
const secret = process.env.WEBHOOK_SECRET;
|
||||
|
||||
if (!verifyWebhookSignature(signature, req.body, secret)) {
|
||||
return res.status(401).send('Invalid signature');
|
||||
}
|
||||
|
||||
// Process webhook...
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 200 | Success |
|
||||
| 201 | Created |
|
||||
| 400 | Bad Request - Invalid payload |
|
||||
| 401 | Unauthorized - Invalid JWT or signature |
|
||||
| 404 | Not Found - Resource doesn't exist |
|
||||
| 409 | Conflict - Duplicate vote |
|
||||
| 500 | Internal Server Error |
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Currently no rate limiting is implemented. Consider implementing rate limiting in production:
|
||||
- Per IP address
|
||||
- Per JWT token
|
||||
- Per webhook endpoint
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always verify webhook signatures** before processing
|
||||
2. **Use HTTPS** for webhook URLs in production
|
||||
3. **Store secrets securely** in environment variables
|
||||
4. **Respond quickly** to webhooks (< 5 seconds)
|
||||
5. **Log webhook activity** for debugging
|
||||
6. **Handle retries gracefully** if implementing retry logic
|
||||
7. **Validate timestamps** to prevent replay attacks
|
||||
|
||||
---
|
||||
|
||||
For detailed documentation, see [BOT_INTEGRATION.md](BOT_INTEGRATION.md)
|
||||
|
||||
759
docs/archive/BOT_INTEGRATION.md
Normal file
759
docs/archive/BOT_INTEGRATION.md
Normal file
@@ -0,0 +1,759 @@
|
||||
# Bot Integration Guide
|
||||
|
||||
This guide explains how to integrate your bot with the Jackbox Game Picker API for live voting and game notifications.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Live Voting (Bot → API)](#live-voting-bot--api)
|
||||
2. [Game Notifications (API → Bot)](#game-notifications-api--bot)
|
||||
- [WebSocket Integration (Recommended)](#websocket-integration-recommended)
|
||||
- [Webhook Integration](#webhook-integration)
|
||||
3. [Webhook Management](#webhook-management)
|
||||
4. [Testing](#testing)
|
||||
5. [Available Events](#available-events)
|
||||
|
||||
---
|
||||
|
||||
## Live Voting (Bot → API)
|
||||
|
||||
Your bot can send real-time votes to the API when it detects "thisgame++" or "thisgame--" in Kosmi chat.
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST /api/votes/live
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
Requires JWT token in Authorization header:
|
||||
|
||||
```
|
||||
Authorization: Bearer YOUR_JWT_TOKEN
|
||||
```
|
||||
|
||||
### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "string", // Username of the voter
|
||||
"vote": "up" | "down", // "up" for thisgame++, "down" for thisgame--
|
||||
"timestamp": "string" // ISO 8601 timestamp (e.g., "2025-11-01T20:30:00Z")
|
||||
}
|
||||
```
|
||||
|
||||
### Response (Success)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Vote recorded successfully",
|
||||
"session": {
|
||||
"id": 123,
|
||||
"games_played": 5
|
||||
},
|
||||
"game": {
|
||||
"id": 45,
|
||||
"title": "Fibbage 4",
|
||||
"upvotes": 46,
|
||||
"downvotes": 3,
|
||||
"popularity_score": 43
|
||||
},
|
||||
"vote": {
|
||||
"username": "TestUser",
|
||||
"type": "up",
|
||||
"timestamp": "2025-11-01T20:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
- **400 Bad Request**: Invalid payload or timestamp format
|
||||
- **404 Not Found**: No active session or timestamp doesn't match any game
|
||||
- **409 Conflict**: Duplicate vote (within 1 second of previous vote from same user)
|
||||
- **500 Internal Server Error**: Server error
|
||||
|
||||
### Example Implementation (Node.js)
|
||||
|
||||
```javascript
|
||||
// When bot detects "thisgame++" or "thisgame--" in Kosmi chat
|
||||
async function handleVote(username, message) {
|
||||
const isUpvote = message.includes('thisgame++');
|
||||
const isDownvote = message.includes('thisgame--');
|
||||
|
||||
if (!isUpvote && !isDownvote) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('http://your-api-url/api/votes/live', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.JWT_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
vote: isUpvote ? 'up' : 'down',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
console.log(`Vote recorded for ${data.game.title}: ${data.game.upvotes}👍 ${data.game.downvotes}👎`);
|
||||
} else {
|
||||
console.error('Vote failed:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending vote:', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **Deduplication**: Votes from the same user within 1 second are automatically rejected to prevent spam
|
||||
- **Timestamp Matching**: The API matches the vote timestamp to the correct game based on when games were played
|
||||
- **Active Session Required**: Votes can only be recorded when there's an active session with games played
|
||||
|
||||
---
|
||||
|
||||
## Game Notifications (API → Bot)
|
||||
|
||||
The API can notify your bot when games are added to a session, allowing you to announce "Coming up next: Game Title!" in Kosmi chat.
|
||||
|
||||
There are two integration methods available:
|
||||
|
||||
1. **WebSocket (Recommended)**: Real-time bidirectional communication, simpler setup, works through firewalls
|
||||
2. **Webhooks**: Traditional HTTP callbacks, good for serverless/stateless integrations
|
||||
|
||||
### WebSocket Integration (Recommended)
|
||||
|
||||
WebSocket provides real-time event streaming from the API to your bot. This is the recommended approach as it:
|
||||
|
||||
- Works through firewalls and NAT (bot initiates connection)
|
||||
- No need to expose inbound ports
|
||||
- Automatic reconnection on disconnect
|
||||
- Lower latency than webhooks
|
||||
- Bidirectional communication
|
||||
|
||||
#### Connection Flow
|
||||
|
||||
1. Bot connects to WebSocket endpoint
|
||||
2. Bot authenticates with JWT token
|
||||
3. Bot subscribes to active session
|
||||
4. Bot receives `game.added` events in real-time
|
||||
|
||||
#### WebSocket Endpoint
|
||||
|
||||
```
|
||||
wss://your-api-url/api/sessions/live
|
||||
```
|
||||
|
||||
#### Message Protocol
|
||||
|
||||
All messages are JSON-formatted.
|
||||
|
||||
**Client → Server Messages:**
|
||||
|
||||
```json
|
||||
// 1. Authenticate (first message after connecting)
|
||||
{
|
||||
"type": "auth",
|
||||
"token": "YOUR_JWT_TOKEN"
|
||||
}
|
||||
|
||||
// 2. Subscribe to a session
|
||||
{
|
||||
"type": "subscribe",
|
||||
"sessionId": 123
|
||||
}
|
||||
|
||||
// 3. Unsubscribe from a session
|
||||
{
|
||||
"type": "unsubscribe",
|
||||
"sessionId": 123
|
||||
}
|
||||
|
||||
// 4. Heartbeat (keep connection alive)
|
||||
{
|
||||
"type": "ping"
|
||||
}
|
||||
```
|
||||
|
||||
**Server → Client Messages:**
|
||||
|
||||
```json
|
||||
// Authentication success
|
||||
{
|
||||
"type": "auth_success",
|
||||
"message": "Authenticated successfully"
|
||||
}
|
||||
|
||||
// Authentication failure
|
||||
{
|
||||
"type": "auth_error",
|
||||
"message": "Invalid or expired token"
|
||||
}
|
||||
|
||||
// Subscription confirmed
|
||||
{
|
||||
"type": "subscribed",
|
||||
"sessionId": 123,
|
||||
"message": "Subscribed to session 123"
|
||||
}
|
||||
|
||||
// Game added event
|
||||
{
|
||||
"type": "game.added",
|
||||
"timestamp": "2025-11-01T20:30:00Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 123,
|
||||
"is_active": true,
|
||||
"games_played": 5
|
||||
},
|
||||
"game": {
|
||||
"id": 45,
|
||||
"title": "Fibbage 4",
|
||||
"pack_name": "The Jackbox Party Pack 9",
|
||||
"min_players": 2,
|
||||
"max_players": 8,
|
||||
"manually_added": false,
|
||||
"room_code": "JYET"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Session started event (broadcast to all authenticated clients)
|
||||
{
|
||||
"type": "session.started",
|
||||
"timestamp": "2025-11-01T20:00:00Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 123,
|
||||
"is_active": true,
|
||||
"created_at": "2025-11-01T20:00:00Z",
|
||||
"notes": "Friday game night"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Session ended event (broadcast to session subscribers)
|
||||
{
|
||||
"type": "session.ended",
|
||||
"timestamp": "2025-11-01T23:00:00Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 123,
|
||||
"is_active": false,
|
||||
"games_played": 8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Game started event (broadcast to session subscribers)
|
||||
// Fired when the Jackbox room becomes locked, meaning gameplay has begun
|
||||
{
|
||||
"type": "game.started",
|
||||
"timestamp": "2025-11-01T20:31:00Z",
|
||||
"data": {
|
||||
"sessionId": 123,
|
||||
"gameId": 456,
|
||||
"roomCode": "JYET",
|
||||
"maxPlayers": 8
|
||||
}
|
||||
}
|
||||
|
||||
// Audience joined event (broadcast to session subscribers)
|
||||
// Confirms the app successfully joined a Jackbox room as an audience member
|
||||
{
|
||||
"type": "audience.joined",
|
||||
"timestamp": "2025-11-01T20:31:05Z",
|
||||
"data": {
|
||||
"sessionId": 123,
|
||||
"gameId": 456,
|
||||
"roomCode": "JYET"
|
||||
}
|
||||
}
|
||||
|
||||
// Heartbeat response
|
||||
{
|
||||
"type": "pong"
|
||||
}
|
||||
|
||||
// Error
|
||||
{
|
||||
"type": "error",
|
||||
"message": "Error description"
|
||||
}
|
||||
```
|
||||
|
||||
#### Example Implementation (Node.js)
|
||||
|
||||
```javascript
|
||||
const WebSocket = require('ws');
|
||||
|
||||
class JackboxWebSocketClient {
|
||||
constructor(apiURL, jwtToken) {
|
||||
this.apiURL = apiURL.replace(/^http/, 'ws') + '/api/sessions/live';
|
||||
this.jwtToken = jwtToken;
|
||||
this.ws = null;
|
||||
this.reconnectDelay = 1000;
|
||||
this.maxReconnectDelay = 30000;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.ws = new WebSocket(this.apiURL);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log('WebSocket connected');
|
||||
this.authenticate();
|
||||
this.startHeartbeat();
|
||||
});
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
this.handleMessage(JSON.parse(data));
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
console.log('WebSocket disconnected, reconnecting...');
|
||||
this.reconnect();
|
||||
});
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
console.error('WebSocket error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
authenticate() {
|
||||
this.send({ type: 'auth', token: this.jwtToken });
|
||||
}
|
||||
|
||||
subscribe(sessionId) {
|
||||
this.send({ type: 'subscribe', sessionId });
|
||||
}
|
||||
|
||||
send(message) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(message) {
|
||||
switch (message.type) {
|
||||
case 'auth_success':
|
||||
console.log('Authenticated successfully');
|
||||
// Get active session and subscribe
|
||||
this.getActiveSessionAndSubscribe();
|
||||
break;
|
||||
|
||||
case 'auth_error':
|
||||
console.error('Authentication failed:', message.message);
|
||||
break;
|
||||
|
||||
case 'subscribed':
|
||||
console.log('Subscribed to session:', message.sessionId);
|
||||
break;
|
||||
|
||||
case 'game.added':
|
||||
this.handleGameAdded(message.data);
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// Heartbeat response
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('Server error:', message.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleGameAdded(data) {
|
||||
const { game } = data;
|
||||
|
||||
// Build announcement with room code if available
|
||||
let announcement = `🎮 Coming up next: ${game.title}!`;
|
||||
if (game.room_code) {
|
||||
announcement += ` Join at jackbox.tv with code: ${game.room_code}`;
|
||||
}
|
||||
|
||||
// Send to your chat platform (e.g., Kosmi chat)
|
||||
this.broadcastToChat(announcement);
|
||||
}
|
||||
|
||||
startHeartbeat() {
|
||||
setInterval(() => {
|
||||
this.send({ type: 'ping' });
|
||||
}, 30000); // Every 30 seconds
|
||||
}
|
||||
|
||||
reconnect() {
|
||||
setTimeout(() => {
|
||||
this.connect();
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
|
||||
async getActiveSessionAndSubscribe() {
|
||||
// Fetch active session from REST API
|
||||
const response = await fetch(`${this.apiURL.replace('/api/sessions/live', '')}/api/sessions/active`, {
|
||||
headers: { 'Authorization': `Bearer ${this.jwtToken}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const session = await response.json();
|
||||
if (session && session.id) {
|
||||
this.subscribe(session.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcastToChat(message) {
|
||||
// Implement your chat platform integration here
|
||||
console.log('Broadcasting:', message);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const client = new JackboxWebSocketClient('https://your-api-url', 'YOUR_JWT_TOKEN');
|
||||
client.connect();
|
||||
```
|
||||
|
||||
#### Example Implementation (Go)
|
||||
|
||||
See the reference implementation in `irc-kosmi-relay/bridge/jackbox/websocket_client.go`.
|
||||
|
||||
---
|
||||
|
||||
### Webhook Integration
|
||||
|
||||
Webhooks are HTTP callbacks sent from the API to your bot when events occur. This is an alternative to WebSocket for bots that prefer stateless integrations.
|
||||
|
||||
#### Webhook Event: `game.added`
|
||||
|
||||
Triggered whenever a game is added to an active session (either via picker or manual selection).
|
||||
|
||||
### Webhook Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "game.added",
|
||||
"timestamp": "2025-11-01T20:30:00Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 123,
|
||||
"is_active": true,
|
||||
"games_played": 5
|
||||
},
|
||||
"game": {
|
||||
"id": 45,
|
||||
"title": "Fibbage 4",
|
||||
"pack_name": "The Jackbox Party Pack 9",
|
||||
"min_players": 2,
|
||||
"max_players": 8,
|
||||
"manually_added": false,
|
||||
"room_code": "JYET"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** `room_code` is the 4-character Jackbox room code (e.g. `"JYET"`). It will be `null` if no room code was provided when the game was added.
|
||||
|
||||
### Webhook Headers
|
||||
|
||||
The API sends the following headers with each webhook:
|
||||
|
||||
- `Content-Type: application/json`
|
||||
- `X-Webhook-Signature: sha256=<hmac_signature>` - HMAC-SHA256 signature for verification
|
||||
- `X-Webhook-Event: game.added` - Event type
|
||||
- `User-Agent: Jackbox-Game-Picker-Webhook/1.0`
|
||||
|
||||
### Signature Verification
|
||||
|
||||
**IMPORTANT**: Always verify the webhook signature to ensure the request is authentic.
|
||||
|
||||
```javascript
|
||||
const crypto = require('crypto');
|
||||
|
||||
function verifyWebhookSignature(signature, payload, secret) {
|
||||
if (!signature || !signature.startsWith('sha256=')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedSignature = 'sha256=' + crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(JSON.stringify(payload))
|
||||
.digest('hex');
|
||||
|
||||
// Use timing-safe comparison
|
||||
try {
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature)
|
||||
);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example Implementation (Express.js)
|
||||
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const app = express();
|
||||
|
||||
// IMPORTANT: Use express.json() with verify option to get raw body
|
||||
app.use(express.json({
|
||||
verify: (req, res, buf) => {
|
||||
req.rawBody = buf.toString('utf8');
|
||||
}
|
||||
}));
|
||||
|
||||
app.post('/webhook/jackbox', (req, res) => {
|
||||
const signature = req.headers['x-webhook-signature'];
|
||||
const secret = process.env.WEBHOOK_SECRET; // Your webhook secret
|
||||
|
||||
// Verify signature
|
||||
if (!signature || !signature.startsWith('sha256=')) {
|
||||
return res.status(401).send('Missing or invalid signature');
|
||||
}
|
||||
|
||||
const expectedSignature = 'sha256=' + crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(req.rawBody)
|
||||
.digest('hex');
|
||||
|
||||
// Timing-safe comparison
|
||||
try {
|
||||
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
|
||||
return res.status(401).send('Invalid signature');
|
||||
}
|
||||
} catch (err) {
|
||||
return res.status(401).send('Invalid signature');
|
||||
}
|
||||
|
||||
// Handle the event
|
||||
if (req.body.event === 'game.added') {
|
||||
const game = req.body.data.game;
|
||||
|
||||
// Build announcement with room code if available
|
||||
let message = `🎮 Coming up next: ${game.title}!`;
|
||||
if (game.room_code) {
|
||||
message += ` Join at jackbox.tv with code: ${game.room_code}`;
|
||||
}
|
||||
|
||||
// Send message to Kosmi chat
|
||||
sendKosmiMessage(message);
|
||||
|
||||
console.log(`Announced game: ${game.title} from ${game.pack_name} (code: ${game.room_code || 'N/A'})`);
|
||||
}
|
||||
|
||||
// Always respond with 200 OK
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
|
||||
function sendKosmiMessage(message) {
|
||||
// Your Kosmi chat integration here
|
||||
console.log('Sending to Kosmi:', message);
|
||||
}
|
||||
|
||||
app.listen(3001, () => {
|
||||
console.log('Webhook receiver listening on port 3001');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhook Management
|
||||
|
||||
You can manage webhooks through the API using the following endpoints (all require JWT authentication).
|
||||
|
||||
### List All Webhooks
|
||||
|
||||
```bash
|
||||
GET /api/webhooks
|
||||
Authorization: Bearer YOUR_JWT_TOKEN
|
||||
```
|
||||
|
||||
### Create Webhook
|
||||
|
||||
```bash
|
||||
POST /api/webhooks
|
||||
Authorization: Bearer YOUR_JWT_TOKEN
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Kosmi Bot",
|
||||
"url": "http://your-bot-url/webhook/jackbox",
|
||||
"secret": "your_shared_secret_key",
|
||||
"events": ["game.added"]
|
||||
}
|
||||
```
|
||||
|
||||
### Update Webhook
|
||||
|
||||
```bash
|
||||
PATCH /api/webhooks/:id
|
||||
Authorization: Bearer YOUR_JWT_TOKEN
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"enabled": false // Disable webhook
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Webhook
|
||||
|
||||
```bash
|
||||
DELETE /api/webhooks/:id
|
||||
Authorization: Bearer YOUR_JWT_TOKEN
|
||||
```
|
||||
|
||||
### Test Webhook
|
||||
|
||||
```bash
|
||||
POST /api/webhooks/test/:id
|
||||
Authorization: Bearer YOUR_JWT_TOKEN
|
||||
```
|
||||
|
||||
Sends a test `game.added` event to verify your webhook is working.
|
||||
|
||||
### View Webhook Logs
|
||||
|
||||
```bash
|
||||
GET /api/webhooks/:id/logs?limit=50
|
||||
Authorization: Bearer YOUR_JWT_TOKEN
|
||||
```
|
||||
|
||||
Returns recent webhook delivery attempts with status codes and errors.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Live Voting
|
||||
|
||||
```bash
|
||||
# Get your JWT token first
|
||||
curl -X POST "http://localhost:5000/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"apiKey": "YOUR_API_KEY"}'
|
||||
|
||||
# Send a test vote
|
||||
curl -X POST "http://localhost:5000/api/votes/live" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "TestUser",
|
||||
"vote": "up",
|
||||
"timestamp": "2025-11-01T20:30:00Z"
|
||||
}'
|
||||
```
|
||||
|
||||
### Test Webhooks
|
||||
|
||||
```bash
|
||||
# Create a webhook
|
||||
curl -X POST "http://localhost:5000/api/webhooks" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Test Webhook",
|
||||
"url": "http://localhost:3001/webhook/jackbox",
|
||||
"secret": "test_secret_123",
|
||||
"events": ["game.added"]
|
||||
}'
|
||||
|
||||
# Test the webhook
|
||||
curl -X POST "http://localhost:5000/api/webhooks/test/1" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
|
||||
# Check webhook logs
|
||||
curl -X GET "http://localhost:5000/api/webhooks/1/logs" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Available Events
|
||||
|
||||
### Webhook Events
|
||||
|
||||
- `game.added` - Triggered when a game is added to an active session. Includes `room_code` (the 4-character Jackbox join code) if one was provided.
|
||||
|
||||
### WebSocket Events
|
||||
|
||||
- `game.added` - Triggered when a game is added to an active session. Sent to clients subscribed to that session. Includes `room_code`.
|
||||
- `session.started` - Triggered when a new session is created. Broadcast to **all** authenticated clients (no subscription required).
|
||||
- `session.ended` - Triggered when a session is closed. Sent to clients subscribed to that session.
|
||||
- `game.started` - Triggered when the Jackbox room becomes locked (gameplay has begun). Detected by polling the Jackbox REST API every 10 seconds via the room monitor. Sent to clients subscribed to that session. Includes `roomCode` and `maxPlayers`.
|
||||
- `audience.joined` - Triggered when the app successfully joins a Jackbox room as an audience member for player count tracking. Sent to clients subscribed to that session.
|
||||
- `player-count.updated` - Triggered when the player count for a game is updated. Sent to clients subscribed to that session.
|
||||
- `vote.received` - Triggered when a live vote is recorded via `POST /api/votes/live`. Sent to clients subscribed to that session. Includes voter username, vote direction, game info, and updated global vote totals. Does **not** fire for chat-import votes.
|
||||
|
||||
> **Tip:** To receive `session.started` events, your bot only needs to authenticate — no subscription is needed. Once you receive a `session.started` event, subscribe to the new session ID to receive `game.added`, `vote.received`, and `session.ended` events for it.
|
||||
|
||||
### Event Lifecycle (for a game with room code)
|
||||
|
||||
When a game is added with a room code, events fire in this order:
|
||||
|
||||
1. **`game.added`** — Game added to the session (immediate).
|
||||
2. **`game.started`** — Jackbox room becomes locked, gameplay has begun. Detected by a room monitor that polls the Jackbox REST API every 10 seconds. This is independent of the player count system.
|
||||
3. **`audience.joined`** — The player count bot successfully joined the Jackbox room as an audience member (seconds after `game.started`).
|
||||
4. **`player-count.updated`** (status: `checking`) — Player count data received from the game's WebSocket traffic (ongoing).
|
||||
5. **`player-count.updated`** (status: `completed`) — Game ended, final player count confirmed.
|
||||
|
||||
Room monitoring and player counting are separate systems. The room monitor (`room-monitor.js`) handles steps 1-2 and then hands off to the player count checker (`player-count-checker.js`) for steps 3-5.
|
||||
|
||||
More events may be added in the future.
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Always verify webhook signatures** - Never trust webhook payloads without verification
|
||||
2. **Use HTTPS in production** - Webhook URLs should use HTTPS to prevent man-in-the-middle attacks
|
||||
3. **Keep secrets secure** - Store webhook secrets in environment variables, never in code
|
||||
4. **Implement rate limiting** - Protect your webhook endpoints from abuse
|
||||
5. **Log webhook activity** - Keep logs of webhook deliveries for debugging
|
||||
6. **Use strong secrets** - Generate cryptographically secure random strings for webhook secrets
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Votes Not Being Recorded
|
||||
|
||||
- Check that there's an active session with games played
|
||||
- Verify the timestamp is within the timeframe of a played game
|
||||
- Ensure you're not sending duplicate votes within 1 second
|
||||
- Check API logs for error messages
|
||||
|
||||
### Webhooks Not Being Received
|
||||
|
||||
- Verify your webhook URL is publicly accessible
|
||||
- Check webhook logs via `/api/webhooks/:id/logs`
|
||||
- Test with `ngrok` or similar tool if developing locally
|
||||
- Ensure your webhook endpoint responds with 200 OK
|
||||
- Check that webhook is enabled in the database
|
||||
|
||||
### Signature Verification Failing
|
||||
|
||||
- Ensure you're using the raw request body for signature verification
|
||||
- Check that the secret matches what's stored in the database
|
||||
- Verify you're using HMAC-SHA256 algorithm
|
||||
- Make sure to prefix with "sha256=" when comparing
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions, contact: cottongin@cottongin.xyz
|
||||
|
||||
105
docs/archive/SESSION_END_QUICK_START.md
Normal file
105
docs/archive/SESSION_END_QUICK_START.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Session End Event - Quick Start Guide
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Listen for Session End Events
|
||||
|
||||
```javascript
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const ws = new WebSocket('ws://localhost:5000/api/sessions/live');
|
||||
|
||||
ws.on('open', () => {
|
||||
// 1. Authenticate
|
||||
ws.send(JSON.stringify({
|
||||
type: 'auth',
|
||||
token: 'YOUR_JWT_TOKEN'
|
||||
}));
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
|
||||
if (msg.type === 'auth_success') {
|
||||
// 2. Subscribe to session
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
sessionId: 17
|
||||
}));
|
||||
}
|
||||
|
||||
if (msg.type === 'session.ended') {
|
||||
// 3. Handle session end
|
||||
console.log('Session ended!');
|
||||
console.log(`Games played: ${msg.data.session.games_played}`);
|
||||
// Announce to your users here
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 📦 Event Format
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "session.ended",
|
||||
"timestamp": "2025-11-01T02:30:45.123Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 17,
|
||||
"is_active": 0,
|
||||
"games_played": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 Test It
|
||||
|
||||
```bash
|
||||
# Get your JWT token first
|
||||
curl -X POST http://localhost:5000/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"key":"YOUR_ADMIN_KEY"}'
|
||||
|
||||
# Run the test script
|
||||
node ../tests/test-session-end-websocket.js 17 YOUR_JWT_TOKEN
|
||||
|
||||
# In another terminal, close the session
|
||||
curl -X POST http://localhost:5000/api/sessions/17/close \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
## 🤖 Bot Integration
|
||||
|
||||
When your bot receives `session.ended`:
|
||||
|
||||
```javascript
|
||||
if (msg.type === 'session.ended') {
|
||||
const { id, games_played } = msg.data.session;
|
||||
|
||||
// Announce to IRC/Discord/etc
|
||||
bot.announce(`🌙 Game Night has ended! We played ${games_played} games.`);
|
||||
bot.announce('Thanks for playing!');
|
||||
}
|
||||
```
|
||||
|
||||
## 📚 Full Documentation
|
||||
|
||||
See [SESSION_END_WEBSOCKET.md](SESSION_END_WEBSOCKET.md) for complete documentation.
|
||||
|
||||
## ⚡ Key Points
|
||||
|
||||
- ✅ **Instant** - No polling needed
|
||||
- ✅ **Reliable** - Broadcast to all subscribers
|
||||
- ✅ **Simple** - Same format as `game.added`
|
||||
- ✅ **Tested** - Test script included
|
||||
|
||||
## 🔗 Related Events
|
||||
|
||||
| Event | When |
|
||||
|-------|------|
|
||||
| `session.started` | Session created |
|
||||
| `game.added` | Game starts |
|
||||
| `session.ended` | Session closes |
|
||||
| `vote.received` | Vote cast |
|
||||
|
||||
306
docs/archive/SESSION_END_WEBSOCKET.md
Normal file
306
docs/archive/SESSION_END_WEBSOCKET.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# Session End WebSocket Event
|
||||
|
||||
This document describes the `session.ended` WebSocket event that is broadcast when a game session is closed.
|
||||
|
||||
## 📋 Event Overview
|
||||
|
||||
When a session is closed (either manually or through timeout), the backend broadcasts a `session.ended` event to all subscribed WebSocket clients. This allows bots and other integrations to react immediately to session closures.
|
||||
|
||||
## 🔌 WebSocket Connection
|
||||
|
||||
**Endpoint:** `ws://localhost:5000/api/sessions/live`
|
||||
|
||||
**Authentication:** Required (JWT token)
|
||||
|
||||
## 📨 Event Format
|
||||
|
||||
### Event Type
|
||||
```
|
||||
session.ended
|
||||
```
|
||||
|
||||
### Full Message Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "session.ended",
|
||||
"timestamp": "2025-11-01T02:30:45.123Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 17,
|
||||
"is_active": 0,
|
||||
"games_played": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Field Descriptions
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `type` | string | Always `"session.ended"` |
|
||||
| `timestamp` | string | ISO 8601 timestamp when the event was generated |
|
||||
| `data.session.id` | number | The ID of the session that ended |
|
||||
| `data.session.is_active` | number | Always `0` (inactive) for ended sessions |
|
||||
| `data.session.games_played` | number | Total number of games played in the session |
|
||||
|
||||
## 🚀 Implementation
|
||||
|
||||
### Backend Implementation
|
||||
|
||||
The `session.ended` event is automatically broadcast when:
|
||||
|
||||
1. **Manual Session Close**: Admin closes a session via `POST /api/sessions/:id/close`
|
||||
2. **Session Timeout**: (If implemented) When a session times out
|
||||
|
||||
**Code Location:** `backend/routes/sessions.js` - `POST /:id/close` endpoint
|
||||
|
||||
```javascript
|
||||
// Broadcast session.ended event via WebSocket
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
const eventData = {
|
||||
session: {
|
||||
id: closedSession.id,
|
||||
is_active: 0,
|
||||
games_played: closedSession.games_played
|
||||
}
|
||||
};
|
||||
|
||||
wsManager.broadcastEvent('session.ended', eventData, parseInt(req.params.id));
|
||||
}
|
||||
```
|
||||
|
||||
### Client Implementation Example
|
||||
|
||||
#### Node.js with `ws` library
|
||||
|
||||
```javascript
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const ws = new WebSocket('ws://localhost:5000/api/sessions/live');
|
||||
|
||||
ws.on('open', () => {
|
||||
// Authenticate
|
||||
ws.send(JSON.stringify({
|
||||
type: 'auth',
|
||||
token: 'your-jwt-token'
|
||||
}));
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
switch (message.type) {
|
||||
case 'auth_success':
|
||||
// Subscribe to session
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
sessionId: 17
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'session.ended':
|
||||
console.log('Session ended!');
|
||||
console.log(`Session ID: ${message.data.session.id}`);
|
||||
console.log(`Games played: ${message.data.session.games_played}`);
|
||||
// Handle session end (e.g., announce in IRC, Discord, etc.)
|
||||
break;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### Python with `websockets` library
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import json
|
||||
import websockets
|
||||
|
||||
async def listen_for_session_end():
|
||||
uri = "ws://localhost:5000/api/sessions/live"
|
||||
|
||||
async with websockets.connect(uri) as websocket:
|
||||
# Authenticate
|
||||
await websocket.send(json.dumps({
|
||||
"type": "auth",
|
||||
"token": "your-jwt-token"
|
||||
}))
|
||||
|
||||
async for message in websocket:
|
||||
data = json.loads(message)
|
||||
|
||||
if data["type"] == "auth_success":
|
||||
# Subscribe to session
|
||||
await websocket.send(json.dumps({
|
||||
"type": "subscribe",
|
||||
"sessionId": 17
|
||||
}))
|
||||
|
||||
elif data["type"] == "session.ended":
|
||||
session = data["data"]["session"]
|
||||
print(f"Session {session['id']} ended!")
|
||||
print(f"Games played: {session['games_played']}")
|
||||
# Handle session end
|
||||
|
||||
asyncio.run(listen_for_session_end())
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Using the Test Script
|
||||
|
||||
A test script is provided to verify the `session.ended` event:
|
||||
|
||||
```bash
|
||||
node ../tests/test-session-end-websocket.js <session_id> <jwt_token>
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
node ../tests/test-session-end-websocket.js 17 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
### Manual Testing Steps
|
||||
|
||||
1. **Start the backend server:**
|
||||
```bash
|
||||
cd backend
|
||||
npm start
|
||||
```
|
||||
|
||||
2. **Run the test script in another terminal:**
|
||||
```bash
|
||||
node ../tests/test-session-end-websocket.js 17 <your-jwt-token>
|
||||
```
|
||||
|
||||
3. **Close the session in the Picker UI** or via API:
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/sessions/17/close \
|
||||
-H "Authorization: Bearer <your-jwt-token>" \
|
||||
-H "Content-Type: application/json"
|
||||
```
|
||||
|
||||
4. **Verify the event is received** in the test script output
|
||||
|
||||
### Expected Output
|
||||
|
||||
```
|
||||
🚀 Testing session.ended WebSocket event
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📡 Connecting to: ws://localhost:5000/api/sessions/live
|
||||
🎮 Session ID: 17
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
✅ Connected to WebSocket server
|
||||
|
||||
🔐 Authenticating...
|
||||
✅ Authentication successful
|
||||
|
||||
📻 Subscribing to session 17...
|
||||
✅ Subscribed to session 17
|
||||
|
||||
👂 Listening for session.ended events...
|
||||
(Close the session in the Picker to trigger the event)
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🎉 SESSION.ENDED EVENT RECEIVED!
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
📦 Event Data:
|
||||
{
|
||||
"type": "session.ended",
|
||||
"timestamp": "2025-11-01T02:30:45.123Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 17,
|
||||
"is_active": 0,
|
||||
"games_played": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
✨ Event Details:
|
||||
Session ID: 17
|
||||
Active: No
|
||||
Games Played: 5
|
||||
Timestamp: 2025-11-01T02:30:45.123Z
|
||||
|
||||
✅ Test successful! The bot should now announce the session end.
|
||||
```
|
||||
|
||||
## 🤖 Bot Integration
|
||||
|
||||
### IRC/Kosmi Bot Example
|
||||
|
||||
When the bot receives a `session.ended` event, it should:
|
||||
|
||||
1. **Announce the final vote counts** for the last game played
|
||||
2. **Announce that the game night has ended**
|
||||
3. **Optionally display session statistics**
|
||||
|
||||
Example bot response:
|
||||
```
|
||||
🗳️ Final votes for Quiplash 3: 5👍 1👎 (Score: +4)
|
||||
🌙 Game Night has ended! Thanks for playing!
|
||||
📊 Session Stats: 5 games played
|
||||
```
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
The bot should also implement **polling detection** as a fallback in case the WebSocket connection fails or the event is not received:
|
||||
|
||||
- Poll `GET /api/sessions/active` every 30 seconds
|
||||
- If a previously active session becomes inactive, treat it as a session end
|
||||
- This ensures the bot will always detect session endings, even without WebSocket
|
||||
|
||||
## 🔍 Debugging
|
||||
|
||||
### Check WebSocket Logs
|
||||
|
||||
The backend logs WebSocket events:
|
||||
|
||||
```
|
||||
[WebSocket] Client subscribed to session 17
|
||||
[Sessions] Broadcasted session.ended event for session 17
|
||||
[WebSocket] Broadcasted session.ended to 1 client(s) for session 17
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Event not received:**
|
||||
- Verify the client is authenticated (`auth_success` received)
|
||||
- Verify the client is subscribed to the correct session
|
||||
- Check backend logs for broadcast confirmation
|
||||
|
||||
2. **Connection drops:**
|
||||
- Implement ping/pong heartbeat (send `{"type": "ping"}` every 30s)
|
||||
- Handle reconnection logic in your client
|
||||
|
||||
3. **Multiple events received:**
|
||||
- This is normal if multiple clients are subscribed
|
||||
- Each client receives its own copy of the event
|
||||
|
||||
## 📚 Related Documentation
|
||||
|
||||
- [WebSocket Testing Guide](WEBSOCKET_TESTING.md)
|
||||
- [Bot Integration Guide](BOT_INTEGRATION.md)
|
||||
- [API Quick Reference](API_QUICK_REFERENCE.md)
|
||||
|
||||
## 🔗 Related Events
|
||||
|
||||
| Event Type | Description | When Triggered |
|
||||
|------------|-------------|----------------|
|
||||
| `session.started` | A new session was created | When session is created |
|
||||
| `game.added` | A new game was added to the session | When a game starts |
|
||||
| `session.ended` | The session has ended | When session is closed |
|
||||
| `vote.received` | A vote was cast for a game | When a user votes |
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- The `session.ended` event is broadcast to **all clients subscribed to that session**
|
||||
- The event includes the final `games_played` count for the session
|
||||
- The `is_active` field will always be `0` for ended sessions
|
||||
- The timestamp is in ISO 8601 format with timezone (UTC)
|
||||
|
||||
361
docs/archive/SESSION_START_WEBSOCKET.md
Normal file
361
docs/archive/SESSION_START_WEBSOCKET.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# Session Start WebSocket Event
|
||||
|
||||
This document describes the `session.started` WebSocket event that is broadcast when a new game session is created.
|
||||
|
||||
## 📋 Event Overview
|
||||
|
||||
When a new session is created, the backend broadcasts a `session.started` event to all subscribed WebSocket clients. This allows bots and other integrations to react immediately to new game sessions.
|
||||
|
||||
## 🔌 WebSocket Connection
|
||||
|
||||
**Endpoint:** `ws://localhost:5000/api/sessions/live`
|
||||
|
||||
**Authentication:** Required (JWT token)
|
||||
|
||||
## 📨 Event Format
|
||||
|
||||
### Event Type
|
||||
```
|
||||
session.started
|
||||
```
|
||||
|
||||
### Full Message Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "session.started",
|
||||
"timestamp": "2025-11-01T20:00:00.123Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 17,
|
||||
"is_active": 1,
|
||||
"created_at": "2025-11-01T20:00:00.123Z",
|
||||
"notes": "Friday Game Night"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Field Descriptions
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `type` | string | Always `"session.started"` |
|
||||
| `timestamp` | string | ISO 8601 timestamp when the event was generated |
|
||||
| `data.session.id` | number | The ID of the newly created session |
|
||||
| `data.session.is_active` | number | Always `1` (active) for new sessions |
|
||||
| `data.session.created_at` | string | ISO 8601 timestamp when the session was created |
|
||||
| `data.session.notes` | string/null | Optional notes for the session |
|
||||
|
||||
## 🚀 Implementation
|
||||
|
||||
### Backend Implementation
|
||||
|
||||
The `session.started` event is automatically broadcast when:
|
||||
|
||||
1. **New Session Created**: Admin creates a session via `POST /api/sessions`
|
||||
|
||||
**Code Location:** `backend/routes/sessions.js` - `POST /` endpoint
|
||||
|
||||
```javascript
|
||||
// Broadcast session.started event via WebSocket
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
const eventData = {
|
||||
session: {
|
||||
id: newSession.id,
|
||||
is_active: 1,
|
||||
created_at: newSession.created_at,
|
||||
notes: newSession.notes
|
||||
}
|
||||
};
|
||||
|
||||
wsManager.broadcastEvent('session.started', eventData, parseInt(newSession.id));
|
||||
}
|
||||
```
|
||||
|
||||
### Client Implementation Example
|
||||
|
||||
#### Node.js with `ws` library
|
||||
|
||||
```javascript
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const ws = new WebSocket('ws://localhost:5000/api/sessions/live');
|
||||
|
||||
ws.on('open', () => {
|
||||
// Authenticate
|
||||
ws.send(JSON.stringify({
|
||||
type: 'auth',
|
||||
token: 'your-jwt-token'
|
||||
}));
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
switch (message.type) {
|
||||
case 'auth_success':
|
||||
// Subscribe to the new session (or subscribe when you receive session.started)
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
sessionId: 17
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'session.started':
|
||||
console.log('New session started!');
|
||||
console.log(`Session ID: ${message.data.session.id}`);
|
||||
console.log(`Created at: ${message.data.session.created_at}`);
|
||||
if (message.data.session.notes) {
|
||||
console.log(`Notes: ${message.data.session.notes}`);
|
||||
}
|
||||
|
||||
// Auto-subscribe to the new session
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
sessionId: message.data.session.id
|
||||
}));
|
||||
break;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### Python with `websockets` library
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import json
|
||||
import websockets
|
||||
|
||||
async def listen_for_session_start():
|
||||
uri = "ws://localhost:5000/api/sessions/live"
|
||||
|
||||
async with websockets.connect(uri) as websocket:
|
||||
# Authenticate
|
||||
await websocket.send(json.dumps({
|
||||
"type": "auth",
|
||||
"token": "your-jwt-token"
|
||||
}))
|
||||
|
||||
async for message in websocket:
|
||||
data = json.loads(message)
|
||||
|
||||
if data["type"] == "auth_success":
|
||||
print("Authenticated, waiting for sessions...")
|
||||
|
||||
elif data["type"] == "session.started":
|
||||
session = data["data"]["session"]
|
||||
print(f"🎮 New session started! ID: {session['id']}")
|
||||
print(f"📅 Created: {session['created_at']}")
|
||||
if session.get('notes'):
|
||||
print(f"📝 Notes: {session['notes']}")
|
||||
|
||||
# Auto-subscribe to the new session
|
||||
await websocket.send(json.dumps({
|
||||
"type": "subscribe",
|
||||
"sessionId": session["id"]
|
||||
}))
|
||||
|
||||
asyncio.run(listen_for_session_start())
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Manual Testing Steps
|
||||
|
||||
1. **Start the backend server:**
|
||||
```bash
|
||||
cd backend
|
||||
npm start
|
||||
```
|
||||
|
||||
2. **Connect a WebSocket client** (use the test script or your own):
|
||||
```bash
|
||||
# You can modify ../tests/test-session-end-websocket.js to listen for session.started
|
||||
```
|
||||
|
||||
3. **Create a new session** in the Picker UI or via API:
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/sessions \
|
||||
-H "Authorization: Bearer <your-jwt-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"notes": "Friday Game Night"}'
|
||||
```
|
||||
|
||||
4. **Verify the event is received** by your WebSocket client
|
||||
|
||||
### Expected Event
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "session.started",
|
||||
"timestamp": "2025-11-01T20:00:00.123Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 18,
|
||||
"is_active": 1,
|
||||
"created_at": "2025-11-01T20:00:00.123Z",
|
||||
"notes": "Friday Game Night"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🤖 Bot Integration
|
||||
|
||||
### IRC/Kosmi Bot Example
|
||||
|
||||
When the bot receives a `session.started` event, it should:
|
||||
|
||||
1. **Announce the new game session** to users
|
||||
2. **Auto-subscribe to the session** to receive game.added and session.ended events
|
||||
3. **Optionally display session info** (notes, ID, etc.)
|
||||
|
||||
Example bot response:
|
||||
```
|
||||
🎮 Game Night has started! Session #18
|
||||
📝 Friday Game Night
|
||||
🗳️ Vote with thisgame++ or thisgame-- during games!
|
||||
```
|
||||
|
||||
### Implementation Example
|
||||
|
||||
```javascript
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
|
||||
if (msg.type === 'session.started') {
|
||||
const { id, notes, created_at } = msg.data.session;
|
||||
|
||||
// Announce to IRC/Discord/etc
|
||||
bot.announce(`🎮 Game Night has started! Session #${id}`);
|
||||
if (notes) {
|
||||
bot.announce(`📝 ${notes}`);
|
||||
}
|
||||
bot.announce('🗳️ Vote with thisgame++ or thisgame-- during games!');
|
||||
|
||||
// Auto-subscribe to this session
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
sessionId: id
|
||||
}));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 🔍 Debugging
|
||||
|
||||
### Check WebSocket Logs
|
||||
|
||||
The backend logs WebSocket events:
|
||||
|
||||
```
|
||||
[Sessions] Broadcasted session.started event for session 18
|
||||
[WebSocket] Broadcasted session.started to 1 client(s) for session 18
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Event not received:**
|
||||
- Verify the client is authenticated (`auth_success` received)
|
||||
- Check backend logs for broadcast confirmation
|
||||
- **No subscription required** - All authenticated clients automatically receive `session.started` events
|
||||
- Make sure your WebSocket connection is open and authenticated
|
||||
|
||||
2. **Missing session data:**
|
||||
- Check if the session was created successfully
|
||||
- Verify the API response includes all fields
|
||||
|
||||
3. **Duplicate events:**
|
||||
- Normal if multiple clients are connected
|
||||
- Each client receives its own copy of the event
|
||||
|
||||
## 📚 Related Documentation
|
||||
|
||||
- [Session End WebSocket Event](SESSION_END_WEBSOCKET.md)
|
||||
- [WebSocket Testing Guide](WEBSOCKET_TESTING.md)
|
||||
- [Bot Integration Guide](BOT_INTEGRATION.md)
|
||||
- [API Quick Reference](API_QUICK_REFERENCE.md)
|
||||
|
||||
## 🔗 Session Lifecycle Events
|
||||
|
||||
```
|
||||
session.started
|
||||
↓
|
||||
game.added (multiple times)
|
||||
↓
|
||||
vote.received (during each game)
|
||||
↓
|
||||
session.ended
|
||||
```
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- The `session.started` event is broadcast to **all authenticated clients** (not just subscribed ones)
|
||||
- **No subscription required** - All authenticated clients automatically receive this event
|
||||
- Clients should auto-subscribe to the new session to receive subsequent `game.added` and `vote.received` events
|
||||
- The `is_active` field will always be `1` for new sessions
|
||||
- The `notes` field may be `null` if no notes were provided
|
||||
- The timestamp is in ISO 8601 format with timezone (UTC)
|
||||
|
||||
## 💡 Use Cases
|
||||
|
||||
1. **Bot Announcements** - Notify users when game night starts
|
||||
2. **Auto-Subscription** - Automatically subscribe to new sessions
|
||||
3. **Session Tracking** - Track all sessions in external systems
|
||||
4. **Analytics** - Log session creation times and frequency
|
||||
5. **Notifications** - Send push notifications to users
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
1. **Auto-subscribe** to new sessions when you receive `session.started`
|
||||
2. **Store the session ID** for later reference
|
||||
3. **Handle reconnections** gracefully (you might miss the event)
|
||||
4. **Use polling as fallback** to detect sessions created while disconnected
|
||||
5. **Validate session data** before processing
|
||||
|
||||
## 🔄 Complete Event Flow Example
|
||||
|
||||
```javascript
|
||||
const WebSocket = require('ws');
|
||||
const ws = new WebSocket('ws://localhost:5000/api/sessions/live');
|
||||
|
||||
let currentSessionId = null;
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
|
||||
switch (msg.type) {
|
||||
case 'session.started':
|
||||
currentSessionId = msg.data.session.id;
|
||||
console.log(`🎮 Session ${currentSessionId} started!`);
|
||||
|
||||
// Auto-subscribe
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
sessionId: currentSessionId
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'game.added':
|
||||
console.log(`🎲 New game: ${msg.data.game.title}`);
|
||||
break;
|
||||
|
||||
case 'vote.received':
|
||||
console.log(`🗳️ Vote: ${msg.data.vote.type}`);
|
||||
break;
|
||||
|
||||
case 'session.ended':
|
||||
console.log(`🌙 Session ${msg.data.session.id} ended!`);
|
||||
console.log(`📊 Games played: ${msg.data.session.games_played}`);
|
||||
currentSessionId = null;
|
||||
break;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## ✨ Conclusion
|
||||
|
||||
The `session.started` WebSocket event provides instant notification when new game sessions are created, allowing bots and integrations to react immediately and provide a seamless user experience.
|
||||
|
||||
256
docs/archive/WEBSOCKET_FLOW_DIAGRAM.md
Normal file
256
docs/archive/WEBSOCKET_FLOW_DIAGRAM.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# WebSocket Event Flow Diagram
|
||||
|
||||
## 🔄 Complete Session Lifecycle
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ BOT CONNECTS │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Bot → Server: { type: "auth", token: "..." } │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Server → Bot: { type: "auth_success" } │
|
||||
│ ✅ Bot is now AUTHENTICATED │
|
||||
│ ⏳ Bot waits... (no subscription yet) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ADMIN CREATES SESSION │
|
||||
│ POST /api/sessions { notes: "Friday Game Night" } │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Server → ALL AUTHENTICATED CLIENTS: │
|
||||
│ { │
|
||||
│ type: "session.started", │
|
||||
│ data: { │
|
||||
│ session: { id: 22, is_active: 1, ... } │
|
||||
│ } │
|
||||
│ } │
|
||||
│ 📢 Broadcast to ALL (no subscription needed) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Bot receives session.started │
|
||||
│ 🎮 Bot announces: "Game Night #22 has started!" │
|
||||
│ │
|
||||
│ Bot → Server: { type: "subscribe", sessionId: 22 } │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Server → Bot: { type: "subscribed", sessionId: 22 } │
|
||||
│ ✅ Bot is now SUBSCRIBED to session 22 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ADMIN ADDS GAME │
|
||||
│ POST /api/sessions/22/games { game_id: 45 } │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Server → SUBSCRIBED CLIENTS (session 22): │
|
||||
│ { │
|
||||
│ type: "game.added", │
|
||||
│ data: { │
|
||||
│ game: { title: "Quiplash 3", ... } │
|
||||
│ } │
|
||||
│ } │
|
||||
│ 📢 Only to subscribers of session 22 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Bot receives game.added │
|
||||
│ 🎲 Bot announces: "Now playing: Quiplash 3" │
|
||||
│ 🗳️ Bot announces: "Vote with thisgame++ or thisgame--" │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ USER VOTES │
|
||||
│ POST /api/votes/live { username: "Alice", vote: "up" } │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Server → SUBSCRIBED CLIENTS (session 22): │
|
||||
│ { │
|
||||
│ type: "vote.received", │
|
||||
│ data: { │
|
||||
│ vote: { username: "Alice", type: "up" } │
|
||||
│ } │
|
||||
│ } │
|
||||
│ 📢 Only to subscribers of session 22 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Bot receives vote.received │
|
||||
│ 🗳️ Bot tracks vote (may announce later) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
(more games and votes...)
|
||||
↓
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ADMIN CLOSES SESSION │
|
||||
│ POST /api/sessions/22/close │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Server → SUBSCRIBED CLIENTS (session 22): │
|
||||
│ { │
|
||||
│ type: "session.ended", │
|
||||
│ data: { │
|
||||
│ session: { id: 22, is_active: 0, games_played: 5 } │
|
||||
│ } │
|
||||
│ } │
|
||||
│ 📢 Only to subscribers of session 22 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Bot receives session.ended │
|
||||
│ 🗳️ Bot announces: "Final votes for Quiplash 3: 5👍 1👎" │
|
||||
│ 🌙 Bot announces: "Game Night ended! 5 games played" │
|
||||
│ ⏳ Bot waits for next session.started... │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 📊 Broadcast Scope Comparison
|
||||
|
||||
### session.started (Global Broadcast)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ SERVER │
|
||||
│ │
|
||||
│ broadcastToAll('session.started', data) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓ ↓ ↓
|
||||
┌──────────┴───────────┴───────────┴──────────┐
|
||||
↓ ↓ ↓
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│ Bot A │ │ Bot B │ │ Bot C │
|
||||
│ ✅ │ │ ✅ │ │ ✅ │
|
||||
└────────┘ └────────┘ └────────┘
|
||||
|
||||
ALL authenticated clients receive it
|
||||
(no subscription required)
|
||||
```
|
||||
|
||||
### game.added, vote.received, session.ended (Session-Specific)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ SERVER │
|
||||
│ │
|
||||
│ broadcastEvent('game.added', data, sessionId: 22) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────┴─────────┐
|
||||
↓ ↓
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│ Bot A │ │ Bot B │ │ Bot C │
|
||||
│ ✅ │ │ ❌ │ │ ✅ │
|
||||
│subscr. │ │ not │ │subscr. │
|
||||
│sess 22 │ │subscr. │ │sess 22 │
|
||||
└────────┘ └────────┘ └────────┘
|
||||
|
||||
ONLY subscribers to session 22 receive it
|
||||
```
|
||||
|
||||
## 🎯 Bot State Machine
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ DISCONNECTED│
|
||||
└──────┬──────┘
|
||||
│ connect()
|
||||
↓
|
||||
┌─────────────┐
|
||||
│ CONNECTED │
|
||||
└──────┬──────┘
|
||||
│ send auth
|
||||
↓
|
||||
┌─────────────┐
|
||||
│AUTHENTICATED│ ← Wait here for session.started
|
||||
└──────┬──────┘ (no subscription yet)
|
||||
│
|
||||
│ receive session.started
|
||||
↓
|
||||
┌─────────────┐
|
||||
│ WAITING │
|
||||
│ TO │
|
||||
│ SUBSCRIBE │
|
||||
└──────┬──────┘
|
||||
│ send subscribe
|
||||
↓
|
||||
┌─────────────┐
|
||||
│ SUBSCRIBED │ ← Now receive game.added, vote.received, session.ended
|
||||
└──────┬──────┘
|
||||
│
|
||||
│ receive session.ended
|
||||
↓
|
||||
┌─────────────┐
|
||||
│AUTHENTICATED│ ← Back to waiting for next session.started
|
||||
└─────────────┘ (still authenticated, but not subscribed)
|
||||
```
|
||||
|
||||
## 🔍 Event Flow by Subscription Status
|
||||
|
||||
### Before Subscription (Just Authenticated)
|
||||
|
||||
```
|
||||
Server Events: Bot Receives:
|
||||
───────────── ─────────────
|
||||
session.started ✅ YES (broadcast to all)
|
||||
game.added ❌ NO (not subscribed yet)
|
||||
vote.received ❌ NO (not subscribed yet)
|
||||
session.ended ❌ NO (not subscribed yet)
|
||||
```
|
||||
|
||||
### After Subscription (Subscribed to Session 22)
|
||||
|
||||
```
|
||||
Server Events: Bot Receives:
|
||||
───────────── ─────────────
|
||||
session.started ✅ YES (still broadcast to all)
|
||||
game.added ✅ YES (subscribed to session 22)
|
||||
vote.received ✅ YES (subscribed to session 22)
|
||||
session.ended ✅ YES (subscribed to session 22)
|
||||
```
|
||||
|
||||
## 🎮 Multiple Sessions Example
|
||||
|
||||
```
|
||||
Time Event Bot A (sess 22) Bot B (sess 23)
|
||||
──── ───── ─────────────── ───────────────
|
||||
10:00 session.started (sess 22) ✅ Receives ✅ Receives
|
||||
10:01 Bot A subscribes to sess 22 ✅ Subscribed ❌ Not subscribed
|
||||
10:02 game.added (sess 22) ✅ Receives ❌ Doesn't receive
|
||||
10:05 session.started (sess 23) ✅ Receives ✅ Receives
|
||||
10:06 Bot B subscribes to sess 23 ✅ Still sess 22 ✅ Subscribed
|
||||
10:07 game.added (sess 22) ✅ Receives ❌ Doesn't receive
|
||||
10:08 game.added (sess 23) ❌ Doesn't receive ✅ Receives
|
||||
10:10 session.ended (sess 22) ✅ Receives ❌ Doesn't receive
|
||||
10:15 session.ended (sess 23) ❌ Doesn't receive ✅ Receives
|
||||
```
|
||||
|
||||
## 📝 Quick Reference
|
||||
|
||||
| Event | Broadcast Method | Scope | Subscription Required? |
|
||||
|-------|------------------|-------|------------------------|
|
||||
| `session.started` | `broadcastToAll()` | All authenticated clients | ❌ NO |
|
||||
| `game.added` | `broadcastEvent()` | Session subscribers only | ✅ YES |
|
||||
| `vote.received` | `broadcastEvent()` | Session subscribers only | ✅ YES |
|
||||
| `session.ended` | `broadcastEvent()` | Session subscribers only | ✅ YES |
|
||||
|
||||
## 🔗 Related Documentation
|
||||
|
||||
- [WEBSOCKET_SUBSCRIPTION_GUIDE.md](WEBSOCKET_SUBSCRIPTION_GUIDE.md) - Detailed subscription guide
|
||||
- [SESSION_START_WEBSOCKET.md](SESSION_START_WEBSOCKET.md) - session.started event
|
||||
- [SESSION_END_WEBSOCKET.md](SESSION_END_WEBSOCKET.md) - session.ended event
|
||||
- [BOT_INTEGRATION.md](BOT_INTEGRATION.md) - Bot integration guide
|
||||
|
||||
310
docs/archive/WEBSOCKET_SUBSCRIPTION_GUIDE.md
Normal file
310
docs/archive/WEBSOCKET_SUBSCRIPTION_GUIDE.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# WebSocket Subscription Guide
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
This guide explains how WebSocket subscriptions work in the Jackbox Game Picker and which events require subscriptions.
|
||||
|
||||
## 🔌 Connection & Authentication
|
||||
|
||||
### 1. Connect to WebSocket
|
||||
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:5000/api/sessions/live');
|
||||
```
|
||||
|
||||
### 2. Authenticate
|
||||
|
||||
```javascript
|
||||
ws.send(JSON.stringify({
|
||||
type: 'auth',
|
||||
token: 'YOUR_JWT_TOKEN'
|
||||
}));
|
||||
```
|
||||
|
||||
### 3. Wait for Auth Success
|
||||
|
||||
```javascript
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === 'auth_success') {
|
||||
console.log('Authenticated!');
|
||||
// Now you can subscribe to sessions
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 📨 Event Types & Subscription Requirements
|
||||
|
||||
| Event Type | Requires Subscription? | Broadcast To | When to Subscribe |
|
||||
|------------|------------------------|--------------|-------------------|
|
||||
| `session.started` | ❌ **NO** | All authenticated clients | N/A - Automatic |
|
||||
| `game.added` | ✅ **YES** | Subscribed clients only | After session.started |
|
||||
| `vote.received` | ✅ **YES** | Subscribed clients only | After session.started |
|
||||
| `session.ended` | ✅ **YES** | Subscribed clients only | After session.started |
|
||||
|
||||
## 🎯 Subscription Strategy
|
||||
|
||||
### Strategy 1: Auto-Subscribe to New Sessions (Recommended for Bots)
|
||||
|
||||
```javascript
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
|
||||
// After authentication
|
||||
if (msg.type === 'auth_success') {
|
||||
console.log('✅ Authenticated');
|
||||
}
|
||||
|
||||
// Auto-subscribe to new sessions
|
||||
if (msg.type === 'session.started') {
|
||||
const sessionId = msg.data.session.id;
|
||||
console.log(`🎮 New session ${sessionId} started!`);
|
||||
|
||||
// Subscribe to this session
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
sessionId: sessionId
|
||||
}));
|
||||
}
|
||||
|
||||
// Now you'll receive game.added, vote.received, and session.ended
|
||||
if (msg.type === 'game.added') {
|
||||
console.log(`🎲 Game: ${msg.data.game.title}`);
|
||||
}
|
||||
|
||||
if (msg.type === 'session.ended') {
|
||||
console.log(`🌙 Session ended! ${msg.data.session.games_played} games played`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Strategy 2: Subscribe to Active Session on Connect
|
||||
|
||||
```javascript
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
|
||||
if (msg.type === 'auth_success') {
|
||||
console.log('✅ Authenticated');
|
||||
|
||||
// Fetch active session from API
|
||||
fetch('http://localhost:5000/api/sessions/active')
|
||||
.then(res => res.json())
|
||||
.then(session => {
|
||||
if (session && session.id) {
|
||||
// Subscribe to active session
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
sessionId: session.id
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Strategy 3: Subscribe to Specific Session
|
||||
|
||||
```javascript
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
|
||||
if (msg.type === 'auth_success') {
|
||||
console.log('✅ Authenticated');
|
||||
|
||||
// Subscribe to specific session
|
||||
const sessionId = 17;
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
sessionId: sessionId
|
||||
}));
|
||||
}
|
||||
|
||||
if (msg.type === 'subscribed') {
|
||||
console.log(`✅ Subscribed to session ${msg.sessionId}`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 🔄 Complete Bot Flow
|
||||
|
||||
```javascript
|
||||
const WebSocket = require('ws');
|
||||
|
||||
class JackboxBot {
|
||||
constructor(token) {
|
||||
this.token = token;
|
||||
this.ws = null;
|
||||
this.currentSessionId = null;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.ws = new WebSocket('ws://localhost:5000/api/sessions/live');
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log('🔌 Connected to WebSocket');
|
||||
this.authenticate();
|
||||
});
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
this.handleMessage(JSON.parse(data.toString()));
|
||||
});
|
||||
}
|
||||
|
||||
authenticate() {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'auth',
|
||||
token: this.token
|
||||
}));
|
||||
}
|
||||
|
||||
handleMessage(msg) {
|
||||
switch (msg.type) {
|
||||
case 'auth_success':
|
||||
console.log('✅ Authenticated');
|
||||
// Don't subscribe yet - wait for session.started
|
||||
break;
|
||||
|
||||
case 'session.started':
|
||||
this.currentSessionId = msg.data.session.id;
|
||||
console.log(`🎮 Session ${this.currentSessionId} started!`);
|
||||
|
||||
// Auto-subscribe to this session
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
sessionId: this.currentSessionId
|
||||
}));
|
||||
|
||||
// Announce to users
|
||||
this.announce(`Game Night has started! Session #${this.currentSessionId}`);
|
||||
break;
|
||||
|
||||
case 'subscribed':
|
||||
console.log(`✅ Subscribed to session ${msg.sessionId}`);
|
||||
break;
|
||||
|
||||
case 'game.added':
|
||||
console.log(`🎲 Game: ${msg.data.game.title}`);
|
||||
this.announce(`Now playing: ${msg.data.game.title}`);
|
||||
break;
|
||||
|
||||
case 'vote.received':
|
||||
console.log(`🗳️ Vote: ${msg.data.vote.type}`);
|
||||
break;
|
||||
|
||||
case 'session.ended':
|
||||
console.log(`🌙 Session ${msg.data.session.id} ended`);
|
||||
this.announce(`Game Night ended! ${msg.data.session.games_played} games played`);
|
||||
this.currentSessionId = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
announce(message) {
|
||||
// Send to IRC/Discord/Kosmi/etc
|
||||
console.log(`📢 ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const bot = new JackboxBot('YOUR_JWT_TOKEN');
|
||||
bot.connect();
|
||||
```
|
||||
|
||||
## 📊 Subscription Lifecycle
|
||||
|
||||
```
|
||||
1. Connect to WebSocket
|
||||
↓
|
||||
2. Send auth message
|
||||
↓
|
||||
3. Receive auth_success
|
||||
↓
|
||||
4. Wait for session.started (no subscription needed)
|
||||
↓
|
||||
5. Receive session.started
|
||||
↓
|
||||
6. Send subscribe message with sessionId
|
||||
↓
|
||||
7. Receive subscribed confirmation
|
||||
↓
|
||||
8. Now receive: game.added, vote.received, session.ended
|
||||
↓
|
||||
9. Receive session.ended
|
||||
↓
|
||||
10. Wait for next session.started (repeat from step 4)
|
||||
```
|
||||
|
||||
## 🔍 Debugging
|
||||
|
||||
### Check What You're Subscribed To
|
||||
|
||||
The WebSocket manager tracks subscriptions. Check backend logs:
|
||||
|
||||
```
|
||||
[WebSocket] Client subscribed to session 17
|
||||
[WebSocket] Client unsubscribed from session 17
|
||||
```
|
||||
|
||||
### Verify Event Reception
|
||||
|
||||
**session.started** - Should receive immediately after authentication (no subscription needed):
|
||||
```
|
||||
[WebSocket] Broadcasted session.started to 2 authenticated client(s)
|
||||
```
|
||||
|
||||
**game.added, vote.received, session.ended** - Only after subscribing:
|
||||
```
|
||||
[WebSocket] Broadcasted game.added to 1 client(s) for session 17
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Not receiving session.started:**
|
||||
- ✅ Are you authenticated?
|
||||
- ✅ Is your WebSocket connection open?
|
||||
- ✅ Check backend logs for broadcast confirmation
|
||||
|
||||
2. **Not receiving game.added:**
|
||||
- ✅ Did you subscribe to the session?
|
||||
- ✅ Did you receive `subscribed` confirmation?
|
||||
- ✅ Is the session ID correct?
|
||||
|
||||
3. **Not receiving session.ended:**
|
||||
- ✅ Are you still subscribed to the session?
|
||||
- ✅ Did the session actually close?
|
||||
- ✅ Check backend logs
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
1. **Auto-subscribe to new sessions** when you receive `session.started`
|
||||
2. **Don't subscribe before session.started** - there's nothing to subscribe to yet
|
||||
3. **Handle reconnections** - re-authenticate and re-subscribe on reconnect
|
||||
4. **Use polling as fallback** - poll `/api/sessions/active` every 30s as backup
|
||||
5. **Unsubscribe when done** - clean up subscriptions when you're done with a session
|
||||
6. **Validate session IDs** - make sure the session exists before subscribing
|
||||
|
||||
## 📝 Summary
|
||||
|
||||
### ❌ No Subscription Required
|
||||
- `session.started` - Broadcast to **all authenticated clients**
|
||||
|
||||
### ✅ Subscription Required
|
||||
- `game.added` - Only to **subscribed clients**
|
||||
- `vote.received` - Only to **subscribed clients**
|
||||
- `session.ended` - Only to **subscribed clients**
|
||||
|
||||
### 🎯 Recommended Flow
|
||||
1. Authenticate
|
||||
2. Wait for `session.started` (automatic)
|
||||
3. Subscribe to the session
|
||||
4. Receive `game.added`, `vote.received`, `session.ended`
|
||||
5. Repeat from step 2 for next session
|
||||
|
||||
## 🔗 Related Documentation
|
||||
|
||||
- [SESSION_START_WEBSOCKET.md](SESSION_START_WEBSOCKET.md) - session.started event details
|
||||
- [SESSION_END_WEBSOCKET.md](SESSION_END_WEBSOCKET.md) - session.ended event details
|
||||
- [API_QUICK_REFERENCE.md](API_QUICK_REFERENCE.md) - API reference
|
||||
- [BOT_INTEGRATION.md](BOT_INTEGRATION.md) - Bot integration guide
|
||||
|
||||
239
docs/archive/WEBSOCKET_TESTING.md
Normal file
239
docs/archive/WEBSOCKET_TESTING.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# WebSocket Integration Testing Guide
|
||||
|
||||
This guide walks you through testing the WebSocket event system for game notifications.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Backend API running with WebSocket support
|
||||
2. Valid JWT token for authentication
|
||||
3. Active session with games (or ability to create one)
|
||||
|
||||
## Testing Steps
|
||||
|
||||
### Step 1: Install Backend Dependencies
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
```
|
||||
|
||||
This will install the `ws` package that was added to `package.json`.
|
||||
|
||||
### Step 2: Start the Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm start
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
Server is running on port 5000
|
||||
WebSocket server available at ws://localhost:5000/api/sessions/live
|
||||
[WebSocket] WebSocket server initialized on /api/sessions/live
|
||||
```
|
||||
|
||||
### Step 3: Get JWT Token
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:5000/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"key":"YOUR_ADMIN_KEY"}'
|
||||
```
|
||||
|
||||
Save the token from the response.
|
||||
|
||||
### Step 4: Test WebSocket Connection
|
||||
|
||||
Run the test script:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
JWT_TOKEN="your_token_here" node test-websocket.js
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
🚀 WebSocket Test Client
|
||||
═══════════════════════════════════════════════════════
|
||||
Connecting to: ws://localhost:5000/api/sessions/live
|
||||
|
||||
✅ Connected to WebSocket server
|
||||
|
||||
📝 Step 1: Authenticating...
|
||||
✅ Authentication successful
|
||||
|
||||
📝 Step 2: Subscribing to session 1...
|
||||
✅ Subscribed to session 1
|
||||
|
||||
🎧 Listening for events...
|
||||
Add a game in the Picker page to see events here
|
||||
Press Ctrl+C to exit
|
||||
```
|
||||
|
||||
### Step 5: Test Game Added Event
|
||||
|
||||
1. Keep the WebSocket test client running
|
||||
2. Open the web app in your browser
|
||||
3. Go to the Picker page
|
||||
4. Add a game to the session
|
||||
|
||||
You should see in the test client:
|
||||
```
|
||||
🎮 GAME ADDED EVENT RECEIVED!
|
||||
═══════════════════════════════════════════════════════
|
||||
Game: Fibbage 4
|
||||
Pack: The Jackbox Party Pack 9
|
||||
Players: 2-8
|
||||
Session ID: 1
|
||||
Games Played: 1
|
||||
Timestamp: 2025-11-01T...
|
||||
═══════════════════════════════════════════════════════
|
||||
```
|
||||
|
||||
### Step 6: Test Bot Integration
|
||||
|
||||
If you're using the `irc-kosmi-relay` bot:
|
||||
|
||||
1. Make sure `UseWebSocket=true` in `matterbridge.toml`
|
||||
2. Build and run the bot:
|
||||
```bash
|
||||
cd irc-kosmi-relay
|
||||
go build
|
||||
./matterbridge -conf matterbridge.toml
|
||||
```
|
||||
|
||||
3. Look for these log messages:
|
||||
```
|
||||
INFO Jackbox integration initialized successfully
|
||||
INFO Connecting to WebSocket: wss://your-api-url/api/sessions/live
|
||||
INFO WebSocket connected
|
||||
INFO Authentication successful
|
||||
INFO Subscribed to session X
|
||||
```
|
||||
|
||||
4. Add a game in the Picker page
|
||||
|
||||
5. The bot should announce in Kosmi/IRC:
|
||||
```
|
||||
🎮 Coming up next: Fibbage 4!
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Refused
|
||||
|
||||
**Problem**: `Error: connect ECONNREFUSED`
|
||||
|
||||
**Solution**: Make sure the backend is running on the correct port (default 5000).
|
||||
|
||||
### Authentication Failed
|
||||
|
||||
**Problem**: `Authentication failed: Invalid or expired token`
|
||||
|
||||
**Solution**:
|
||||
- Get a fresh JWT token
|
||||
- Make sure you're using the correct admin key
|
||||
- Check token hasn't expired (tokens expire after 24 hours)
|
||||
|
||||
### No Events Received
|
||||
|
||||
**Problem**: WebSocket connects but no `game.added` events are received
|
||||
|
||||
**Solution**:
|
||||
- Make sure you're subscribed to the correct session ID
|
||||
- Verify the session is active
|
||||
- Check backend logs for errors
|
||||
- Try adding a game manually via the Picker page
|
||||
|
||||
### Bot Not Connecting
|
||||
|
||||
**Problem**: Bot fails to connect to WebSocket
|
||||
|
||||
**Solution**:
|
||||
- Check `APIURL` in `matterbridge.toml` is correct
|
||||
- Verify `UseWebSocket=true` is set
|
||||
- Check bot has valid JWT token (authentication succeeded)
|
||||
- Look for error messages in bot logs
|
||||
|
||||
### Reconnection Issues
|
||||
|
||||
**Problem**: WebSocket disconnects and doesn't reconnect
|
||||
|
||||
**Solution**:
|
||||
- Check network connectivity
|
||||
- Backend automatically handles reconnection with exponential backoff
|
||||
- Bot automatically reconnects on disconnect
|
||||
- Check logs for reconnection attempts
|
||||
|
||||
## Advanced Testing
|
||||
|
||||
### Test Multiple Clients
|
||||
|
||||
You can run multiple test clients simultaneously:
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
JWT_TOKEN="token1" node test-websocket.js
|
||||
|
||||
# Terminal 2
|
||||
JWT_TOKEN="token2" node test-websocket.js
|
||||
```
|
||||
|
||||
Both should receive the same `game.added` events.
|
||||
|
||||
### Test Heartbeat
|
||||
|
||||
The WebSocket connection sends ping/pong messages every 30 seconds. You should see:
|
||||
|
||||
```
|
||||
💓 Heartbeat
|
||||
```
|
||||
|
||||
If you don't see heartbeats, the connection may be stale.
|
||||
|
||||
### Test Reconnection
|
||||
|
||||
1. Start the test client
|
||||
2. Stop the backend (Ctrl+C)
|
||||
3. The client should log: `WebSocket disconnected, reconnecting...`
|
||||
4. Restart the backend
|
||||
5. The client should reconnect automatically
|
||||
|
||||
## Integration Checklist
|
||||
|
||||
- [ ] Backend WebSocket server starts successfully
|
||||
- [ ] Test client can connect and authenticate
|
||||
- [ ] Test client receives `game.added` events
|
||||
- [ ] Heartbeat keeps connection alive (30s interval)
|
||||
- [ ] Auto-reconnect works after disconnect
|
||||
- [ ] Multiple clients can connect simultaneously
|
||||
- [ ] Invalid JWT is rejected properly
|
||||
- [ ] Bot connects and authenticates
|
||||
- [ ] Bot receives events and broadcasts to chat
|
||||
- [ ] Bot reconnects after network issues
|
||||
|
||||
## Next Steps
|
||||
|
||||
Once testing is complete:
|
||||
|
||||
1. Update your bot configuration to use `UseWebSocket=true`
|
||||
2. Deploy the updated backend with WebSocket support
|
||||
3. Restart your bot to connect via WebSocket
|
||||
4. Monitor logs for any connection issues
|
||||
5. Webhooks remain available as a fallback option
|
||||
|
||||
## Comparison: WebSocket vs Webhooks
|
||||
|
||||
| Feature | WebSocket | Webhooks |
|
||||
|---------|-----------|----------|
|
||||
| Setup Complexity | Simple | Moderate |
|
||||
| Inbound Ports | Not needed | Required |
|
||||
| Docker Networking | Simple | Complex |
|
||||
| Latency | Lower | Higher |
|
||||
| Connection Type | Persistent | Per-event |
|
||||
| Reconnection | Automatic | N/A |
|
||||
| Best For | Real-time bots | Serverless integrations |
|
||||
|
||||
**Recommendation**: Use WebSocket for bot integrations. Use webhooks for serverless/stateless integrations or when WebSocket is not feasible.
|
||||
|
||||
1046
docs/jackbox-ecast-api.md
Normal file
1046
docs/jackbox-ecast-api.md
Normal file
File diff suppressed because it is too large
Load Diff
130
docs/plans/2026-03-15-api-documentation-design.md
Normal file
130
docs/plans/2026-03-15-api-documentation-design.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# API Documentation Design
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Create comprehensive, accurate API documentation for the Jackbox Game Picker by reading the source code directly — not relying on existing docs which may be stale or incorrect. The documentation serves both internal maintainers and external integrators (bot developers, extension authors, etc.).
|
||||
|
||||
## Scope
|
||||
|
||||
- All 41 REST/HTTP endpoints across 7 route groups (Auth, Games, Sessions, Picker, Stats, Votes, Webhooks) plus the health check
|
||||
- WebSocket protocol at `/api/sessions/live` (auth, subscriptions, event broadcasting)
|
||||
- Does NOT cover: Chrome extension internals, deployment/Docker setup, frontend
|
||||
|
||||
## Approach
|
||||
|
||||
**OpenAPI-first with generated Markdown** (Approach A from brainstorming).
|
||||
|
||||
- `openapi.yaml` (OpenAPI 3.1) is the single source of truth for REST endpoints
|
||||
- Human-readable Markdown endpoint docs are derived from the spec and enriched with guide-style prose, curl examples, and workflow explanations
|
||||
- WebSocket protocol documented separately in Markdown (outside OpenAPI's scope)
|
||||
- Existing `docs/` files archived to `docs/archive/`
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
docs/
|
||||
├── archive/ # Old docs preserved here
|
||||
│ ├── API_QUICK_REFERENCE.md
|
||||
│ ├── BOT_INTEGRATION.md
|
||||
│ ├── SESSION_END_QUICK_START.md
|
||||
│ ├── SESSION_END_WEBSOCKET.md
|
||||
│ ├── SESSION_START_WEBSOCKET.md
|
||||
│ ├── WEBSOCKET_FLOW_DIAGRAM.md
|
||||
│ ├── WEBSOCKET_SUBSCRIPTION_GUIDE.md
|
||||
│ ├── WEBSOCKET_TESTING.md
|
||||
│ └── todos.md
|
||||
├── api/
|
||||
│ ├── openapi.yaml # OpenAPI 3.1 spec (source of truth)
|
||||
│ ├── README.md # API overview, auth, base URL, error conventions
|
||||
│ ├── endpoints/
|
||||
│ │ ├── auth.md
|
||||
│ │ ├── games.md
|
||||
│ │ ├── sessions.md
|
||||
│ │ ├── picker.md
|
||||
│ │ ├── stats.md
|
||||
│ │ ├── votes.md
|
||||
│ │ └── webhooks.md
|
||||
│ ├── websocket.md # WebSocket protocol documentation
|
||||
│ └── guides/
|
||||
│ ├── getting-started.md # Quick start: auth, pick a game, run a session
|
||||
│ ├── session-lifecycle.md # Sessions end-to-end
|
||||
│ ├── voting-and-popularity.md # Chat import, live votes, popularity scoring
|
||||
│ └── webhooks-and-events.md # Webhooks + WS event system
|
||||
└── plans/
|
||||
```
|
||||
|
||||
## OpenAPI Spec Design
|
||||
|
||||
### Info & Servers
|
||||
- Title: "Jackbox Game Picker API"
|
||||
- Servers: local dev (`http://localhost:5000`), Docker proxy (`http://localhost:3000/api`)
|
||||
|
||||
### Security
|
||||
- `bearerAuth` scheme (JWT via `Authorization: Bearer <token>`)
|
||||
- Applied per-operation; public endpoints explicitly marked
|
||||
|
||||
### Tags
|
||||
Auth, Games, Sessions, Picker, Stats, Votes, Webhooks
|
||||
|
||||
### Schemas (components)
|
||||
- `Game`, `Session`, `SessionGame`, `Pack`, `PackMeta`
|
||||
- `Webhook`, `WebhookLog`
|
||||
- `ChatMessage`, `LiveVote`
|
||||
- `Error` (reusable error response)
|
||||
- Enums: `status` (playing/played/skipped), `vote_type` (up/down), `favor_bias` (-1/0/1), `drawing` (only/exclude), `length` (short/medium/long)
|
||||
|
||||
### Per-operation
|
||||
Each path operation includes: `operationId`, `summary`, `description`, `parameters`, `requestBody`, `responses` (success + all documented error codes)
|
||||
|
||||
## Markdown Endpoint Template
|
||||
|
||||
Each file in `docs/api/endpoints/` follows:
|
||||
|
||||
1. **Header:** Resource overview, what it represents, common use cases
|
||||
2. **Summary table:** Method | Path | Auth | Description
|
||||
3. **Per-endpoint sections:**
|
||||
- Description and when to use it
|
||||
- Authentication requirement
|
||||
- Parameters table (Name | In | Type | Required | Description)
|
||||
- Request body (JSON schema with field descriptions)
|
||||
- Success response (JSON example with annotations)
|
||||
- Error responses table (Status | Body | When)
|
||||
- curl example + sample response
|
||||
|
||||
## WebSocket Documentation Structure
|
||||
|
||||
`docs/api/websocket.md` covers:
|
||||
- Connection URL and setup
|
||||
- Authentication flow (send `auth` message with JWT)
|
||||
- Client-to-server message types: `auth`, `subscribe`, `unsubscribe`, `ping`
|
||||
- Server-to-client message types: `auth_success`, `subscribed`, `unsubscribed`, `pong`, `session.started`, `game.added`, `session.ended`, `player-count.updated`, `error`, `auth_error`
|
||||
- Subscription model (per-session)
|
||||
- Event payloads with full JSON examples
|
||||
- Heartbeat/timeout (60s) and reconnection guidance
|
||||
- Complete session lifecycle example
|
||||
|
||||
## Guide Documents
|
||||
|
||||
Each guide uses narrative prose connecting endpoints into workflows:
|
||||
|
||||
- **getting-started.md:** Authenticate, browse games, pick a game, start a session — minimum viable integration path
|
||||
- **session-lifecycle.md:** Create session → add games → track status → room codes → player counts → close session
|
||||
- **voting-and-popularity.md:** How `popularity_score`, `upvotes`, `downvotes` work; chat import flow; live vote endpoint; how voting affects the picker
|
||||
- **webhooks-and-events.md:** Create/manage webhooks, event types, delivery logs, relationship between webhook events and WebSocket events
|
||||
|
||||
## Maintenance Strategy
|
||||
|
||||
- `openapi.yaml` is the source of truth for REST endpoints
|
||||
- When endpoints change: update spec first, then update Markdown
|
||||
- WebSocket and guide docs are maintained manually
|
||||
- No build-time generation tooling — Markdown committed directly
|
||||
|
||||
## Validation Plan
|
||||
|
||||
After writing, cross-reference:
|
||||
1. Every route file in `backend/routes/` against the spec — no endpoints missed
|
||||
2. Request/response shapes against database schema (`backend/database.js`) and route handlers
|
||||
3. Auth requirements against middleware usage in each route
|
||||
768
docs/plans/2026-03-15-api-documentation-implementation.md
Normal file
768
docs/plans/2026-03-15-api-documentation-implementation.md
Normal file
@@ -0,0 +1,768 @@
|
||||
# API Documentation Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Create comprehensive, accurate API documentation from source code — OpenAPI 3.1 spec as source of truth, plus human-readable Markdown with examples and guide-style prose.
|
||||
|
||||
**Architecture:** OpenAPI YAML spec covers all 41 REST endpoints. Separate Markdown files per route group with curl examples and response samples. WebSocket protocol documented in dedicated Markdown. Guide files connect endpoints into workflows.
|
||||
|
||||
**Tech Stack:** OpenAPI 3.1 YAML, Markdown, curl for examples
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Archive existing docs and create directory structure
|
||||
|
||||
**Files:**
|
||||
- Move: `docs/*.md` → `docs/archive/`
|
||||
- Create directories: `docs/api/`, `docs/api/endpoints/`, `docs/api/guides/`
|
||||
|
||||
**Step 1: Create the archive directory**
|
||||
|
||||
```bash
|
||||
mkdir -p docs/archive docs/api/endpoints docs/api/guides
|
||||
```
|
||||
|
||||
**Step 2: Move existing docs to archive**
|
||||
|
||||
```bash
|
||||
mv docs/API_QUICK_REFERENCE.md docs/archive/
|
||||
mv docs/BOT_INTEGRATION.md docs/archive/
|
||||
mv docs/SESSION_END_QUICK_START.md docs/archive/
|
||||
mv docs/SESSION_END_WEBSOCKET.md docs/archive/
|
||||
mv docs/SESSION_START_WEBSOCKET.md docs/archive/
|
||||
mv docs/WEBSOCKET_FLOW_DIAGRAM.md docs/archive/
|
||||
mv docs/WEBSOCKET_SUBSCRIPTION_GUIDE.md docs/archive/
|
||||
mv docs/WEBSOCKET_TESTING.md docs/archive/
|
||||
mv docs/todos.md docs/archive/
|
||||
```
|
||||
|
||||
**Step 3: Verify structure**
|
||||
|
||||
```bash
|
||||
ls -R docs/
|
||||
```
|
||||
|
||||
Expected: `archive/` with old files, `api/endpoints/` and `api/guides/` empty, `plans/` with design docs.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/
|
||||
git commit -m "docs: archive old documentation, create new docs structure"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Write OpenAPI spec — info, servers, security, and component schemas
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/api/openapi.yaml`
|
||||
|
||||
**Step 1: Write the OpenAPI header, servers, security schemes, and all reusable component schemas**
|
||||
|
||||
Write `docs/api/openapi.yaml` with:
|
||||
|
||||
```yaml
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Jackbox Game Picker API
|
||||
description: API for managing Jackbox Party Pack games, sessions, voting, and integrations.
|
||||
version: "1.0"
|
||||
license:
|
||||
name: MIT
|
||||
|
||||
servers:
|
||||
- url: http://localhost:5000
|
||||
description: Local development (backend direct)
|
||||
- url: http://localhost:3000/api
|
||||
description: Docker Compose (via Vite/Nginx proxy)
|
||||
|
||||
security: []
|
||||
|
||||
tags:
|
||||
- name: Auth
|
||||
description: Authentication endpoints
|
||||
- name: Games
|
||||
description: Game management and filtering
|
||||
- name: Sessions
|
||||
description: Session lifecycle and game tracking
|
||||
- name: Picker
|
||||
description: Weighted random game selection
|
||||
- name: Stats
|
||||
description: Aggregate statistics
|
||||
- name: Votes
|
||||
description: Real-time popularity voting
|
||||
- name: Webhooks
|
||||
description: Webhook management for external integrations
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: >
|
||||
JWT token obtained from POST /api/auth/login.
|
||||
Pass as `Authorization: Bearer <token>`. Tokens expire after 24 hours.
|
||||
|
||||
schemas:
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
required:
|
||||
- error
|
||||
|
||||
Game:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
pack_name:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
min_players:
|
||||
type: integer
|
||||
max_players:
|
||||
type: integer
|
||||
length_minutes:
|
||||
type: integer
|
||||
nullable: true
|
||||
has_audience:
|
||||
type: integer
|
||||
enum: [0, 1]
|
||||
family_friendly:
|
||||
type: integer
|
||||
enum: [0, 1]
|
||||
game_type:
|
||||
type: string
|
||||
nullable: true
|
||||
secondary_type:
|
||||
type: string
|
||||
nullable: true
|
||||
play_count:
|
||||
type: integer
|
||||
popularity_score:
|
||||
type: integer
|
||||
upvotes:
|
||||
type: integer
|
||||
downvotes:
|
||||
type: integer
|
||||
enabled:
|
||||
type: integer
|
||||
enum: [0, 1]
|
||||
favor_bias:
|
||||
type: integer
|
||||
enum: [-1, 0, 1]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
Session:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
closed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
is_active:
|
||||
type: integer
|
||||
enum: [0, 1]
|
||||
notes:
|
||||
type: string
|
||||
nullable: true
|
||||
games_played:
|
||||
type: integer
|
||||
|
||||
SessionGame:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
session_id:
|
||||
type: integer
|
||||
game_id:
|
||||
type: integer
|
||||
played_at:
|
||||
type: string
|
||||
format: date-time
|
||||
manually_added:
|
||||
type: integer
|
||||
enum: [0, 1]
|
||||
status:
|
||||
type: string
|
||||
enum: [playing, played, skipped]
|
||||
room_code:
|
||||
type: string
|
||||
nullable: true
|
||||
player_count:
|
||||
type: integer
|
||||
nullable: true
|
||||
player_count_check_status:
|
||||
type: string
|
||||
nullable: true
|
||||
pack_name:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
game_type:
|
||||
type: string
|
||||
nullable: true
|
||||
min_players:
|
||||
type: integer
|
||||
max_players:
|
||||
type: integer
|
||||
popularity_score:
|
||||
type: integer
|
||||
upvotes:
|
||||
type: integer
|
||||
downvotes:
|
||||
type: integer
|
||||
|
||||
Pack:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
favor_bias:
|
||||
type: integer
|
||||
enum: [-1, 0, 1]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
PackMeta:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
total_count:
|
||||
type: integer
|
||||
enabled_count:
|
||||
type: integer
|
||||
total_plays:
|
||||
type: integer
|
||||
|
||||
Webhook:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
WebhookLog:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
webhook_id:
|
||||
type: integer
|
||||
event_type:
|
||||
type: string
|
||||
payload:
|
||||
type: object
|
||||
response_status:
|
||||
type: integer
|
||||
nullable: true
|
||||
error_message:
|
||||
type: string
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
```
|
||||
|
||||
**Step 2: Validate YAML syntax**
|
||||
|
||||
```bash
|
||||
node -e "const fs=require('fs'); const y=require('yaml'); y.parse(fs.readFileSync('docs/api/openapi.yaml','utf8')); console.log('Valid YAML')"
|
||||
```
|
||||
|
||||
If `yaml` module not available, use: `npx -y yaml-cli docs/api/openapi.yaml` or manually verify structure.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/api/openapi.yaml
|
||||
git commit -m "docs: add OpenAPI spec with schemas and security definitions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Write OpenAPI paths — Auth and Games endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/api/openapi.yaml`
|
||||
|
||||
**Step 1: Add `paths` section with Auth endpoints**
|
||||
|
||||
Source: `backend/routes/auth.js` — 2 endpoints.
|
||||
|
||||
Add paths for:
|
||||
- `POST /api/auth/login` — Body: `{ key }`, responses: 200 (token+message+expiresIn), 400 (missing key), 401 (invalid key)
|
||||
- `POST /api/auth/verify` — Security: bearerAuth, responses: 200 (`{ valid, user: { role, timestamp } }`)
|
||||
|
||||
**Step 2: Add Games endpoints**
|
||||
|
||||
Source: `backend/routes/games.js` — 13 endpoints.
|
||||
|
||||
Add paths for:
|
||||
- `GET /api/games` — Query params: `enabled`, `minPlayers`, `maxPlayers`, `playerCount`, `drawing` (only/exclude), `length` (short/medium/long), `familyFriendly`, `pack`. Response: array of Game.
|
||||
- `GET /api/games/packs` — Response: array of Pack.
|
||||
- `GET /api/games/meta/packs` — Response: array of PackMeta.
|
||||
- `GET /api/games/export/csv` — Security: bearerAuth. Response: CSV file (text/csv).
|
||||
- `PATCH /api/games/packs/{name}/favor` — Security: bearerAuth. Body: `{ favor_bias }` (-1/0/1). Response: `{ message, favor_bias }`. Error 400 for invalid value.
|
||||
- `GET /api/games/{id}` — Response: Game or 404.
|
||||
- `POST /api/games` — Security: bearerAuth. Body: `{ pack_name, title, min_players, max_players, length_minutes?, has_audience?, family_friendly?, game_type?, secondary_type? }`. Response 201: Game. Error 400: missing fields.
|
||||
- `PUT /api/games/{id}` — Security: bearerAuth. Body: same fields (all optional). Response: Game or 404.
|
||||
- `DELETE /api/games/{id}` — Security: bearerAuth. Response: `{ message }` or 404.
|
||||
- `PATCH /api/games/{id}/toggle` — Security: bearerAuth. Response: Game (with toggled enabled) or 404.
|
||||
- `PATCH /api/games/packs/{name}/toggle` — Security: bearerAuth. Body: `{ enabled }`. Response: `{ message, gamesAffected }`. Error 400: missing enabled.
|
||||
- `POST /api/games/import/csv` — Security: bearerAuth. Body: `{ csvData, mode }` (mode: "append" or "replace"). Response: `{ message, count, mode }`. Error 400: missing csvData. CSV columns: Game Pack, Game Title, Min. Players, Max. Players, Length, Audience, Family Friendly?, Game Type, Secondary Type.
|
||||
- `PATCH /api/games/{id}/favor` — Security: bearerAuth. Body: `{ favor_bias }` (-1/0/1). Response: `{ message, favor_bias }`. Error 400/404.
|
||||
|
||||
**Step 3: Validate YAML syntax**
|
||||
|
||||
Same validation command as Task 2 Step 2.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/api/openapi.yaml
|
||||
git commit -m "docs: add Auth and Games endpoint paths to OpenAPI spec"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Write OpenAPI paths — Sessions, Picker, Stats, Votes, Webhooks
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/api/openapi.yaml`
|
||||
|
||||
**Step 1: Add Sessions endpoints (15 endpoints)**
|
||||
|
||||
Source: `backend/routes/sessions.js`
|
||||
|
||||
Add paths for:
|
||||
- `GET /api/sessions` — Response: array of Session (with games_played count).
|
||||
- `GET /api/sessions/active` — Response: Session object or `{ session: null, message }`.
|
||||
- `GET /api/sessions/{id}` — Response: Session or 404.
|
||||
- `POST /api/sessions` — Security: bearerAuth. Body: `{ notes? }`. Response 201: Session. Error 400: active session already exists (`{ error, activeSessionId }`). Triggers WebSocket `session.started` broadcast.
|
||||
- `POST /api/sessions/{id}/close` — Security: bearerAuth. Body: `{ notes? }`. Response: closed Session (with games_played). Error 404/400 (already closed). Sets all 'playing' games to 'played'. Triggers WebSocket `session.ended`.
|
||||
- `DELETE /api/sessions/{id}` — Security: bearerAuth. Response: `{ message, sessionId }`. Error 404/400 (cannot delete active). Cascades: deletes chat_logs and session_games.
|
||||
- `GET /api/sessions/{id}/games` — Response: array of SessionGame (joined with game data).
|
||||
- `POST /api/sessions/{id}/games` — Security: bearerAuth. Body: `{ game_id, manually_added?, room_code? }`. Response 201: SessionGame. Error 400 (closed session, missing game_id), 404 (session/game not found). Side effects: increments play_count, sets previous 'playing' games to 'played', triggers `game.added` webhook + WebSocket, auto-starts room monitor if room_code provided.
|
||||
- `POST /api/sessions/{id}/chat-import` — Security: bearerAuth. Body: `{ chatData: [{ username, message, timestamp }] }`. Response: `{ message, messagesImported, duplicatesSkipped, votesProcessed, votesByGame, debug }`. Vote patterns: "thisgame++" = upvote, "thisgame--" = downvote. Matches votes to games by timestamp intervals.
|
||||
- `PATCH /api/sessions/{sessionId}/games/{gameId}/status` — Security: bearerAuth. Body: `{ status }` (playing/played/skipped). Response: `{ message, status }`. Error 400/404. If setting to 'playing', auto-sets other playing games to 'played'.
|
||||
- `DELETE /api/sessions/{sessionId}/games/{gameId}` — Security: bearerAuth. Response: `{ message }`. Error 404. Stops room monitor/player count check.
|
||||
- `PATCH /api/sessions/{sessionId}/games/{gameId}/room-code` — Security: bearerAuth. Body: `{ room_code }` (exactly 4 chars, A-Z0-9). Response: SessionGame. Error 400 (invalid format)/404.
|
||||
- `GET /api/sessions/{id}/export` — Security: bearerAuth. Query: `format` ("json" or "txt", default "txt"). Response: file download (application/json or text/plain).
|
||||
- `POST /api/sessions/{sessionId}/games/{gameId}/start-player-check` — Security: bearerAuth. Response: `{ message, status: "monitoring" }`. Error 400 (no room code)/404.
|
||||
- `POST /api/sessions/{sessionId}/games/{gameId}/stop-player-check` — Security: bearerAuth. Response: `{ message, status: "stopped" }`.
|
||||
- `PATCH /api/sessions/{sessionId}/games/{gameId}/player-count` — Security: bearerAuth. Body: `{ player_count }` (non-negative integer). Response: `{ message, player_count }`. Error 400/404. Triggers WebSocket `player-count.updated`.
|
||||
|
||||
**Step 2: Add Picker endpoint**
|
||||
|
||||
Source: `backend/routes/picker.js` — mounted at `/api` (not `/api/picker`).
|
||||
|
||||
- `POST /api/pick` — No auth. Body: `{ playerCount?, drawing?, length?, familyFriendly?, sessionId?, excludePlayed? }`. Response 200: `{ game: Game, poolSize, totalEnabled }`. Response 404: `{ error, suggestion, recentlyPlayed? }`. Bias: game favor_bias 1=3x, -1=0.2x; pack favor_bias 1=2x, -1=0.3x. Repeat avoidance: excludes last 2 played games by default, or all played if excludePlayed=true.
|
||||
|
||||
**Step 3: Add Stats endpoint**
|
||||
|
||||
Source: `backend/routes/stats.js`
|
||||
|
||||
- `GET /api/stats` — No auth. Response: `{ games: { count }, gamesEnabled: { count }, packs: { count }, sessions: { count }, activeSessions: { count }, totalGamesPlayed: { count }, mostPlayedGames: [...], topRatedGames: [...] }`. The game arrays include: id, title, pack_name, play_count, popularity_score, upvotes, downvotes. Limited to top 10.
|
||||
|
||||
**Step 4: Add Votes endpoint**
|
||||
|
||||
Source: `backend/routes/votes.js`
|
||||
|
||||
- `POST /api/votes/live` — Security: bearerAuth. Body: `{ username, vote, timestamp }` where vote is "up" or "down", timestamp is ISO 8601. Response 200: `{ success, message, session: { id, games_played }, game: { id, title, upvotes, downvotes, popularity_score }, vote: { username, type, timestamp } }`. Error 400 (missing fields, invalid vote/timestamp), 404 (no active session, no games, vote doesn't match a game), 409 (duplicate within 1 second).
|
||||
|
||||
**Step 5: Add Webhooks endpoints (7 endpoints)**
|
||||
|
||||
Source: `backend/routes/webhooks.js`
|
||||
|
||||
- `GET /api/webhooks` — Security: bearerAuth. Response: array of Webhook (events parsed from JSON, enabled as boolean).
|
||||
- `GET /api/webhooks/{id}` — Security: bearerAuth. Response: Webhook or 404.
|
||||
- `POST /api/webhooks` — Security: bearerAuth. Body: `{ name, url, secret, events }` where events is string array. Response 201: Webhook + `{ message }`. Error 400: missing fields, invalid URL, events not array.
|
||||
- `PATCH /api/webhooks/{id}` — Security: bearerAuth. Body: `{ name?, url?, secret?, events?, enabled? }`. Response: Webhook + `{ message }`. Error 400 (no fields, invalid URL, events not array)/404.
|
||||
- `DELETE /api/webhooks/{id}` — Security: bearerAuth. Response: `{ message, webhookId }`. Error 404.
|
||||
- `POST /api/webhooks/test/{id}` — Security: bearerAuth. Sends test `game.added` payload. Response: `{ message, note }`. Error 404.
|
||||
- `GET /api/webhooks/{id}/logs` — Security: bearerAuth. Query: `limit` (default 50). Response: array of WebhookLog (payload parsed from JSON).
|
||||
|
||||
**Step 6: Add Health endpoint**
|
||||
|
||||
- `GET /health` — No auth. Response: `{ status: "ok", message: "Jackbox Game Picker API is running" }`.
|
||||
|
||||
**Step 7: Validate YAML syntax**
|
||||
|
||||
Same validation as before.
|
||||
|
||||
**Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/api/openapi.yaml
|
||||
git commit -m "docs: complete all OpenAPI endpoint paths"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Write API README
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/api/README.md`
|
||||
|
||||
**Step 1: Write the API overview document**
|
||||
|
||||
Content should include:
|
||||
- **Overview:** What the API does (manage Jackbox games, run sessions, track popularity, pick games with weighted randomness)
|
||||
- **Base URL:** `http://localhost:5000` (direct) or `http://localhost:3000/api` (Docker proxy). All REST endpoints prefixed with `/api/` except `/health`.
|
||||
- **Authentication:** POST to `/api/auth/login` with admin key → receive JWT. Include as `Authorization: Bearer <token>`. Tokens expire in 24 hours. Public endpoints (GET games, GET sessions, GET stats, POST pick, GET health) don't require auth. All write operations require auth.
|
||||
- **Request/Response format:** JSON request/response. `Content-Type: application/json`. Exceptions: CSV export returns `text/csv`, session export can return `text/plain`.
|
||||
- **Error handling:** All errors return `{ "error": "message" }`. HTTP status codes: 400 (bad request/validation), 401 (no token), 403 (invalid/expired token), 404 (not found), 409 (conflict/duplicate), 500 (server error).
|
||||
- **Boolean fields:** SQLite stores booleans as integers (0/1). In request bodies, pass JS booleans; the API converts. In responses, expect 0/1 except for Webhook.enabled which returns a JS boolean.
|
||||
- **Pagination:** No pagination — all list endpoints return full result sets.
|
||||
- **Quick reference table:** All 41 endpoints in a single table: Method | Path | Auth | Description
|
||||
- **Links:** to endpoint docs, WebSocket docs, and guides
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/api/README.md
|
||||
git commit -m "docs: add API README with overview, auth, and quick reference"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Write endpoint docs — Auth and Games
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/api/endpoints/auth.md`
|
||||
- Create: `docs/api/endpoints/games.md`
|
||||
|
||||
**Step 1: Write auth.md**
|
||||
|
||||
Cover the 2 auth endpoints with the template from the design doc. Include:
|
||||
- Overview: simple admin-key authentication. One role (admin). No user management.
|
||||
- Endpoint table
|
||||
- For each endpoint: description, auth, parameters, request body, response, errors, curl example
|
||||
- curl examples must use realistic sample data
|
||||
|
||||
**Step 2: Write games.md**
|
||||
|
||||
Cover all 13 games endpoints with the template. Important details to include from source code:
|
||||
- GET /api/games filter behavior: `drawing=only` matches `game_type='Drawing'`, `drawing=exclude` excludes Drawing. `length=short` is ≤15min (including NULL), `medium` is 16-25min, `long` is >25min. Results ordered by pack_name, title.
|
||||
- POST /api/games requires: pack_name, title, min_players, max_players. Optional: length_minutes, has_audience, family_friendly, game_type, secondary_type.
|
||||
- PUT /api/games/:id uses COALESCE for most fields (only updates what's provided), but length_minutes, game_type, secondary_type accept explicit null.
|
||||
- CSV import expects columns: Game Pack, Game Title, Min. Players, Max. Players, Length, Audience, Family Friendly?, Game Type, Secondary Type. Mode "replace" deletes ALL existing games first.
|
||||
- Favor bias: 1=favor, -1=disfavor, 0=neutral. Applies to picker weighting.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/api/endpoints/auth.md docs/api/endpoints/games.md
|
||||
git commit -m "docs: add Auth and Games endpoint documentation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Write endpoint docs — Sessions
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/api/endpoints/sessions.md`
|
||||
|
||||
**Step 1: Write sessions.md**
|
||||
|
||||
Cover all 15 sessions endpoints. Important details from source code:
|
||||
- Only one active session at a time. Creating a session when one exists returns 400 with activeSessionId.
|
||||
- Closing a session auto-sets all 'playing' games to 'played'.
|
||||
- Cannot delete active sessions — must close first. Delete cascades chat_logs and session_games.
|
||||
- Adding a game to session: auto-sets previous 'playing' games to 'played' (skipped games stay skipped), increments game play_count, triggers `game.added` webhook + WebSocket, auto-starts room monitor if room_code provided.
|
||||
- Chat import: matches vote timestamps to games using interval logic (vote belongs to game whose played_at is most recent before vote timestamp). Deduplicates by SHA-256 hash of `username:message:timestamp`.
|
||||
- Status update to 'playing' auto-sets other 'playing' games to 'played'.
|
||||
- Room code: exactly 4 chars, uppercase A-Z and 0-9 only. Regex: `/^[A-Z0-9]{4}$/`.
|
||||
- Export formats: JSON (structured with session+games+chat_logs) and TXT (human-readable plaintext).
|
||||
- Player count: body is `{ player_count }`, must be non-negative integer. Sets `player_count_check_status` to 'completed'. Broadcasts `player-count.updated` via WebSocket.
|
||||
- The `gameId` parameter in session game sub-routes refers to `session_games.id`, NOT `games.id`.
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/api/endpoints/sessions.md
|
||||
git commit -m "docs: add Sessions endpoint documentation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Write endpoint docs — Picker, Stats, Votes, Webhooks
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/api/endpoints/picker.md`
|
||||
- Create: `docs/api/endpoints/stats.md`
|
||||
- Create: `docs/api/endpoints/votes.md`
|
||||
- Create: `docs/api/endpoints/webhooks.md`
|
||||
|
||||
**Step 1: Write picker.md**
|
||||
|
||||
Key details from source code:
|
||||
- Filters enabled games only (enabled=1)
|
||||
- Weighted random: game favor_bias 1 = 3x weight, -1 = 0.2x weight. Pack favor_bias 1 = 2x weight, -1 = 0.3x weight. Biases multiply.
|
||||
- Repeat avoidance: with sessionId, excludes last 2 games by default. With excludePlayed=true, excludes ALL games played in session.
|
||||
- 404 when no games match filters (with suggestion), or when all eligible games have been played.
|
||||
|
||||
**Step 2: Write stats.md**
|
||||
|
||||
Key details: single endpoint, no auth, returns aggregate counts and top-10 lists (most played, top rated). mostPlayedGames sorted by play_count DESC, topRatedGames sorted by popularity_score DESC, both only include games with > 0 in respective metric.
|
||||
|
||||
**Step 3: Write votes.md**
|
||||
|
||||
Key details:
|
||||
- Requires auth. Body: `{ username, vote, timestamp }`.
|
||||
- `vote` must be "up" or "down". `timestamp` must be valid ISO 8601.
|
||||
- Automatically matches vote to the correct game in the active session using timestamp interval logic.
|
||||
- Deduplication: rejects votes from same username within 1 second (409).
|
||||
- Updates game upvotes/downvotes/popularity_score atomically in a transaction.
|
||||
|
||||
**Step 4: Write webhooks.md**
|
||||
|
||||
Key details:
|
||||
- All endpoints require auth.
|
||||
- Events stored as JSON string in DB, returned as parsed array.
|
||||
- `enabled` stored as 0/1 in DB, returned as JS boolean.
|
||||
- Secret is never returned in GET responses (excluded from SELECT).
|
||||
- Test sends a `game.added` event with dummy data.
|
||||
- Logs include parsed payload, limited by `limit` query param (default 50).
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/api/endpoints/picker.md docs/api/endpoints/stats.md docs/api/endpoints/votes.md docs/api/endpoints/webhooks.md
|
||||
git commit -m "docs: add Picker, Stats, Votes, and Webhooks endpoint documentation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Write WebSocket documentation
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/api/websocket.md`
|
||||
|
||||
**Step 1: Write websocket.md**
|
||||
|
||||
Cover the full WebSocket protocol from `backend/utils/websocket-manager.js`:
|
||||
|
||||
- **Connection:** `ws://host:port/api/sessions/live`. No query params needed. Connection established without auth.
|
||||
- **Authentication:** Send `{ "type": "auth", "token": "<jwt>" }`. Server responds with `{ "type": "auth_success", "message": "Authenticated successfully" }` or `{ "type": "auth_error", "message": "..." }`. Auth required for subscribe/unsubscribe.
|
||||
- **Subscription model:** Subscribe to a specific session's events with `{ "type": "subscribe", "sessionId": <number> }`. Response: `{ "type": "subscribed", "sessionId": <number>, "message": "..." }`. Can subscribe to multiple sessions. Unsubscribe with `{ "type": "unsubscribe", "sessionId": <number> }`.
|
||||
- **Heartbeat:** Client sends `{ "type": "ping" }`, server responds `{ "type": "pong" }`. Timeout: 60 seconds since last ping — server terminates connection. Heartbeat check runs every 30 seconds.
|
||||
- **Events (server → client):**
|
||||
- `session.started` — broadcast to ALL authenticated clients (not session-specific). Data: `{ session: { id, is_active, created_at, notes } }`. Triggered when `POST /api/sessions` creates a new session.
|
||||
- `game.added` — broadcast to session subscribers. Data: `{ session: { id, is_active, games_played }, game: { id, title, pack_name, min_players, max_players, manually_added, room_code } }`. Triggered when `POST /api/sessions/:id/games` adds a game.
|
||||
- `session.ended` — broadcast to session subscribers. Data: `{ session: { id, is_active, games_played } }`. Triggered when `POST /api/sessions/:id/close` closes session.
|
||||
- `player-count.updated` — broadcast to session subscribers. Data: `{ sessionId, gameId, playerCount, status }`. Triggered when player count is updated.
|
||||
- **Event envelope:** `{ "type": "<event-type>", "timestamp": "<ISO 8601>", "data": { ... } }`
|
||||
- **Error messages:** `{ "type": "error", "message": "..." }` for general errors, `{ "type": "auth_error", "message": "..." }` for auth failures.
|
||||
- **Connection lifecycle example:** Connect → auth → subscribe → receive events → ping/pong loop → unsubscribe → close.
|
||||
- **Reconnection:** Server doesn't maintain state across disconnects. Client must re-authenticate and re-subscribe after reconnecting.
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/api/websocket.md
|
||||
git commit -m "docs: add WebSocket protocol documentation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Write guide — Getting Started
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/api/guides/getting-started.md`
|
||||
|
||||
**Step 1: Write getting-started.md**
|
||||
|
||||
Narrative guide walking through the minimum viable integration:
|
||||
1. Health check — verify API is running
|
||||
2. Authenticate — get a JWT token
|
||||
3. Browse games — list all games, filter by player count
|
||||
4. Pick a game — use the picker with filters
|
||||
5. Start a session — create session, add the picked game
|
||||
6. Close the session
|
||||
|
||||
Each step includes a curl example and expected response. Cross-references the endpoint docs for full details.
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/api/guides/getting-started.md
|
||||
git commit -m "docs: add Getting Started guide"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Write guide — Session Lifecycle
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/api/guides/session-lifecycle.md`
|
||||
|
||||
**Step 1: Write session-lifecycle.md**
|
||||
|
||||
Narrative guide covering:
|
||||
1. Creating a session (one active at a time constraint)
|
||||
2. Adding games (via picker or manual), understanding auto-status-transitions
|
||||
3. Tracking game status (playing → played/skipped)
|
||||
4. Room codes and player count monitoring
|
||||
5. Closing sessions (auto-finalizes playing games)
|
||||
6. Exporting session data (JSON and TXT formats)
|
||||
7. Deleting old sessions (must close first, cascades)
|
||||
8. WebSocket integration for real-time updates
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/api/guides/session-lifecycle.md
|
||||
git commit -m "docs: add Session Lifecycle guide"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Write guide — Voting and Popularity
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/api/guides/voting-and-popularity.md`
|
||||
|
||||
**Step 1: Write voting-and-popularity.md**
|
||||
|
||||
Narrative guide covering:
|
||||
1. How popularity works: `popularity_score = upvotes - downvotes`
|
||||
2. Two voting mechanisms: chat import (batch, after-the-fact) and live votes (real-time, from bots)
|
||||
3. Chat import flow: collect chat logs with `thisgame++`/`thisgame--` patterns, POST to chat-import, timestamp-matching algorithm explained
|
||||
4. Live vote flow: bot sends votes in real-time via POST /api/votes/live, same timestamp-matching logic, deduplication within 1 second
|
||||
5. How voting affects the picker: popularity_score doesn't directly affect picker weights (favor_bias does), but topRatedGames in stats uses it
|
||||
6. Favor bias vs popularity: favor_bias is admin-controlled weighting for the picker; popularity is community-driven sentiment tracking
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/api/guides/voting-and-popularity.md
|
||||
git commit -m "docs: add Voting and Popularity guide"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Write guide — Webhooks and Events
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/api/guides/webhooks-and-events.md`
|
||||
|
||||
**Step 1: Write webhooks-and-events.md**
|
||||
|
||||
Narrative guide covering:
|
||||
1. Two notification systems: Webhooks (HTTP callbacks) and WebSocket (persistent connections)
|
||||
2. When to use which: Webhooks for server-to-server integrations (bots, Discord); WebSocket for real-time UI or tools that maintain persistent connections
|
||||
3. Webhook setup: create webhook, specify events, provide URL and secret
|
||||
4. Webhook events: `game.added` (currently the only webhook event triggered in code — verify this). Payload shape.
|
||||
5. Webhook delivery: async, logged in webhook_logs. Test with POST /api/webhooks/test/:id.
|
||||
6. WebSocket events: `session.started`, `game.added`, `session.ended`, `player-count.updated`. Which are broadcast to all clients vs session subscribers.
|
||||
7. Event payload reference (linking to websocket.md)
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/api/guides/webhooks-and-events.md
|
||||
git commit -m "docs: add Webhooks and Events guide"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Validate documentation against source code
|
||||
|
||||
**Files:**
|
||||
- Read: all `backend/routes/*.js` files
|
||||
- Read: `docs/api/openapi.yaml`
|
||||
- Read: all `docs/api/endpoints/*.md` files
|
||||
|
||||
**Step 1: Cross-reference every route handler against the OpenAPI spec**
|
||||
|
||||
For each file in `backend/routes/`:
|
||||
- Count endpoints in source code
|
||||
- Count corresponding paths in `openapi.yaml`
|
||||
- Verify HTTP methods match
|
||||
- Verify path patterns match (including parameter names)
|
||||
- Verify auth requirements match (`authenticateToken` usage)
|
||||
|
||||
**Step 2: Verify request/response shapes**
|
||||
|
||||
For each endpoint:
|
||||
- Compare request body fields to what the route handler destructures from `req.body`
|
||||
- Compare response shapes to what the route handler actually sends via `res.json()`
|
||||
- Check error status codes and messages match
|
||||
|
||||
**Step 3: Fix any discrepancies found**
|
||||
|
||||
Edit the OpenAPI spec and/or Markdown files to match the source code.
|
||||
|
||||
**Step 4: Commit any fixes**
|
||||
|
||||
```bash
|
||||
git add docs/
|
||||
git commit -m "docs: fix discrepancies found during validation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 15: Final review and summary commit
|
||||
|
||||
**Step 1: Verify all files exist**
|
||||
|
||||
```bash
|
||||
ls -la docs/api/
|
||||
ls -la docs/api/endpoints/
|
||||
ls -la docs/api/guides/
|
||||
ls -la docs/archive/
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `docs/api/`: openapi.yaml, README.md, websocket.md
|
||||
- `docs/api/endpoints/`: auth.md, games.md, sessions.md, picker.md, stats.md, votes.md, webhooks.md
|
||||
- `docs/api/guides/`: getting-started.md, session-lifecycle.md, voting-and-popularity.md, webhooks-and-events.md
|
||||
- `docs/archive/`: 9 old doc files
|
||||
|
||||
**Step 2: Verify total endpoint count in OpenAPI spec**
|
||||
|
||||
```bash
|
||||
grep -c "operationId:" docs/api/openapi.yaml
|
||||
```
|
||||
|
||||
Expected: 42 (41 API endpoints + 1 health check)
|
||||
|
||||
**Step 3: Final commit if any files were missed**
|
||||
|
||||
```bash
|
||||
git add docs/
|
||||
git status
|
||||
git commit -m "docs: complete API documentation with OpenAPI spec, endpoint docs, WebSocket, and guides"
|
||||
```
|
||||
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
|
||||
1572
docs/plans/2026-03-15-vote-tracking-api.md
Normal file
1572
docs/plans/2026-03-15-vote-tracking-api.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
# Jackbox Ecast API Reverse Engineering — Design
|
||||
|
||||
**Date:** 2026-03-20
|
||||
**Goal:** Produce comprehensive documentation of the Jackbox ecast API (REST + WebSocket) through live traffic analysis.
|
||||
**Deliverable:** `docs/jackbox-ecast-api.md`
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
1. **Ecast REST API** — All discoverable endpoints at `ecast.jackboxgames.com`, including undocumented paths, multiple API versions, and different HTTP methods.
|
||||
|
||||
2. **Ecast WebSocket Protocol** — Full bidirectional message protocol between `jackbox.tv` and ecast servers: connection handshake, all opcodes/message types, entity model, and state transitions through the complete game lifecycle.
|
||||
|
||||
3. **Player & Room Management** — Join/leave detection, real-time player count, max players, lobby lock/game start signals, game completion signals, player and game stats/results.
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Kosmi integration (chrome extension)
|
||||
- This application's own API
|
||||
- Game-specific mechanics (drawing, voting within Drawful 2) unless they reveal useful metadata
|
||||
|
||||
## Approach
|
||||
|
||||
**Primary: REST probing + browser console WebSocket interception (Approach A)**
|
||||
**Fallback: Puppeteer + CDP automated capture script (Approach B)**
|
||||
|
||||
### Phase 1 — REST API Probing
|
||||
|
||||
- Probe known endpoint `GET /api/v2/rooms/{code}` and document full response
|
||||
- Discover new paths: `/players`, `/audience`, `/state`, etc.
|
||||
- Try API versions `v1`, `v3`
|
||||
- Check for discovery endpoints (`/api`, `/swagger`, `/openapi`, `/health`)
|
||||
- Try different HTTP methods (POST, PUT, OPTIONS)
|
||||
- Document all response schemas, headers, status codes
|
||||
|
||||
### Phase 2 — WebSocket Interception
|
||||
|
||||
- Navigate to `jackbox.tv` in browser
|
||||
- Inject WebSocket interceptor (monkey-patch `WebSocket`) before joining room
|
||||
- Join as player — capture full connection lifecycle
|
||||
- Join as audience — capture audience-specific messages
|
||||
- If injection races with connection, reload page after patching
|
||||
|
||||
### Phase 3 — Game Lifecycle Capture
|
||||
|
||||
Walk through entire Drawful 2 lifecycle with interceptor running:
|
||||
- Lobby: join/leave messages, player list updates
|
||||
- Game start: lock signal, round info
|
||||
- Gameplay: state transitions, metadata
|
||||
- Game end: results, stats, disconnection sequence
|
||||
- Multiple players to observe multi-player messages
|
||||
|
||||
### Fallback (Approach B)
|
||||
|
||||
If browser console interception is unreliable, write a Node.js script using Puppeteer + CDP `Network.webSocketFrame*` events for automated structured capture.
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
The deliverable (`docs/jackbox-ecast-api.md`) will contain:
|
||||
|
||||
1. **Overview** — What ecast is, base URLs, architecture
|
||||
2. **REST API Reference** — Each endpoint with method, URL, params, response schema, notes
|
||||
3. **WebSocket Protocol Reference** — Connection details, message format, message catalog (all opcodes), entity model
|
||||
4. **Game Lifecycle** — Sequence diagram of message flow from room creation through completion
|
||||
5. **Player & Room Management** — Answers to specific questions (join/leave detection, player count, max players, lock/start, completion, stats) with supporting evidence
|
||||
6. **Appendix: Raw Captures** — Sanitized example payloads
|
||||
345
docs/plans/2026-03-20-shard-monitor-design.md
Normal file
345
docs/plans/2026-03-20-shard-monitor-design.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# Ecast Shard Monitor — Design Document
|
||||
|
||||
**Date:** 2026-03-20
|
||||
**Status:** Approved
|
||||
**Replaces:** `room-monitor.js` (REST polling for lock) + `player-count-checker.js` (Puppeteer audience join)
|
||||
|
||||
## Problem
|
||||
|
||||
The current player count approach launches a headless Chrome instance via Puppeteer, navigates to `jackbox.tv`, joins as an audience member through the UI, and sniffs WebSocket frames via CDP. This is fragile, resource-heavy, and occupies an audience slot. The room monitor is a separate module that polls the REST API until the room locks, then hands off to the Puppeteer checker. Two modules, two connection strategies, a circular dependency workaround.
|
||||
|
||||
## Solution
|
||||
|
||||
Replace both modules with a single `EcastShardClient` that connects to the Jackbox ecast server as a **shard** via a direct Node.js WebSocket. The shard role:
|
||||
|
||||
- Gets the full `here` map (authoritative player list with names and roles)
|
||||
- Receives real-time entity updates (room state, player joins, game end)
|
||||
- Can query entities via `object/get`
|
||||
- Does NOT count toward `maxPlayers` or trigger `full: true`
|
||||
- Does NOT require a browser
|
||||
|
||||
One REST call upfront validates the room and retrieves the `host` field needed for the WebSocket URL. After that, the shard connection handles everything.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Lifecycle
|
||||
|
||||
```
|
||||
Room code registered
|
||||
│
|
||||
▼
|
||||
REST: GET /rooms/{code} ──── 404 ──→ Mark failed, stop
|
||||
│
|
||||
│ (get host, maxPlayers, locked, appTag)
|
||||
▼
|
||||
WSS: Connect as shard
|
||||
wss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&userId=gamepicker-{sessionId}&format=json
|
||||
│
|
||||
▼
|
||||
client/welcome received
|
||||
├── Parse `here` → initial player count (filter for `player` roles)
|
||||
├── Parse `entities.room` → lobby state, gameCanStart, etc.
|
||||
├── Store `secret` + `id` for reconnection
|
||||
└── Broadcast initial state to our clients
|
||||
│
|
||||
▼
|
||||
┌─── Event loop (listening for server messages) ───┐
|
||||
│ │
|
||||
│ `object` (key: textDescriptions) │
|
||||
│ → Parse latestDescriptions for player joins │
|
||||
│ → Broadcast `lobby.player-joined` to clients │
|
||||
│ │
|
||||
│ `object` (key: room) │
|
||||
│ → Detect state transitions: │
|
||||
│ lobbyState changes → broadcast lobby updates │
|
||||
│ state: "Gameplay" → broadcast `game.started` │
|
||||
│ gameFinished: true → broadcast `game.ended` │
|
||||
│ gameResults → extract final player count │
|
||||
│ │
|
||||
│ `client/connected` (if delivered to shards) │
|
||||
│ → Update here map, recount players │
|
||||
│ │
|
||||
│ WebSocket close/error │
|
||||
│ → REST check: room exists? │
|
||||
│ Yes → reconnect with secret/id │
|
||||
│ No → game ended, finalize │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Internal State
|
||||
|
||||
| Field | Type | Source |
|
||||
|-------|------|--------|
|
||||
| `playerCount` | number | `here` map filtered for `player` roles |
|
||||
| `playerNames` | string[] | `here` map player role `name` fields |
|
||||
| `lobbyState` | string | `room` entity `lobbyState` |
|
||||
| `gameState` | string | `room` entity `state` (`"Lobby"`, `"Gameplay"`) |
|
||||
| `gameStarted` | boolean | Derived from `state === "Gameplay"` |
|
||||
| `gameFinished` | boolean | `room` entity `gameFinished` |
|
||||
| `maxPlayers` | number | REST response + `room` entity |
|
||||
| `secret` / `id` | string/number | `client/welcome` for reconnection |
|
||||
|
||||
### Player Counting
|
||||
|
||||
The `here` map from `client/welcome` is the authoritative source. It lists all registered connections with their roles. Count entries where `roles` contains `player`. The shard itself is excluded (it has `roles: {shard: {}}`). The host (ID 1, `roles: {host: {}}`) is also excluded. Since Jackbox holds slots for disconnected players, `here` always reflects the true occupied slot count.
|
||||
|
||||
For subsequent joins after connect, `textDescriptions` entity updates provide join notifications. Since shards have `here` visibility, `client/connected` messages may also be delivered — both paths are handled, with `here` as source of truth.
|
||||
|
||||
## WebSocket Events (Game Picker → Connected Clients)
|
||||
|
||||
### `room.connected`
|
||||
|
||||
Shard successfully connected to the Jackbox room. Sent once on initial connect. Replaces the old `audience.joined` event.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "room.connected",
|
||||
"timestamp": "...",
|
||||
"data": {
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"appTag": "drawful2international",
|
||||
"maxPlayers": 8,
|
||||
"playerCount": 2,
|
||||
"players": ["Alice", "Bob"],
|
||||
"lobbyState": "CanStart",
|
||||
"gameState": "Lobby"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `lobby.player-joined`
|
||||
|
||||
A new player joined the lobby.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "lobby.player-joined",
|
||||
"timestamp": "...",
|
||||
"data": {
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"playerName": "Charlie",
|
||||
"playerCount": 3,
|
||||
"players": ["Alice", "Bob", "Charlie"],
|
||||
"maxPlayers": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `lobby.updated`
|
||||
|
||||
Lobby state changed (enough players to start, countdown started, etc.).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "lobby.updated",
|
||||
"timestamp": "...",
|
||||
"data": {
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"lobbyState": "Countdown",
|
||||
"gameCanStart": true,
|
||||
"gameIsStarting": true,
|
||||
"playerCount": 4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `game.started`
|
||||
|
||||
The game transitioned from Lobby to Gameplay.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "game.started",
|
||||
"timestamp": "...",
|
||||
"data": {
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"playerCount": 4,
|
||||
"players": ["Alice", "Bob", "Charlie", "Diana"],
|
||||
"maxPlayers": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `game.ended`
|
||||
|
||||
The game finished.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "game.ended",
|
||||
"timestamp": "...",
|
||||
"data": {
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"playerCount": 4,
|
||||
"players": ["Alice", "Bob", "Charlie", "Diana"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `room.disconnected`
|
||||
|
||||
Shard lost connection to the Jackbox room.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "room.disconnected",
|
||||
"timestamp": "...",
|
||||
"data": {
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"reason": "room_closed",
|
||||
"finalPlayerCount": 4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Possible `reason` values: `room_closed`, `room_not_found`, `connection_failed`, `role_rejected`, `manually_stopped`.
|
||||
|
||||
### Dropped Events
|
||||
|
||||
| Old event | Replacement |
|
||||
|-----------|-------------|
|
||||
| `audience.joined` | `room.connected` (richer payload) |
|
||||
| `player-count.updated` (automated) | `lobby.player-joined`, `game.started`, `game.ended` carry `playerCount` |
|
||||
|
||||
The manual `PATCH .../player-count` endpoint keeps broadcasting `player-count.updated` for its specific use case.
|
||||
|
||||
### DB Persistence
|
||||
|
||||
The `session_games` table columns `player_count` and `player_count_check_status` continue to be updated:
|
||||
|
||||
- `player_count` — updated on each join and at game end
|
||||
- `player_count_check_status` — `'monitoring'` (shard connected), `'completed'` (game ended with count), `'failed'` (couldn't connect), `'stopped'` (manual stop)
|
||||
|
||||
The old `'checking'` status becomes `'monitoring'`.
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Files Deleted
|
||||
|
||||
- `backend/utils/player-count-checker.js` — Puppeteer audience approach
|
||||
- `backend/utils/room-monitor.js` — REST polling for lock state
|
||||
|
||||
### Files Created
|
||||
|
||||
- `backend/utils/ecast-shard-client.js` — `EcastShardClient` class + module exports: `startMonitor`, `stopMonitor`, `cleanupAllShards`
|
||||
|
||||
### Files Modified
|
||||
|
||||
**`backend/utils/jackbox-api.js`** — Add `getRoomInfo(roomCode)` returning the full room response including `host`, `appTag`, `audienceEnabled`.
|
||||
|
||||
**`backend/routes/sessions.js`** — Replace imports:
|
||||
|
||||
```javascript
|
||||
// Old
|
||||
const { stopPlayerCountCheck } = require('../utils/player-count-checker');
|
||||
const { startRoomMonitor, stopRoomMonitor } = require('../utils/room-monitor');
|
||||
|
||||
// New
|
||||
const { startMonitor, stopMonitor } = require('../utils/ecast-shard-client');
|
||||
```
|
||||
|
||||
All call sites change from two-function calls to one:
|
||||
|
||||
| Route | Old | New |
|
||||
|-------|-----|-----|
|
||||
| `POST /:id/games` (with room_code) | `startRoomMonitor(...)` | `startMonitor(...)` |
|
||||
| `PATCH .../status` (away from playing) | `stopRoomMonitor(...) + stopPlayerCountCheck(...)` | `stopMonitor(...)` |
|
||||
| `DELETE .../games/:gameId` | `stopRoomMonitor(...) + stopPlayerCountCheck(...)` | `stopMonitor(...)` |
|
||||
| `POST .../start-player-check` | `startRoomMonitor(...)` | `startMonitor(...)` |
|
||||
| `POST .../stop-player-check` | `stopRoomMonitor(...) + stopPlayerCountCheck(...)` | `stopMonitor(...)` |
|
||||
|
||||
Endpoint paths stay the same for backwards compatibility.
|
||||
|
||||
**`backend/server.js`** — Wire `cleanupAllShards()` into `SIGTERM`/`SIGINT` handlers.
|
||||
|
||||
## Error Handling and Reconnection
|
||||
|
||||
### Connection Failures
|
||||
|
||||
1. **REST validation fails** (room not found, network error): Set status `'failed'`, broadcast `room.disconnected` with `reason: 'room_not_found'` or `'connection_failed'`. No automatic retry.
|
||||
|
||||
2. **Shard WebSocket fails to connect**: Retry up to 3 times with exponential backoff (2s, 4s, 8s). On exhaustion, set status `'failed'`, broadcast `room.disconnected` with `reason: 'connection_failed'`.
|
||||
|
||||
3. **Ecast rejects the shard role** (error opcode received): Set status `'failed'`, broadcast `room.disconnected` with `reason: 'role_rejected'`. No retry.
|
||||
|
||||
### Mid-Session Disconnections
|
||||
|
||||
4. **WebSocket closes unexpectedly**: REST check `GET /rooms/{code}`:
|
||||
- Room exists → reconnect with stored `secret`/`id` (up to 3 attempts, exponential backoff). Transparent to clients on success.
|
||||
- Room gone → finalize with last known count, status `'completed'`, broadcast `game.ended` + `room.disconnected`.
|
||||
|
||||
5. **Ecast error 2027 "room already closed"**: Same as room-gone path.
|
||||
|
||||
### Manual Stop
|
||||
|
||||
6. **`stop-player-check` called or game status changes**: Close WebSocket gracefully, set status `'stopped'` (unless already `'completed'`), broadcast `room.disconnected` with `reason: 'manually_stopped'`.
|
||||
|
||||
### Server Shutdown
|
||||
|
||||
7. **`SIGTERM`/`SIGINT`**: `cleanupAllShards()` closes all WebSocket connections. No DB updates on shutdown.
|
||||
|
||||
### State Machine
|
||||
|
||||
```
|
||||
startMonitor()
|
||||
│
|
||||
▼
|
||||
┌───────────┐
|
||||
┌────────│ not_started│
|
||||
│ └───────────┘
|
||||
│ │
|
||||
REST fails REST succeeds
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────┐ ┌────────────┐
|
||||
│ failed │ │ monitoring │◄──── reconnect success
|
||||
└────────┘ └─────┬──────┘
|
||||
▲ │
|
||||
│ ┌────┴─────┬──────────────┐
|
||||
reconnect │ │ │
|
||||
exhausted game ends WS drops manual stop
|
||||
│ │ │ │
|
||||
│ ▼ ▼ ▼
|
||||
│ ┌──────────┐ REST check ┌─────────┐
|
||||
│ │ completed │ │ │ stopped │
|
||||
│ └──────────┘ │ └─────────┘
|
||||
│ │
|
||||
└──── room gone? ────┘
|
||||
│
|
||||
room exists?
|
||||
│
|
||||
reconnect...
|
||||
```
|
||||
|
||||
### Timeouts
|
||||
|
||||
| Concern | Value | Rationale |
|
||||
|---------|-------|-----------|
|
||||
| WebSocket connect timeout | 10s | Ecast servers respond fast |
|
||||
| Reconnect backoff | 2s, 4s, 8s | Three attempts, ~14s total |
|
||||
| Max reconnect attempts | 3 | Fail fast, user can retry manually |
|
||||
| WebSocket inactivity timeout | None | Shard connections receive periodic `shard/sync` CRDT messages |
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Added:** `ws` (Node.js WebSocket library) — already a dependency (used by `websocket-manager.js`).
|
||||
|
||||
**Removed:** `puppeteer` — no longer needed for room monitoring.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Renaming REST endpoint paths (`start-player-check` / `stop-player-check`) — kept for backwards compatibility
|
||||
- Auto-starting monitoring when room code is set via `PATCH .../room-code` — kept as manual trigger only
|
||||
- Frontend `Picker.jsx` changes — tracked separately (existing bugs: `message.event` vs `message.type`, subscribe without auth, `'waiting'` status that's never set)
|
||||
722
docs/plans/2026-03-20-shard-monitor-implementation.md
Normal file
722
docs/plans/2026-03-20-shard-monitor-implementation.md
Normal file
@@ -0,0 +1,722 @@
|
||||
# Ecast Shard Monitor Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the Puppeteer-based audience join and REST-polling room monitor with a single WebSocket shard client that monitors Jackbox rooms in real-time.
|
||||
|
||||
**Architecture:** A new `EcastShardClient` class connects as a shard to the Jackbox ecast server via the `ws` library. One REST call validates the room and gets the `host` field. The shard connection then handles lobby monitoring, player counting, game start/end detection, and reconnection. The module exports `startMonitor`, `stopMonitor`, and `cleanupAllShards` as drop-in replacements for the old two-module API.
|
||||
|
||||
**Tech Stack:** Node.js, `ws` library (already installed), ecast WebSocket protocol (`ecast-v0`), Jest for tests.
|
||||
|
||||
**Design doc:** `docs/plans/2026-03-20-shard-monitor-design.md`
|
||||
|
||||
**Ecast API reference:** `docs/jackbox-ecast-api.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extend `jackbox-api.js` with `getRoomInfo`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/utils/jackbox-api.js`
|
||||
- Test: `tests/api/jackbox-api.test.js` (create)
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Create `tests/api/jackbox-api.test.js`:
|
||||
|
||||
```javascript
|
||||
const { getRoomInfo } = require('../../backend/utils/jackbox-api');
|
||||
|
||||
describe('getRoomInfo', () => {
|
||||
test('is exported as a function', () => {
|
||||
expect(typeof getRoomInfo).toBe('function');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/jackbox-api.test.js --verbose --forceExit`
|
||||
Expected: FAIL — `getRoomInfo` is not exported.
|
||||
|
||||
**Step 3: Implement `getRoomInfo`**
|
||||
|
||||
In `backend/utils/jackbox-api.js`, add a new function that calls `GET /api/v2/rooms/{code}` and returns the full room body including `host`, `appTag`, `audienceEnabled`, `maxPlayers`, `locked`, `full`. On failure, return `{ exists: false }`.
|
||||
|
||||
The existing `checkRoomStatus` stays for now (other code may still reference it during migration).
|
||||
|
||||
```javascript
|
||||
async function getRoomInfo(roomCode) {
|
||||
try {
|
||||
const response = await fetch(`${JACKBOX_API_BASE}/rooms/${roomCode}`, {
|
||||
headers: DEFAULT_HEADERS
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const body = data.body || data;
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
host: body.host,
|
||||
audienceHost: body.audienceHost,
|
||||
appTag: body.appTag,
|
||||
appId: body.appId,
|
||||
code: body.code,
|
||||
locked: body.locked || false,
|
||||
full: body.full || false,
|
||||
maxPlayers: body.maxPlayers || 8,
|
||||
minPlayers: body.minPlayers || 0,
|
||||
audienceEnabled: body.audienceEnabled || false,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`[Jackbox API] Error getting room info for ${roomCode}:`, e.message);
|
||||
return { exists: false };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Export it alongside `checkRoomStatus`:
|
||||
|
||||
```javascript
|
||||
module.exports = { checkRoomStatus, getRoomInfo };
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/jackbox-api.test.js --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/utils/jackbox-api.js tests/api/jackbox-api.test.js
|
||||
git commit -m "feat: add getRoomInfo to jackbox-api for full room data including host"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create `EcastShardClient` — connection and welcome handling
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/utils/ecast-shard-client.js`
|
||||
- Test: `tests/api/ecast-shard-client.test.js` (create)
|
||||
|
||||
This task builds the core class with: constructor, `connect()`, `client/welcome` parsing, `here` map player counting, and the `disconnect()` method. No event broadcasting yet — that's Task 3.
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
Create `tests/api/ecast-shard-client.test.js`. Since we can't connect to real Jackbox servers in tests, test the pure logic: `here` map parsing, player counting, entity parsing. Export these as static/utility methods on the class for testability.
|
||||
|
||||
```javascript
|
||||
const { EcastShardClient } = require('../../backend/utils/ecast-shard-client');
|
||||
|
||||
describe('EcastShardClient', () => {
|
||||
describe('parsePlayersFromHere', () => {
|
||||
test('counts only player roles, excludes host and shard', () => {
|
||||
const here = {
|
||||
'1': { id: 1, roles: { host: {} } },
|
||||
'2': { id: 2, roles: { player: { name: 'Alice' } } },
|
||||
'3': { id: 3, roles: { player: { name: 'Bob' } } },
|
||||
'5': { id: 5, roles: { shard: {} } },
|
||||
};
|
||||
const result = EcastShardClient.parsePlayersFromHere(here);
|
||||
expect(result.playerCount).toBe(2);
|
||||
expect(result.playerNames).toEqual(['Alice', 'Bob']);
|
||||
});
|
||||
|
||||
test('returns zero for empty here or host-only', () => {
|
||||
const here = { '1': { id: 1, roles: { host: {} } } };
|
||||
const result = EcastShardClient.parsePlayersFromHere(here);
|
||||
expect(result.playerCount).toBe(0);
|
||||
expect(result.playerNames).toEqual([]);
|
||||
});
|
||||
|
||||
test('handles null or undefined here', () => {
|
||||
expect(EcastShardClient.parsePlayersFromHere(null).playerCount).toBe(0);
|
||||
expect(EcastShardClient.parsePlayersFromHere(undefined).playerCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRoomEntity', () => {
|
||||
test('extracts lobby state from room entity val', () => {
|
||||
const roomVal = {
|
||||
state: 'Lobby',
|
||||
lobbyState: 'CanStart',
|
||||
gameCanStart: true,
|
||||
gameIsStarting: false,
|
||||
gameFinished: false,
|
||||
};
|
||||
const result = EcastShardClient.parseRoomEntity(roomVal);
|
||||
expect(result.gameState).toBe('Lobby');
|
||||
expect(result.lobbyState).toBe('CanStart');
|
||||
expect(result.gameCanStart).toBe(true);
|
||||
expect(result.gameStarted).toBe(false);
|
||||
expect(result.gameFinished).toBe(false);
|
||||
});
|
||||
|
||||
test('detects game started from Gameplay state', () => {
|
||||
const roomVal = { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: false, gameFinished: false };
|
||||
const result = EcastShardClient.parseRoomEntity(roomVal);
|
||||
expect(result.gameStarted).toBe(true);
|
||||
});
|
||||
|
||||
test('detects game finished', () => {
|
||||
const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true };
|
||||
const result = EcastShardClient.parseRoomEntity(roomVal);
|
||||
expect(result.gameFinished).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePlayerJoinFromTextDescriptions', () => {
|
||||
test('extracts player name from join description', () => {
|
||||
const val = {
|
||||
latestDescriptions: [
|
||||
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Charlie joined.' }
|
||||
]
|
||||
};
|
||||
const result = EcastShardClient.parsePlayerJoinFromTextDescriptions(val);
|
||||
expect(result).toEqual([{ name: 'Charlie', isVIP: false }]);
|
||||
});
|
||||
|
||||
test('extracts VIP join', () => {
|
||||
const val = {
|
||||
latestDescriptions: [
|
||||
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED_VIP', text: 'Alice joined and is the VIP.' }
|
||||
]
|
||||
};
|
||||
const result = EcastShardClient.parsePlayerJoinFromTextDescriptions(val);
|
||||
expect(result).toEqual([{ name: 'Alice', isVIP: true }]);
|
||||
});
|
||||
|
||||
test('returns empty array for no joins', () => {
|
||||
const val = { latestDescriptions: [] };
|
||||
expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(val)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: FAIL — module does not exist.
|
||||
|
||||
**Step 3: Implement EcastShardClient**
|
||||
|
||||
Create `backend/utils/ecast-shard-client.js` with:
|
||||
|
||||
1. **Static utility methods** (`parsePlayersFromHere`, `parseRoomEntity`, `parsePlayerJoinFromTextDescriptions`) — pure functions, tested above.
|
||||
2. **Constructor** — takes `{ sessionId, gameId, roomCode, maxPlayers }`, initializes internal state.
|
||||
3. **`connect(roomInfo)`** — accepts the result of `getRoomInfo()`. Opens a WebSocket to `wss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&userId=gamepicker-{sessionId}&format=json` with `Sec-WebSocket-Protocol: ecast-v0` and `Origin: https://jackbox.tv`.
|
||||
4. **`handleMessage(data)`** — dispatcher that routes `client/welcome`, `object`, `error`, `client/connected`, `client/disconnected` to handler methods.
|
||||
5. **`handleWelcome(result)`** — parses `here`, `entities.room`, stores `secret`/`id`.
|
||||
6. **`disconnect()`** — closes the WebSocket gracefully.
|
||||
7. **Internal state:** `playerCount`, `playerNames`, `lobbyState`, `gameState`, `gameStarted`, `gameFinished`, `maxPlayers`, `secret`, `id`, `ws`.
|
||||
|
||||
Do NOT add broadcasting or reconnection yet — those are Tasks 3 and 4.
|
||||
|
||||
Key implementation details for the WebSocket connection:
|
||||
|
||||
```javascript
|
||||
const WebSocket = require('ws');
|
||||
|
||||
// In connect():
|
||||
this.ws = new WebSocket(url, ['ecast-v0'], {
|
||||
headers: { 'Origin': 'https://jackbox.tv' },
|
||||
handshakeTimeout: 10000,
|
||||
});
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: PASS (all 8 tests)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
|
||||
git commit -m "feat: add EcastShardClient with connection, welcome parsing, and player counting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add event broadcasting and entity update handling
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/utils/ecast-shard-client.js`
|
||||
- Modify: `tests/api/ecast-shard-client.test.js`
|
||||
|
||||
This task wires up the WebSocket message handlers to broadcast events via `WebSocketManager` and update the `session_games` DB row.
|
||||
|
||||
**Step 1: Write failing tests for entity update handlers**
|
||||
|
||||
Add tests to `tests/api/ecast-shard-client.test.js`:
|
||||
|
||||
```javascript
|
||||
describe('handleRoomUpdate', () => {
|
||||
test('detects game start transition', () => {
|
||||
// Create client instance, set initial state to Lobby
|
||||
// Call handleRoomUpdate with Gameplay state
|
||||
// Verify gameStarted flipped and handler would broadcast
|
||||
});
|
||||
|
||||
test('detects game end transition', () => {
|
||||
// Create client, set gameStarted = true
|
||||
// Call handleRoomUpdate with gameFinished: true
|
||||
// Verify gameFinished flipped
|
||||
});
|
||||
|
||||
test('detects lobby state change', () => {
|
||||
// Create client, set lobbyState to WaitingForMore
|
||||
// Call handleRoomUpdate with CanStart
|
||||
// Verify lobbyState updated
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Since broadcasting and DB writes involve external dependencies, use a test approach where the client accepts a `broadcaster` callback in its constructor options. The callback receives `(eventType, data)`. This makes the class testable without mocking the WebSocketManager singleton.
|
||||
|
||||
Constructor signature becomes:
|
||||
|
||||
```javascript
|
||||
constructor({ sessionId, gameId, roomCode, maxPlayers, onEvent })
|
||||
```
|
||||
|
||||
Where `onEvent` is `(eventType, eventData) => void`. The module-level `startMonitor` function provides a default `onEvent` that calls `wsManager.broadcastEvent(...)` and writes to the DB.
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: FAIL on new tests.
|
||||
|
||||
**Step 3: Implement entity update handlers**
|
||||
|
||||
Add to `EcastShardClient`:
|
||||
|
||||
- **`handleRoomUpdate(roomVal)`** — called when an `object` message arrives with `key: "room"` (or `key: "bc:room"` for some games). Compares new state against stored state. Broadcasts:
|
||||
- `lobby.updated` if `lobbyState` changed
|
||||
- `game.started` if `state` changed to `"Gameplay"` and `gameStarted` was false
|
||||
- `game.ended` if `gameFinished` changed to true
|
||||
- Updates `playerCount` in DB via `updatePlayerCount()` on game start and end.
|
||||
|
||||
- **`handleTextDescriptionsUpdate(val)`** — called when `object` with `key: "textDescriptions"` arrives. Uses `parsePlayerJoinFromTextDescriptions` to detect joins. Broadcasts `lobby.player-joined` for each new join. Updates internal `playerNames` list.
|
||||
|
||||
- **`handleClientConnected(result)`** — if shards receive `client/connected`, update internal `here` tracking and recount players. Broadcast `lobby.player-joined` if the new connection is a player.
|
||||
|
||||
- **`updatePlayerCount(count, status)`** — writes to `session_games` and calls `this.onEvent('player-count.updated', ...)` for DB-triggered updates.
|
||||
|
||||
Add the module-level `startMonitor` function:
|
||||
|
||||
```javascript
|
||||
async function startMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
|
||||
const monitorKey = `${sessionId}-${gameId}`;
|
||||
if (activeShards.has(monitorKey)) return;
|
||||
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
if (!roomInfo.exists) {
|
||||
// set failed status in DB, broadcast room.disconnected
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new EcastShardClient({
|
||||
sessionId, gameId, roomCode,
|
||||
maxPlayers: roomInfo.maxPlayers || maxPlayers,
|
||||
onEvent: (type, data) => {
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) wsManager.broadcastEvent(type, data, parseInt(sessionId));
|
||||
}
|
||||
});
|
||||
|
||||
activeShards.set(monitorKey, client);
|
||||
await client.connect(roomInfo);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
|
||||
git commit -m "feat: add event broadcasting and entity update handlers to shard client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add reconnection logic
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/utils/ecast-shard-client.js`
|
||||
- Modify: `tests/api/ecast-shard-client.test.js`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```javascript
|
||||
describe('reconnection state machine', () => {
|
||||
test('buildReconnectUrl uses stored secret and id', () => {
|
||||
const client = new EcastShardClient({
|
||||
sessionId: 1, gameId: 1, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
|
||||
});
|
||||
client.secret = 'abc-123';
|
||||
client.shardId = 5;
|
||||
client.host = 'ecast-prod-use2.jackboxgames.com';
|
||||
|
||||
const url = client.buildReconnectUrl();
|
||||
expect(url).toContain('secret=abc-123');
|
||||
expect(url).toContain('id=5');
|
||||
expect(url).toContain('role=shard');
|
||||
expect(url).toContain('ecast-prod-use2.jackboxgames.com');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: FAIL — `buildReconnectUrl` doesn't exist.
|
||||
|
||||
**Step 3: Implement reconnection**
|
||||
|
||||
Add to `EcastShardClient`:
|
||||
|
||||
- **`handleClose(code, reason)`** — called on WebSocket `close` event. If `gameFinished` or `manuallyStopped`, do nothing. Otherwise, call `attemptReconnect()`.
|
||||
- **`attemptReconnect()`** — calls `getRoomInfo(roomCode)`. If room gone, finalize. If room exists, try `reconnectWithBackoff()`.
|
||||
- **`reconnectWithBackoff()`** — attempts up to 3 reconnections with 2s/4s/8s delays. Uses `buildReconnectUrl()` with stored `secret`/`id`. On success, resumes message handling transparently. On failure, set status `'failed'`, broadcast `room.disconnected`.
|
||||
- **`buildReconnectUrl()`** — constructs `wss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&format=json&secret={secret}&id={id}`.
|
||||
- **`handleError(err)`** — logs the error, defers to `handleClose` for reconnection decisions.
|
||||
|
||||
Also handle ecast error opcode 2027 ("room already closed") in `handleMessage` — treat as game-ended.
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
|
||||
git commit -m "feat: add reconnection logic with exponential backoff to shard client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add module exports (`startMonitor`, `stopMonitor`, `cleanupAllShards`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/utils/ecast-shard-client.js`
|
||||
- Modify: `tests/api/ecast-shard-client.test.js`
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
```javascript
|
||||
const { startMonitor, stopMonitor, cleanupAllShards } = require('../../backend/utils/ecast-shard-client');
|
||||
|
||||
describe('module exports', () => {
|
||||
test('startMonitor is exported', () => {
|
||||
expect(typeof startMonitor).toBe('function');
|
||||
});
|
||||
|
||||
test('stopMonitor is exported', () => {
|
||||
expect(typeof stopMonitor).toBe('function');
|
||||
});
|
||||
|
||||
test('cleanupAllShards is exported', () => {
|
||||
expect(typeof cleanupAllShards).toBe('function');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: FAIL if not yet exported.
|
||||
|
||||
**Step 3: Finalize module exports**
|
||||
|
||||
Ensure these are all exported from `backend/utils/ecast-shard-client.js`:
|
||||
|
||||
```javascript
|
||||
const activeShards = new Map();
|
||||
|
||||
async function startMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
|
||||
// ... (implemented in Task 3)
|
||||
}
|
||||
|
||||
async function stopMonitor(sessionId, gameId) {
|
||||
const monitorKey = `${sessionId}-${gameId}`;
|
||||
const client = activeShards.get(monitorKey);
|
||||
if (client) {
|
||||
client.manuallyStopped = true;
|
||||
client.disconnect();
|
||||
activeShards.delete(monitorKey);
|
||||
|
||||
// Update DB status unless already completed
|
||||
const game = db.prepare(
|
||||
'SELECT player_count_check_status FROM session_games WHERE session_id = ? AND id = ?'
|
||||
).get(sessionId, gameId);
|
||||
|
||||
if (game && game.player_count_check_status !== 'completed' && game.player_count_check_status !== 'failed') {
|
||||
db.prepare(
|
||||
'UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?'
|
||||
).run('stopped', sessionId, gameId);
|
||||
}
|
||||
|
||||
client.onEvent('room.disconnected', {
|
||||
sessionId, gameId, roomCode: client.roomCode,
|
||||
reason: 'manually_stopped',
|
||||
finalPlayerCount: client.playerCount
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupAllShards() {
|
||||
for (const [, client] of activeShards) {
|
||||
client.manuallyStopped = true;
|
||||
client.disconnect();
|
||||
}
|
||||
activeShards.clear();
|
||||
console.log('[Shard Monitor] Cleaned up all active shards');
|
||||
}
|
||||
|
||||
module.exports = { EcastShardClient, startMonitor, stopMonitor, cleanupAllShards };
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
|
||||
git commit -m "feat: add startMonitor, stopMonitor, cleanupAllShards module exports"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Rewire `sessions.js` routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/routes/sessions.js` (lines 7–8 imports, lines 394–401, 617–624, 638–644, 844–875, 877–893)
|
||||
- Test: `tests/api/regression-sessions.test.js` (verify existing tests still pass)
|
||||
|
||||
**Step 1: Run existing session tests as baseline**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/regression-sessions.test.js --verbose --forceExit`
|
||||
Expected: PASS (capture baseline)
|
||||
|
||||
**Step 2: Replace imports**
|
||||
|
||||
In `backend/routes/sessions.js`, replace lines 7–8:
|
||||
|
||||
```javascript
|
||||
// Old
|
||||
const { stopPlayerCountCheck } = require('../utils/player-count-checker');
|
||||
const { startRoomMonitor, stopRoomMonitor } = require('../utils/room-monitor');
|
||||
|
||||
// New
|
||||
const { startMonitor, stopMonitor } = require('../utils/ecast-shard-client');
|
||||
```
|
||||
|
||||
**Step 3: Replace call sites**
|
||||
|
||||
Search and replace across the file. There are 5 call sites:
|
||||
|
||||
1. **Line ~397** (`POST /:id/games`): `startRoomMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players)` → `startMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players)`
|
||||
|
||||
2. **Lines ~620–621** (`PATCH .../status`): Replace both `stopRoomMonitor(sessionId, gameId)` and `stopPlayerCountCheck(sessionId, gameId)` with single `stopMonitor(sessionId, gameId)`
|
||||
|
||||
3. **Lines ~640–641** (`DELETE .../games/:gameId`): Same — replace two stop calls with single `stopMonitor(sessionId, gameId)`
|
||||
|
||||
4. **Line ~866** (`POST .../start-player-check`): `startRoomMonitor(...)` → `startMonitor(...)`
|
||||
|
||||
5. **Lines ~883–884** (`POST .../stop-player-check`): Replace two stop calls with single `stopMonitor(sessionId, gameId)`
|
||||
|
||||
**Step 4: Run tests to verify nothing broke**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/regression-sessions.test.js --verbose --forceExit`
|
||||
Expected: PASS (same as baseline — these tests don't exercise actual Jackbox connections)
|
||||
|
||||
Also run the full test suite:
|
||||
|
||||
Run: `cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/routes/sessions.js
|
||||
git commit -m "refactor: rewire sessions routes to use ecast shard client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Wire graceful shutdown in `server.js`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/server.js`
|
||||
|
||||
**Step 1: Add shutdown handler**
|
||||
|
||||
In `backend/server.js`, import `cleanupAllShards` and add signal handlers inside the `if (require.main === module)` block:
|
||||
|
||||
```javascript
|
||||
const { cleanupAllShards } = require('./utils/ecast-shard-client');
|
||||
|
||||
// Inside the if (require.main === module) block, after server.listen:
|
||||
const shutdown = async () => {
|
||||
console.log('Shutting down gracefully...');
|
||||
await cleanupAllShards();
|
||||
server.close(() => process.exit(0));
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
```
|
||||
|
||||
**Step 2: Verify server still starts**
|
||||
|
||||
Run: `cd backend && timeout 5 node server.js || true`
|
||||
Expected: Server starts, prints port message, exits on timeout.
|
||||
|
||||
**Step 3: Run full test suite**
|
||||
|
||||
Run: `cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/server.js
|
||||
git commit -m "feat: wire graceful shutdown for shard connections on SIGTERM/SIGINT"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Delete old files and remove Puppeteer dependency
|
||||
|
||||
**Files:**
|
||||
- Delete: `backend/utils/player-count-checker.js`
|
||||
- Delete: `backend/utils/room-monitor.js`
|
||||
- Modify: `backend/package.json` (remove `puppeteer` from dependencies)
|
||||
|
||||
**Step 1: Verify no remaining imports of old modules**
|
||||
|
||||
Search the codebase for any remaining `require('./player-count-checker')`, `require('./room-monitor')`, `require('../utils/player-count-checker')`, `require('../utils/room-monitor')`. After Task 6, `sessions.js` should be the only file that imported them and it now imports from `ecast-shard-client`. The old `room-monitor.js` had a lazy require of `player-count-checker` which is going away with it.
|
||||
|
||||
If any other files reference these modules, update them first.
|
||||
|
||||
**Step 2: Delete the files**
|
||||
|
||||
```bash
|
||||
rm backend/utils/player-count-checker.js backend/utils/room-monitor.js
|
||||
```
|
||||
|
||||
**Step 3: Remove Puppeteer dependency**
|
||||
|
||||
```bash
|
||||
cd backend && npm uninstall puppeteer
|
||||
```
|
||||
|
||||
**Step 4: Run full test suite**
|
||||
|
||||
Run: `cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit`
|
||||
Expected: PASS — no test should depend on the deleted files.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: remove Puppeteer and old room-monitor/player-count-checker modules"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Update WebSocket documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/api/websocket.md`
|
||||
|
||||
**Step 1: Read current websocket.md**
|
||||
|
||||
Read `docs/api/websocket.md` and identify the server-to-client event table.
|
||||
|
||||
**Step 2: Update the event table**
|
||||
|
||||
Replace the old events with the new contract:
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `room.connected` | Shard connected to Jackbox room (replaces `audience.joined`) |
|
||||
| `lobby.player-joined` | A player joined the lobby |
|
||||
| `lobby.updated` | Lobby state changed |
|
||||
| `game.started` | Game transitioned to Gameplay |
|
||||
| `game.ended` | Game finished |
|
||||
| `room.disconnected` | Shard lost connection to room |
|
||||
| `game.added` | New game added to session (unchanged) |
|
||||
| `session.started` | Session created (unchanged) |
|
||||
| `session.ended` | Session closed (unchanged) |
|
||||
| `vote.received` | Vote recorded (unchanged) |
|
||||
| `player-count.updated` | Manual player count override (unchanged) |
|
||||
|
||||
Add payload examples for each new event (from design doc).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/api/websocket.md
|
||||
git commit -m "docs: update websocket event reference with new shard monitor events"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Smoke test with a real Jackbox room (manual)
|
||||
|
||||
This task is manual verification — not automated.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Start the backend: `cd backend && npm run dev`
|
||||
2. Create a session via API, add a game with a room code from an active Jackbox game
|
||||
3. Watch backend logs for `[Shard Monitor]` messages:
|
||||
- REST room info fetched
|
||||
- WebSocket connected as shard
|
||||
- `client/welcome` parsed, player count reported
|
||||
- Player join detected when someone joins the lobby
|
||||
- Game start detected when the game begins
|
||||
- Game end detected when the game finishes
|
||||
4. Connect a WebSocket client to `/api/sessions/live`, authenticate, subscribe to the session, and verify events arrive:
|
||||
- `room.connected`
|
||||
- `lobby.player-joined`
|
||||
- `game.started`
|
||||
- `game.ended`
|
||||
- `room.disconnected`
|
||||
5. Test `stop-player-check` endpoint — verify shard disconnects cleanly
|
||||
6. Test reconnection — kill and restart the backend mid-game, call `start-player-check` again
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Task | Description | Files |
|
||||
|------|-------------|-------|
|
||||
| 1 | `getRoomInfo` in jackbox-api | `jackbox-api.js`, test |
|
||||
| 2 | `EcastShardClient` core + parsing | `ecast-shard-client.js`, test |
|
||||
| 3 | Event broadcasting + entity handlers | `ecast-shard-client.js`, test |
|
||||
| 4 | Reconnection logic | `ecast-shard-client.js`, test |
|
||||
| 5 | Module exports | `ecast-shard-client.js`, test |
|
||||
| 6 | Rewire sessions routes | `sessions.js` |
|
||||
| 7 | Graceful shutdown | `server.js` |
|
||||
| 8 | Delete old files + remove Puppeteer | `player-count-checker.js`, `room-monitor.js`, `package.json` |
|
||||
| 9 | Update docs | `websocket.md` |
|
||||
| 10 | Manual smoke test | — |
|
||||
1652
docs/superpowers/plans/2026-03-22-session-notes-read-edit-delete.md
Normal file
1652
docs/superpowers/plans/2026-03-22-session-notes-read-edit-delete.md
Normal file
File diff suppressed because it is too large
Load Diff
1079
docs/superpowers/plans/2026-03-23-named-admins.md
Normal file
1079
docs/superpowers/plans/2026-03-23-named-admins.md
Normal file
File diff suppressed because it is too large
Load Diff
611
docs/superpowers/plans/2026-03-23-pagination-day-grouping.md
Normal file
611
docs/superpowers/plans/2026-03-23-pagination-day-grouping.md
Normal file
@@ -0,0 +1,611 @@
|
||||
# Pagination & Day Grouping Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add offset-based pagination and day-grouped session rendering to the History page.
|
||||
|
||||
**Architecture:** Backend adds `offset` param and `X-Prev-Last-Date` header to `GET /sessions`. Frontend adds page state, groups sessions by local date at render time with styled day headers, and renders a Prev/Next pagination bar.
|
||||
|
||||
**Tech Stack:** Node.js/Express/better-sqlite3 (backend), React/Tailwind CSS (frontend), Jest/supertest (tests)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — Add `offset` param and `X-Prev-Last-Date` header
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/routes/sessions.js:22-76` (the `GET /` handler)
|
||||
- Test: `tests/api/session-archive.test.js` (add new tests to the existing `GET /api/sessions` describe block)
|
||||
|
||||
- [ ] **Step 1: Write failing tests for `offset` and `X-Prev-Last-Date`**
|
||||
|
||||
Add these tests at the end of the `GET /api/sessions — filter and limit` describe block in `tests/api/session-archive.test.js`:
|
||||
|
||||
```javascript
|
||||
test('offset skips the first N sessions', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
seedSession({ is_active: 0, notes: null });
|
||||
}
|
||||
|
||||
const allRes = await request(app).get('/api/sessions?filter=all&limit=all');
|
||||
const offsetRes = await request(app).get('/api/sessions?filter=all&limit=2&offset=2');
|
||||
expect(offsetRes.status).toBe(200);
|
||||
expect(offsetRes.body).toHaveLength(2);
|
||||
expect(offsetRes.body[0].id).toBe(allRes.body[2].id);
|
||||
expect(offsetRes.body[1].id).toBe(allRes.body[3].id);
|
||||
});
|
||||
|
||||
test('offset defaults to 0 when not provided', async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
seedSession({ is_active: 0, notes: null });
|
||||
}
|
||||
|
||||
const res = await request(app).get('/api/sessions?filter=all&limit=2');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('negative offset is clamped to 0', async () => {
|
||||
seedSession({ is_active: 0, notes: null });
|
||||
|
||||
const res = await request(app).get('/api/sessions?filter=all&offset=-5');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('non-numeric offset is clamped to 0', async () => {
|
||||
seedSession({ is_active: 0, notes: null });
|
||||
|
||||
const res = await request(app).get('/api/sessions?filter=all&offset=abc');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('offset past end returns empty array', async () => {
|
||||
seedSession({ is_active: 0, notes: null });
|
||||
|
||||
const res = await request(app).get('/api/sessions?filter=all&limit=5&offset=100');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(0);
|
||||
expect(res.headers['x-total-count']).toBe('1');
|
||||
});
|
||||
|
||||
test('X-Prev-Last-Date header is set with correct value when offset > 0', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
seedSession({ is_active: 0, notes: null });
|
||||
}
|
||||
|
||||
const allRes = await request(app).get('/api/sessions?filter=all&limit=all');
|
||||
const res = await request(app).get('/api/sessions?filter=all&limit=2&offset=2');
|
||||
expect(res.headers['x-prev-last-date']).toBe(allRes.body[1].created_at);
|
||||
});
|
||||
|
||||
test('X-Prev-Last-Date header is absent when offset is 0', async () => {
|
||||
seedSession({ is_active: 0, notes: null });
|
||||
|
||||
const res = await request(app).get('/api/sessions?filter=all&limit=2');
|
||||
expect(res.headers['x-prev-last-date']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('X-Total-Count is unaffected by offset', async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
seedSession({ is_active: 0, notes: null });
|
||||
}
|
||||
|
||||
const res = await request(app).get('/api/sessions?filter=all&limit=3&offset=6');
|
||||
expect(res.headers['x-total-count']).toBe('10');
|
||||
expect(res.body).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('offset works with filter=default', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
seedSession({ is_active: 0, notes: null });
|
||||
}
|
||||
const archived = seedSession({ is_active: 0, notes: null });
|
||||
require('../helpers/test-utils').db.prepare(
|
||||
'UPDATE sessions SET archived = 1 WHERE id = ?'
|
||||
).run(archived.id);
|
||||
|
||||
const res = await request(app).get('/api/sessions?filter=default&limit=2&offset=2');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(2);
|
||||
expect(res.headers['x-total-count']).toBe('5');
|
||||
res.body.forEach(s => expect(s.archived).toBe(0));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx jest tests/api/session-archive.test.js --no-coverage --forceExit`
|
||||
Expected: 9 new tests FAIL (offset is not yet implemented)
|
||||
|
||||
- [ ] **Step 3: Implement offset and X-Prev-Last-Date in the GET handler**
|
||||
|
||||
In `backend/routes/sessions.js`, modify the `router.get('/')` handler (lines 22-76). After parsing `limitParam` (line 25), add offset parsing:
|
||||
|
||||
```javascript
|
||||
const offsetParam = req.query.offset || '0';
|
||||
let offset = parseInt(offsetParam, 10);
|
||||
if (isNaN(offset) || offset < 0) offset = 0;
|
||||
```
|
||||
|
||||
After the `limitClause` block (line 46), build the offset clause:
|
||||
|
||||
```javascript
|
||||
let offsetClause = '';
|
||||
if (offset > 0) {
|
||||
offsetClause = `OFFSET ${offset}`;
|
||||
}
|
||||
```
|
||||
|
||||
Update the sessions query (line 62) to include `${offsetClause}` after `${limitClause}`:
|
||||
|
||||
```sql
|
||||
ORDER BY s.created_at DESC
|
||||
${limitClause}
|
||||
${offsetClause}
|
||||
```
|
||||
|
||||
Before `res.set('X-Total-Count', ...)`, add the `X-Prev-Last-Date` logic:
|
||||
|
||||
```javascript
|
||||
if (offset > 0) {
|
||||
const prevRow = db.prepare(`
|
||||
SELECT s.created_at
|
||||
FROM sessions s
|
||||
${whereClause}
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT 1 OFFSET ${offset - 1}
|
||||
`).get();
|
||||
if (prevRow) {
|
||||
res.set('X-Prev-Last-Date', prevRow.created_at);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx jest tests/api/session-archive.test.js --no-coverage --forceExit`
|
||||
Expected: ALL tests pass (24 existing + 9 new = 33)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/routes/sessions.js tests/api/session-archive.test.js
|
||||
git commit -m "feat: add offset pagination and X-Prev-Last-Date header to GET /sessions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — Date utility helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/utils/dateUtils.js`
|
||||
|
||||
- [ ] **Step 1: Add `getLocalDateKey` and `formatDayHeader` helpers**
|
||||
|
||||
Append to `frontend/src/utils/dateUtils.js`:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Get a locale-independent date key for grouping sessions by local calendar day
|
||||
* @param {string} sqliteTimestamp
|
||||
* @returns {string} - e.g., "2026-03-23"
|
||||
*/
|
||||
export function getLocalDateKey(sqliteTimestamp) {
|
||||
const d = parseUTCTimestamp(sqliteTimestamp);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a SQLite timestamp as a day header string (e.g., "Sunday, Mar 23, 2026")
|
||||
* @param {string} sqliteTimestamp
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDayHeader(sqliteTimestamp) {
|
||||
const d = parseUTCTimestamp(sqliteTimestamp);
|
||||
return d.toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a SQLite timestamp as a time-only string (e.g., "7:30 PM")
|
||||
* @param {string} sqliteTimestamp
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatTimeOnly(sqliteTimestamp) {
|
||||
const d = parseUTCTimestamp(sqliteTimestamp);
|
||||
return d.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify frontend builds**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: Build succeeds with no errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/utils/dateUtils.js
|
||||
git commit -m "feat: add getLocalDateKey, formatDayHeader, formatTimeOnly date helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Frontend — Pagination state and API integration
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/History.jsx:14-75` (state declarations and `loadSessions`)
|
||||
|
||||
- [ ] **Step 1: Add page state and update loadSessions**
|
||||
|
||||
In `History.jsx`, add state after line 17 (`absoluteTotal`):
|
||||
|
||||
```javascript
|
||||
const [page, setPage] = useState(1);
|
||||
const [prevLastDate, setPrevLastDate] = useState(null);
|
||||
```
|
||||
|
||||
Update `loadSessions` (the `api.get` call around line 32) to pass `offset`:
|
||||
|
||||
```javascript
|
||||
const limitNum = limit === 'all' ? null : parseInt(limit, 10);
|
||||
const offset = limitNum ? (page - 1) * limitNum : 0;
|
||||
|
||||
const response = await api.get('/sessions', {
|
||||
params: { filter, limit, offset: offset || undefined }
|
||||
});
|
||||
```
|
||||
|
||||
After setting `absoluteTotal`, add:
|
||||
|
||||
```javascript
|
||||
setPrevLastDate(response.headers['x-prev-last-date'] || null);
|
||||
```
|
||||
|
||||
Add `page` to the `useCallback` dependency array for `loadSessions`.
|
||||
|
||||
- [ ] **Step 2: Add page reset logic**
|
||||
|
||||
Update `handleFilterChange` and `handleLimitChange` to reset page:
|
||||
|
||||
```javascript
|
||||
const handleFilterChange = (newFilter) => {
|
||||
setFilter(newFilter);
|
||||
localStorage.setItem(prefixKey(adminName, 'history-filter'), newFilter);
|
||||
setSelectedIds(new Set());
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleLimitChange = (newLimit) => {
|
||||
setLimit(newLimit);
|
||||
localStorage.setItem(prefixKey(adminName, 'history-show-limit'), newLimit);
|
||||
setSelectedIds(new Set());
|
||||
setPage(1);
|
||||
};
|
||||
```
|
||||
|
||||
Add auto-reset when page becomes empty. Place this check **after** all state updates (`setSessions`, `setTotalCount`, `setAbsoluteTotal`, `setPrevLastDate`) to avoid stale state:
|
||||
|
||||
```javascript
|
||||
setSessions(response.data);
|
||||
setTotalCount(parseInt(response.headers['x-total-count'] || '0', 10));
|
||||
setAbsoluteTotal(parseInt(response.headers['x-absolute-total'] || '0', 10));
|
||||
setPrevLastDate(response.headers['x-prev-last-date'] || null);
|
||||
|
||||
if (response.data.length === 0 && offset > 0) {
|
||||
setPage(1);
|
||||
}
|
||||
```
|
||||
|
||||
Also add `setPage(1)` to `exitSelectMode`:
|
||||
|
||||
```javascript
|
||||
const exitSelectMode = () => {
|
||||
setSelectMode(false);
|
||||
setSelectedIds(new Set());
|
||||
setShowBulkDeleteConfirm(false);
|
||||
setPage(1);
|
||||
};
|
||||
```
|
||||
|
||||
And in the select mode toggle button's `onClick` (where `setSelectMode(true)` is called), add `setPage(1)` after it. Similarly in `handlePointerDown` where `setSelectMode(true)` is called, add `setPage(1)` after it.
|
||||
|
||||
- [ ] **Step 3: Verify frontend builds**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/History.jsx
|
||||
git commit -m "feat: add pagination state and offset to session API calls"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend — Day grouping rendering
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/History.jsx:1-8` (imports) and `208-316` (session list rendering)
|
||||
|
||||
- [ ] **Step 1: Update imports**
|
||||
|
||||
Replace the `dateUtils` import at line 6:
|
||||
|
||||
```javascript
|
||||
import { formatDayHeader, formatTimeOnly, getLocalDateKey, isSunday } from '../utils/dateUtils';
|
||||
```
|
||||
|
||||
(Remove `formatLocalDate` since session cards will now show time-only under day headers.)
|
||||
|
||||
- [ ] **Step 2: Add grouping logic and render day headers**
|
||||
|
||||
Replace the session list rendering section (`{sessions.map(session => { ... })}`) with day-grouped rendering. The grouping is computed at render time using `useMemo`:
|
||||
|
||||
Add before the `return` statement (above `if (loading)`):
|
||||
|
||||
```javascript
|
||||
const groupedSessions = useMemo(() => {
|
||||
const groups = [];
|
||||
let currentKey = null;
|
||||
|
||||
sessions.forEach(session => {
|
||||
const dateKey = getLocalDateKey(session.created_at);
|
||||
if (dateKey !== currentKey) {
|
||||
currentKey = dateKey;
|
||||
groups.push({ dateKey, sessions: [session] });
|
||||
} else {
|
||||
groups[groups.length - 1].sessions.push(session);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [sessions]);
|
||||
```
|
||||
|
||||
Add `useMemo` to the React import at line 1.
|
||||
|
||||
Replace the `{sessions.map(session => { ... })}` block inside `<div className="space-y-2">` with:
|
||||
|
||||
```jsx
|
||||
{groupedSessions.map((group, groupIdx) => {
|
||||
const isSundayGroup = isSunday(group.sessions[0].created_at);
|
||||
const isContinued = groupIdx === 0 && page > 1 && prevLastDate &&
|
||||
getLocalDateKey(prevLastDate) === group.dateKey;
|
||||
|
||||
return (
|
||||
<div key={group.dateKey}>
|
||||
{/* Day header bar */}
|
||||
<div className="bg-gray-100 dark:bg-[#1e2a3a] rounded-md px-3.5 py-2 mb-2 flex justify-between items-center border-l-[3px] border-indigo-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-indigo-700 dark:text-indigo-300">
|
||||
{formatDayHeader(group.sessions[0].created_at)}
|
||||
</span>
|
||||
{isContinued && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 italic">(continued)</span>
|
||||
)}
|
||||
</div>
|
||||
{!isContinued && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{group.sessions.length} session{group.sessions.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{isSundayGroup && (
|
||||
<span className="text-xs font-semibold text-amber-700 dark:text-amber-300">🎲 Game Night</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Session cards under this day */}
|
||||
<div className="ml-3 space-y-1.5 mb-4">
|
||||
{group.sessions.map(session => {
|
||||
const isActive = session.is_active === 1;
|
||||
const isSelected = selectedIds.has(session.id);
|
||||
const isArchived = session.archived === 1;
|
||||
const canSelect = selectMode && !isActive;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`border rounded-lg transition ${
|
||||
selectMode && isActive
|
||||
? 'opacity-50 cursor-not-allowed border-gray-300 dark:border-gray-600'
|
||||
: isSelected
|
||||
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 cursor-pointer'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-indigo-400 dark:hover:border-indigo-500 cursor-pointer'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (longPressFired.current) {
|
||||
longPressFired.current = false;
|
||||
return;
|
||||
}
|
||||
if (selectMode) {
|
||||
if (!isActive) toggleSelection(session.id);
|
||||
} else {
|
||||
navigate(`/history/${session.id}`);
|
||||
}
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
if (!isActive) handlePointerDown(session.id);
|
||||
}}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{selectMode && (
|
||||
<div className={`mt-0.5 w-5 h-5 flex-shrink-0 rounded border-2 flex items-center justify-center ${
|
||||
isActive
|
||||
? 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700'
|
||||
: isSelected
|
||||
? 'border-indigo-600 bg-indigo-600'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}>
|
||||
{isSelected && (
|
||||
<span className="text-white text-xs font-bold">✓</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-gray-800 dark:text-gray-100">
|
||||
Session #{session.id}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs px-2 py-0.5 rounded">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{isArchived && (filter === 'all' || filter === 'archived') && (
|
||||
<span className="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 text-xs px-2 py-0.5 rounded">
|
||||
Archived
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{session.games_played} game{session.games_played !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatTimeOnly(session.created_at)}
|
||||
</div>
|
||||
{session.has_notes && session.notes_preview && (
|
||||
<div className="mt-2 text-sm text-indigo-400 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/20 px-3 py-2 rounded border-l-2 border-indigo-500">
|
||||
{session.notes_preview}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectMode && isAuthenticated && isActive && (
|
||||
<div className="px-4 pb-4 pt-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setClosingSession(session.id);
|
||||
}}
|
||||
className="w-full bg-orange-600 dark:bg-orange-700 text-white px-4 py-2 rounded text-sm hover:bg-orange-700 dark:hover:bg-orange-800 transition"
|
||||
>
|
||||
End Session
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
```
|
||||
|
||||
Each session card inside the group keeps its existing structure (selection, badges, notes preview, etc.) but:
|
||||
- The date line changes from `formatLocalDate(session.created_at)` to `formatTimeOnly(session.created_at)`
|
||||
- Remove the per-card `isSundaySession` badge (`🎲 Game Night` span) and the `· Sunday` text — these are now on the day header
|
||||
- Remove the `isSundaySession` const from inside the card map — it's computed per-group instead
|
||||
|
||||
- [ ] **Step 3: Verify frontend builds**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/History.jsx
|
||||
git commit -m "feat: render sessions grouped by day with styled header bars"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Frontend — Pagination bar
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/History.jsx` (add pagination bar below session list, above multi-select action bar)
|
||||
|
||||
- [ ] **Step 1: Add pagination bar JSX**
|
||||
|
||||
After the closing `</div>` of `<div className="space-y-2">` (the session list) and before the multi-select action bar `{selectMode && selectedIds.size > 0 && (`, add:
|
||||
|
||||
```jsx
|
||||
{/* Pagination bar */}
|
||||
{limit !== 'all' && (() => {
|
||||
const limitNum = parseInt(limit, 10);
|
||||
const totalPages = Math.ceil(totalCount / limitNum);
|
||||
if (totalPages <= 1) return null;
|
||||
return (
|
||||
<div className="flex justify-center items-center gap-4 py-3 mt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => { setPage(p => p - 1); setSelectedIds(new Set()); }}
|
||||
disabled={page <= 1}
|
||||
className={`px-4 py-1.5 rounded-md text-sm font-medium transition ${
|
||||
page <= 1
|
||||
? 'bg-gray-600 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||||
: 'bg-indigo-600 text-white hover:bg-indigo-700'
|
||||
}`}
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setPage(p => p + 1); setSelectedIds(new Set()); }}
|
||||
disabled={page >= totalPages}
|
||||
className={`px-4 py-1.5 rounded-md text-sm font-medium transition ${
|
||||
page >= totalPages
|
||||
? 'bg-gray-600 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||||
: 'bg-indigo-600 text-white hover:bg-indigo-700'
|
||||
}`}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify frontend builds**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/History.jsx
|
||||
git commit -m "feat: add Prev/Next pagination bar to session history"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Final verification
|
||||
|
||||
**Files:** None (verification only)
|
||||
|
||||
- [ ] **Step 1: Run full backend test suite**
|
||||
|
||||
Run: `npx jest --no-coverage --forceExit`
|
||||
Expected: All tests pass (147 existing + 9 new = 156)
|
||||
|
||||
- [ ] **Step 2: Verify frontend build**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: Clean build, no warnings
|
||||
|
||||
- [ ] **Step 3: Final commit if any cleanup needed**
|
||||
1140
docs/superpowers/plans/2026-03-23-session-archive-multiselect.md
Normal file
1140
docs/superpowers/plans/2026-03-23-session-archive-multiselect.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
||||
# Session Notes — Read, Edit, Delete
|
||||
|
||||
**Date:** 2026-03-22
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
When an admin/host ends a session, they can write notes via the EndSessionModal. But after that, those notes are effectively invisible — the History page doesn't display them, and there's no way to read, edit, or delete them from the UI. Notes only surface in raw exports.
|
||||
|
||||
## Solution
|
||||
|
||||
Add the ability to view, edit, and delete session notes through two surfaces:
|
||||
|
||||
1. **Notes preview on History page session cards** — inline teaser showing the first paragraph
|
||||
2. **New Session Detail page** (`/history/:id`) — full rendered markdown notes with inline edit/delete, plus session management actions
|
||||
|
||||
## Approach
|
||||
|
||||
Minimal extension of existing infrastructure (Approach A). No database schema changes. The `sessions.notes` TEXT column stays as-is. Frontend gets a new page and a markdown rendering dependency.
|
||||
|
||||
## Backend API Changes
|
||||
|
||||
### New Endpoints
|
||||
|
||||
#### `PUT /api/sessions/:id/notes`
|
||||
|
||||
- **Auth:** Required (admin token)
|
||||
- **Body:** `{ "notes": "markdown string" }`
|
||||
- **Behavior:** Overwrites `sessions.notes` for the given session (no COALESCE merge — full replacement)
|
||||
- **Response:** Updated session object
|
||||
- **Errors:** 404 if session not found, 401 if no auth header, 403 if token invalid/expired (consistent with existing `authenticateToken` middleware behavior)
|
||||
|
||||
#### `DELETE /api/sessions/:id/notes`
|
||||
|
||||
- **Auth:** Required (admin token)
|
||||
- **Body:** None
|
||||
- **Behavior:** Sets `sessions.notes = NULL`
|
||||
- **Response:** `{ success: true }`
|
||||
- **Errors:** 404 if session not found, 401 if no auth header, 403 if token invalid/expired
|
||||
|
||||
### Modified Endpoints
|
||||
|
||||
#### `GET /api/sessions` (list)
|
||||
|
||||
Add two fields to each session object in the response:
|
||||
|
||||
- `has_notes` (boolean) — `true` if `notes IS NOT NULL AND notes != ''`
|
||||
- `notes_preview` (string | null) — first paragraph of the markdown, truncated to ~150 characters. `null` if no notes.
|
||||
- **Remove `notes` from list response** — the full `notes` field must be omitted from list items. Use explicit column selection instead of `SELECT s.*` to avoid leaking full notes to unauthenticated clients. The list endpoint only returns `has_notes` and `notes_preview`.
|
||||
|
||||
These are computed server-side from the existing `notes` column.
|
||||
|
||||
#### `GET /api/sessions/:id` (single session)
|
||||
|
||||
Conditional notes visibility based on auth:
|
||||
|
||||
- **Authenticated request:** Returns full `notes` field (plus `has_notes` and `notes_preview`)
|
||||
- **Unauthenticated request:** Returns `notes_preview` and `has_notes` only. `notes` field is omitted or null.
|
||||
|
||||
The endpoint currently does not require auth. It will remain publicly accessible but gate the full notes content behind an optional auth check.
|
||||
|
||||
### Unchanged Endpoints
|
||||
|
||||
- `POST /api/sessions` — session creation (unchanged)
|
||||
- `POST /api/sessions/:id/close` — session close with optional notes (unchanged)
|
||||
- `DELETE /api/sessions/:id` — session deletion (unchanged)
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### History Page (`/history` — `History.jsx`)
|
||||
|
||||
#### Session Cards (Sidebar)
|
||||
|
||||
- Add notes preview teaser below the date/games-count line when `has_notes` is true
|
||||
- Visual treatment: indigo left-border accent, subtle background, truncated text with ellipsis
|
||||
- Clicking a session card navigates to `/history/:id` (instead of expanding the inline detail panel)
|
||||
|
||||
#### Action Buttons
|
||||
|
||||
- **Active sessions:** "End Session" button stays on the card (opens `EndSessionModal` as before)
|
||||
- **Closed sessions:** Delete button removed from the card (moved to detail page only)
|
||||
|
||||
#### Removed from History Page
|
||||
|
||||
- The inline session detail panel (right side, `md:col-span-2`) is replaced by navigation to the detail page
|
||||
- `ChatImportPanel` moves to the detail page
|
||||
- Export buttons move to the detail page
|
||||
|
||||
### New Session Detail Page (`/history/:id` — `SessionDetail.jsx`)
|
||||
|
||||
New route and component.
|
||||
|
||||
#### Layout
|
||||
|
||||
- **Back link** — "← Back to History" navigates to `/history`
|
||||
- **Session header** — Session number, created date/time, games count, active badge if applicable
|
||||
- **Notes section** — Primary content area (see Notes Section below)
|
||||
- **Games list** — Same as current History detail panel (reuse existing game card markup)
|
||||
- **Action buttons:**
|
||||
- Export as TXT / Export as JSON (same as current)
|
||||
- Import Chat Log (active sessions only, admin only)
|
||||
- End Session (active sessions only, admin only — opens `EndSessionModal`)
|
||||
- Delete Session (closed sessions only, admin only — confirmation modal)
|
||||
|
||||
#### Notes Section — View Mode
|
||||
|
||||
- Renders notes as formatted HTML via `react-markdown`
|
||||
- If no notes exist: shows "No notes" placeholder with "Add Notes" button (admin only)
|
||||
- Admin sees an "Edit" button in the section header
|
||||
|
||||
#### Notes Section — Edit Mode (Admin Only)
|
||||
|
||||
- Triggered by clicking "Edit" (or "Add Notes" for empty notes)
|
||||
- Rendered markdown is replaced in-place by a textarea containing the raw markdown
|
||||
- "Supports Markdown formatting" hint below the textarea
|
||||
- Action buttons: **Save** (green), **Cancel** (gray), **Delete Notes** (red, with confirmation)
|
||||
- Save calls `PUT /api/sessions/:id/notes`
|
||||
- Delete Notes calls `DELETE /api/sessions/:id/notes` after a confirmation prompt
|
||||
- Cancel reverts to view mode without saving
|
||||
|
||||
#### Notes Section — Public View (Unauthenticated)
|
||||
|
||||
- Shows `notes_preview` text (first paragraph, plain text — not markdown-rendered)
|
||||
- "Log in to view full notes" hint below the preview
|
||||
- No edit controls
|
||||
|
||||
### Routing
|
||||
|
||||
Add new route in `App.jsx`:
|
||||
|
||||
```
|
||||
/history/:id → <SessionDetail />
|
||||
```
|
||||
|
||||
Existing `/history` route unchanged.
|
||||
|
||||
### New Dependency
|
||||
|
||||
- `react-markdown` — lightweight markdown-to-React renderer. Used only in `SessionDetail.jsx` for rendering notes.
|
||||
|
||||
## What's NOT Changing
|
||||
|
||||
- **Database schema** — no migration, no new tables, no new columns
|
||||
- **`EndSessionModal`** — still works as-is for writing notes at session close time
|
||||
- **`POST /api/sessions/:id/close`** — untouched
|
||||
- **WebSocket events** — no notes-related real-time updates
|
||||
- **Home page** — still shows `activeSession.notes` as plain text for active sessions (no changes)
|
||||
|
||||
## Permission Model
|
||||
|
||||
| Action | Auth Required |
|
||||
|--------|--------------|
|
||||
| View notes preview (list + detail) | No |
|
||||
| View full notes (detail page) | Yes |
|
||||
| Edit notes | Yes |
|
||||
| Delete notes | Yes |
|
||||
| Delete session | Yes |
|
||||
| End session | Yes |
|
||||
|
||||
This is consistent with the existing pattern where read-only session data is public and mutations require admin auth.
|
||||
|
||||
## Notes Preview Computation
|
||||
|
||||
Server-side logic for `notes_preview`:
|
||||
|
||||
1. If `notes` is null or empty, `notes_preview = null`, `has_notes = false`
|
||||
2. Split `notes` on the first double-newline (`\n\n`) to get the first paragraph
|
||||
3. Strip markdown formatting (bold, links, etc.) for a clean plain-text preview
|
||||
4. Truncate to 150 characters, append `...` if truncated
|
||||
5. Return as `notes_preview` string
|
||||
161
docs/superpowers/specs/2026-03-23-named-admins-design.md
Normal file
161
docs/superpowers/specs/2026-03-23-named-admins-design.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Named Admins Design
|
||||
|
||||
**Date:** 2026-03-23
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the single shared `ADMIN_KEY` with named admin accounts, each with their own key. Per-admin preferences are stored in namespaced `localStorage` (except theme, which stays shared — see Edge Cases). A real-time "who is watching" presence bar shows which admins are on the same page.
|
||||
|
||||
## Requirements
|
||||
|
||||
1. **Named admins with multiple keys** — each admin has a name and a unique key, defined in a server-side config file.
|
||||
2. **Per-admin preferences** — UI preferences (saved filter view, show limit, etc.) are linked to the admin who set them via namespaced `localStorage`.
|
||||
3. **Presence badge** — a "who is watching" card in the page header shows which admins are viewing the same page. The current admin sees "me" for themselves, full names for others.
|
||||
|
||||
## Approach
|
||||
|
||||
Identity-in-JWT with a config file. No new database tables. Preferences stay in `localStorage` (per-browser, which is desirable). Presence piggybacks on the existing WebSocket infrastructure. Falls back to the old `ADMIN_KEY` env var for backward compatibility.
|
||||
|
||||
---
|
||||
|
||||
## Section 1: Admin Configuration
|
||||
|
||||
### Config file
|
||||
|
||||
- **`backend/config/admins.example.json`** — committed to repo, shows the expected shape:
|
||||
```json
|
||||
[
|
||||
{ "name": "Alice", "key": "change-me-alice-key" },
|
||||
{ "name": "Bob", "key": "change-me-bob-key" }
|
||||
]
|
||||
```
|
||||
- **`backend/config/admins.json`** — gitignored, contains real keys.
|
||||
|
||||
### Loader module
|
||||
|
||||
**New file: `backend/config/load-admins.js`**
|
||||
|
||||
Startup behavior:
|
||||
1. Read path from `ADMIN_CONFIG_PATH` env var, or default to `backend/config/admins.json`.
|
||||
2. If the file exists: parse and validate (must be an array of `{ name, key }`, no duplicate names or keys).
|
||||
3. If the file does not exist: fall back to `ADMIN_KEY` env var → `[{ name: "Admin", key: ADMIN_KEY }]`.
|
||||
4. If neither exists: throw at startup (fail-fast, same as current behavior).
|
||||
|
||||
Exports:
|
||||
- `findAdminByKey(key)` — returns `{ name }` or `null`.
|
||||
|
||||
---
|
||||
|
||||
## Section 2: Authentication Changes (Backend)
|
||||
|
||||
### Login endpoint (`backend/routes/auth.js`)
|
||||
|
||||
- Replace single `ADMIN_KEY` comparison with `findAdminByKey(key)`.
|
||||
- On match, embed the admin name in the JWT payload: `{ role: 'admin', name: 'Alice', timestamp: Date.now() }`.
|
||||
- Add `name` to the login response JSON: `{ token, name, message, expiresIn }`.
|
||||
- Startup guard changes from checking `ADMIN_KEY` to checking that `loadAdmins()` returned at least one admin.
|
||||
|
||||
### Verify endpoint
|
||||
|
||||
No changes needed. Already returns `{ valid: true, user: req.user }`. The decoded JWT now naturally includes `name`, so the frontend gets it for free.
|
||||
|
||||
### Auth middleware (`backend/middleware/auth.js`)
|
||||
|
||||
No changes. Already decodes the full JWT payload into `req.user`. Routes that need the admin name can read `req.user.name`.
|
||||
|
||||
---
|
||||
|
||||
## Section 3: Frontend Auth & Per-Admin Preferences
|
||||
|
||||
### AuthContext changes (`frontend/src/context/AuthContext.jsx`)
|
||||
|
||||
- Add `adminName` to state (alongside `token`, `isAuthenticated`).
|
||||
- `login()`: read `name` from the login response JSON. Store in state and `localStorage` as `adminName`.
|
||||
- `verify()`: read `name` from `response.data.user.name`. Restore `adminName` from that.
|
||||
- `logout()`: clear `adminName` from state and `localStorage`.
|
||||
- Expose `adminName` from the context.
|
||||
|
||||
### Preference namespacing
|
||||
|
||||
A utility function (e.g. in `frontend/src/utils/adminPrefs.js`):
|
||||
- `prefixKey(adminName, key)` → returns `${adminName}:${key}` when `adminName` is set, or plain `key` as fallback.
|
||||
- History page changes from `localStorage.getItem('history-filter')` → `localStorage.getItem(prefixKey(adminName, 'history-filter'))`.
|
||||
|
||||
### One-time migration
|
||||
|
||||
On login, if old un-namespaced keys exist (`history-filter`, `history-show-limit`), copy them to the namespaced versions (`alice:history-filter`, `alice:history-show-limit`) and delete the originals. This preserves existing preferences for the first admin who logs in after the upgrade.
|
||||
|
||||
---
|
||||
|
||||
## Section 4: Presence System — "Who Is Watching"
|
||||
|
||||
### WebSocket changes (`backend/utils/websocket-manager.js`)
|
||||
|
||||
- On `auth` message: JWT now carries `name`. Store as `clientInfo.adminName` instead of `clientInfo.userId = decoded.role`.
|
||||
- New message type `page_focus`: clients send `{ type: 'page_focus', page: '/history' }` on navigation. Stored as `clientInfo.currentPage`.
|
||||
- On `page_focus` and on `removeClient` (disconnect): broadcast `presence_update` to all authenticated clients.
|
||||
- Presence payload: `{ type: 'presence_update', admins: [{ name: 'Alice', page: '/history' }, { name: 'Bob', page: '/picker' }] }`.
|
||||
- Unauthenticated clients do not participate in presence.
|
||||
|
||||
### Message flow
|
||||
|
||||
1. Admin connects → sends `{ type: 'auth', token }` → server stores `adminName` from JWT.
|
||||
2. Frontend route change → sends `{ type: 'page_focus', page: '/history' }` → server stores page, broadcasts presence.
|
||||
3. Admin disconnects → server removes them, broadcasts updated presence.
|
||||
4. Each client receives the full presence list and filters locally to admins on the same page.
|
||||
|
||||
### Frontend hook — `usePresence`
|
||||
|
||||
- Connects to the existing WebSocket, sends `page_focus` on route changes (via `useLocation`).
|
||||
- Listens for `presence_update` messages.
|
||||
- Filters to admins on the current page.
|
||||
- Returns `{ viewers }` — array of names, with the current admin's name replaced by `"me"`.
|
||||
|
||||
### Frontend component — `PresenceBar`
|
||||
|
||||
- Renders below the `<nav>`, above page content (in `App.jsx` route layout area).
|
||||
- Only renders when the current admin is authenticated **and at least one other admin is on the same page**. A solo admin sees no presence bar — "me" alone is not useful information.
|
||||
- Small card with caption "who is watching" and a row of name badges (rounded pills).
|
||||
- Styling: subtle, fits the existing indigo/gray theme.
|
||||
- Non-admin visitors see nothing.
|
||||
|
||||
Example when Alice is on `/history` with Bob:
|
||||
```
|
||||
┌─ who is watching ──────────┐
|
||||
│ [me] [Bob] │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Changed (Summary)
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `backend/config/admins.example.json` | New — committed template |
|
||||
| `backend/config/admins.json` | New — gitignored, real keys |
|
||||
| `.gitignore` | Add `backend/config/admins.json` |
|
||||
| `backend/config/load-admins.js` | New — config loader + `findAdminByKey` |
|
||||
| `backend/routes/auth.js` | Use `findAdminByKey`, embed `name` in JWT and response |
|
||||
| `backend/utils/websocket-manager.js` | Store `adminName`, handle `page_focus`, broadcast `presence_update` |
|
||||
| `frontend/src/context/AuthContext.jsx` | Add `adminName` state, persist/restore, expose via context |
|
||||
| `frontend/src/utils/adminPrefs.js` | New — `prefixKey` utility + migration helper |
|
||||
| `frontend/src/pages/History.jsx` | Use namespaced localStorage keys |
|
||||
| `frontend/src/hooks/usePresence.js` | New — WebSocket presence hook |
|
||||
| `frontend/src/components/PresenceBar.jsx` | New — "who is watching" UI component |
|
||||
| `frontend/src/App.jsx` | Render `PresenceBar` in layout |
|
||||
| `docker-compose.yml` | Add `ADMIN_CONFIG_PATH` env var (optional) |
|
||||
| `tests/jest.setup.js` | Update test admin config |
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Existing JWTs after deploy:** Tokens issued before this change lack `name`. The `verify` endpoint and `AuthContext` should treat a missing `name` as stale and force re-login (call `logout()`). WebSocket auth should also reject tokens missing `name` (send `auth_error`). Since tokens expire in 24h, this is a brief transition.
|
||||
- **Theme preference:** `ThemeContext` uses the global `theme` localStorage key. Theme stays **shared** (not namespaced per admin) — it's a browser-level display preference, not an admin workflow preference.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- UI-based key management (keys are managed server-side only)
|
||||
- Audit logging / login history (could be added later with a lightweight SQLite table if desired)
|
||||
- Server-side preference storage (per-browser localStorage is sufficient and desirable)
|
||||
- Admin key hashing (keys are compared with strict equality, same as current `ADMIN_KEY`)
|
||||
@@ -0,0 +1,99 @@
|
||||
# Pagination & Day Grouping — Design Spec
|
||||
|
||||
**Date:** 2026-03-23
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Two enhancements to the session History page:
|
||||
1. **Pagination** — When "Show X" is set to a value other than "All", add Prev/Next navigation to access older sessions.
|
||||
2. **Day Grouping** — Group sessions that occurred on the same calendar day under a shared header bar.
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### `GET /api/sessions` — New `offset` parameter
|
||||
|
||||
Add an `offset` query parameter (default `0`) to the existing endpoint. Works with the existing `limit`, `filter`, `X-Total-Count`, and `X-Absolute-Total` headers.
|
||||
|
||||
```
|
||||
GET /api/sessions?filter=default&limit=5&offset=10
|
||||
```
|
||||
|
||||
**Offset validation:** Non-numeric or negative values are clamped to 0. An offset past the end returns an empty array (the pagination bar will show "Page X of Y" and the user can navigate back).
|
||||
|
||||
**New response header:**
|
||||
- `X-Prev-Last-Date` — When `offset > 0`, the raw SQLite `created_at` timestamp (same format as `created_at` in response body, e.g. `"2026-03-23 19:30:00"`) of the session immediately before the current page (the session at position `offset - 1`). Used by the frontend to detect whether the first day group on the current page is a continuation from the previous page. Omitted when `offset` is 0. The frontend parses this with the existing `parseUTCTimestamp` utility.
|
||||
|
||||
**SQL changes:** Add `OFFSET` clause to the existing query. For `X-Prev-Last-Date`, run a small secondary query to fetch the `created_at` of the session at position `offset - 1` (same filter/ordering).
|
||||
|
||||
No other backend changes required.
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### State
|
||||
|
||||
- `page` (number, default `1`) — current page number. Derived from offset: `offset = (page - 1) * limit`. **Not persisted** in localStorage; resets to 1 on navigation.
|
||||
- `prevLastDate` (string|null) — from `X-Prev-Last-Date` header. Used for "(continued)" detection.
|
||||
|
||||
### Page math
|
||||
|
||||
```
|
||||
totalPages = Math.ceil(totalCount / limitNum)
|
||||
offset = (page - 1) * limitNum
|
||||
```
|
||||
|
||||
When `limit` is `"all"`, pagination is disabled (no offset, no pagination bar).
|
||||
|
||||
### `loadSessions` changes
|
||||
|
||||
Pass `offset` as a query parameter alongside `filter` and `limit`. Read `X-Prev-Last-Date` from response headers.
|
||||
|
||||
### Page reset triggers
|
||||
|
||||
Changing `filter`, `limit`, or entering/exiting `selectMode` resets `page` to 1.
|
||||
|
||||
### Day Grouping (render-time only)
|
||||
|
||||
Group the flat session array by local calendar date at render time. For each group:
|
||||
|
||||
1. **Day header bar** — Styled with `bg-[#1e2a3a]` (dark) / `bg-gray-100` (light), left border accent (`border-l-[3px] border-indigo-500`), contains:
|
||||
- Full date: "Sunday, Mar 23, 2026"
|
||||
- Right side: session count ("2 sessions") and, if Sunday, "🎲 Game Night"
|
||||
2. **Session cards** — Indented slightly (`ml-3`) beneath their day header. Display **time only** (e.g., "7:30 PM") since the full date is in the header. Remove the per-card "· Sunday" text and per-card "🎲 Game Night" badge since that information is now on the day header.
|
||||
|
||||
### "(continued)" detection
|
||||
|
||||
When `page > 1` and `prevLastDate` is set:
|
||||
- Parse the previous page's last session date to a local calendar date string
|
||||
- If it matches the first day group's date, append an italic "(continued)" tag to that day header (no session count shown for continued groups since the count would be incomplete)
|
||||
|
||||
### Pagination bar
|
||||
|
||||
Rendered below the session list, above the multi-select action bar (if active). Only shown when `limit !== "all"` and `totalPages > 1`.
|
||||
|
||||
Layout: `← Prev` button | "Page X of Y" text | `Next →` button
|
||||
|
||||
- Prev button disabled (grayed out) on page 1
|
||||
- Next button disabled on last page
|
||||
- Active buttons use indigo (`bg-indigo-600`)
|
||||
- Disabled buttons use gray (`bg-gray-600/700` with `cursor-not-allowed`)
|
||||
|
||||
### Multi-select interaction
|
||||
|
||||
Day header bars are not selectable. Only session cards participate in multi-select. Checkboxes render inside the indented card area as they do today. Changing pages clears selected IDs but keeps select mode active.
|
||||
|
||||
### Polling behavior
|
||||
|
||||
The existing 3-second polling interval refetches the current page (same offset/limit/filter). If sessions are deleted while the user is on a later page and the page becomes empty, the next poll cycle detects `sessions.length === 0 && page > 1` and resets to page 1.
|
||||
|
||||
### "Visible" count update
|
||||
|
||||
The existing "X visible (Y total)" label continues to work as-is. `sessions.length` reflects the current page's sessions.
|
||||
|
||||
## Scope
|
||||
|
||||
- No changes to `SessionDetail.jsx`
|
||||
- No changes to bulk endpoints
|
||||
- No new dependencies
|
||||
- The `dateUtils.js` gains a `formatDayHeader` helper (e.g., "Sunday, Mar 23, 2026") and a `getLocalDateKey` helper for grouping
|
||||
- Existing tests for `GET /sessions` updated to cover `offset` parameter; new tests for `X-Prev-Last-Date` header
|
||||
@@ -0,0 +1,185 @@
|
||||
# Design: Session Archive, Sunday Badge, Multi-Select, and Pagination
|
||||
|
||||
## Overview
|
||||
|
||||
Four enhancements to the History page and Session Detail page:
|
||||
|
||||
1. **Archive/Unarchive sessions** — hide sessions from the default history list, with a filter to view archived sessions
|
||||
2. **Sunday "Game Night" badge** — visual indicator on session cards when a session took place on a Sunday
|
||||
3. **Multi-select mode** — bulk archive and delete operations on the History page (admin only)
|
||||
4. **Pagination options** — configurable number of sessions shown (5, 10, 25, 50, All)
|
||||
|
||||
## Backend
|
||||
|
||||
### Schema Change
|
||||
|
||||
Add `archived INTEGER DEFAULT 0` to the `sessions` table using the existing defensive `ALTER TABLE` pattern in `database.js`:
|
||||
|
||||
```js
|
||||
try {
|
||||
db.exec(`ALTER TABLE sessions ADD COLUMN archived INTEGER DEFAULT 0`);
|
||||
} catch (err) {
|
||||
// Column already exists
|
||||
}
|
||||
```
|
||||
|
||||
No new tables. No migration file.
|
||||
|
||||
### New Endpoints
|
||||
|
||||
#### `POST /api/sessions/:id/archive`
|
||||
|
||||
- **Auth:** Required
|
||||
- **Action:** Sets `archived = 1` on the session
|
||||
- **Constraints:** Returns 400 if the session is still active (`is_active = 1`). Returns 404 if session not found.
|
||||
- **Response:** `{ success: true }`
|
||||
|
||||
#### `POST /api/sessions/:id/unarchive`
|
||||
|
||||
- **Auth:** Required
|
||||
- **Action:** Sets `archived = 0` on the session
|
||||
- **Constraints:** Returns 404 if session not found.
|
||||
- **Response:** `{ success: true }`
|
||||
|
||||
#### `POST /api/sessions/bulk`
|
||||
|
||||
- **Auth:** Required
|
||||
- **Action:** Performs a bulk operation on multiple sessions
|
||||
- **Body:** `{ "action": "archive" | "unarchive" | "delete", "ids": [1, 2, 3] }`
|
||||
- **Constraints:**
|
||||
- For `archive` and `delete`: rejects request with 400 if any session ID in the list is still active, returning the offending IDs in the error response
|
||||
- All IDs must exist (404 if any are not found)
|
||||
- Runs inside a database transaction — all-or-nothing
|
||||
- **Validation:** Returns 400 if `ids` is empty, if `action` is not one of the three valid values, or if `ids` is not an array
|
||||
- **Response:** `{ success: true, affected: <count> }`
|
||||
- **Route registration:** Must be registered before `/:id` routes to avoid Express matching `"bulk"` as an `:id` parameter
|
||||
|
||||
### Modified Endpoint
|
||||
|
||||
#### `GET /api/sessions`
|
||||
|
||||
Add two query parameters:
|
||||
|
||||
- **`filter`**: `"default"` | `"archived"` | `"all"`
|
||||
- `"default"` (when omitted): returns sessions where `archived = 0`
|
||||
- `"archived"`: returns sessions where `archived = 1`
|
||||
- `"all"`: returns all sessions regardless of archived status
|
||||
- **`limit`**: `"5"` | `"10"` | `"25"` | `"50"` | `"all"`
|
||||
- `"5"` (when omitted): returns the first 5 sessions (ordered by `created_at DESC`)
|
||||
- `"all"`: no limit applied
|
||||
- Any other value: applied as SQL `LIMIT`
|
||||
|
||||
The response shape stays the same (array of session objects). Each session now includes the `archived` field (0 or 1). The existing `has_notes` and `notes_preview` fields continue as-is.
|
||||
|
||||
**Total count:** The response should also include a way for the frontend to know how many sessions match the current filter (for the "N sessions total" display). Two options: a response header, or wrapping the response. To avoid breaking the existing array response shape, add a custom response header `X-Total-Count` with the total matching count before limit is applied.
|
||||
|
||||
### Unchanged Endpoints
|
||||
|
||||
- `GET /api/sessions/:id` — already returns the full session object; will naturally include `archived` once the column exists
|
||||
- `POST /api/sessions` — unchanged (new sessions default to `archived = 0`)
|
||||
- `POST /api/sessions/:id/close` — unchanged
|
||||
- `DELETE /api/sessions/:id` — unchanged (still works, used by detail page)
|
||||
- `PUT /api/sessions/:id/notes`, `DELETE /api/sessions/:id/notes` — unchanged
|
||||
|
||||
## Frontend
|
||||
|
||||
### History Page — Controls Bar
|
||||
|
||||
Replace the existing "Show All / Show Recent" toggle with a cohesive controls bar containing:
|
||||
|
||||
1. **Filter dropdown:** "Sessions" (default, `filter=default`), "Archived" (`filter=archived`), "All" (`filter=all`)
|
||||
2. **Show dropdown:** 5 (default), 10, 25, 50, All
|
||||
3. **Session count:** "N sessions total" — derived from the `X-Total-Count` response header
|
||||
4. **Select button** (admin only): toggles multi-select mode on/off
|
||||
|
||||
Both the Filter and Show selections are persisted in `localStorage`:
|
||||
- `history-filter` — stores the selected filter value (`"default"`, `"archived"`, `"all"`)
|
||||
- `history-show-limit` — stores the selected limit value (`"5"`, `"10"`, `"25"`, `"50"`, `"all"`)
|
||||
|
||||
Values are read on mount and written on change. The frontend should always pass both `filter` and `limit` query params explicitly when calling `GET /api/sessions`, including in the 3-second polling interval.
|
||||
|
||||
### History Page — Session Cards
|
||||
|
||||
#### Sunday Badge
|
||||
|
||||
Sessions whose `created_at` falls on a Sunday (in the user's local timezone) display:
|
||||
- An amber "GAME NIGHT" badge (with sun icon) next to the session number
|
||||
- "· Sunday" appended to the date line in muted text
|
||||
|
||||
Determination is client-side: `parseUTCTimestamp(session.created_at).getDay() === 0` using the existing `dateUtils.js` helper.
|
||||
|
||||
#### Archived Badge
|
||||
|
||||
When viewing the "All" or "Archived" filter, archived sessions show a gray "Archived" badge next to the session number.
|
||||
|
||||
#### Notes Preview
|
||||
|
||||
Continues as-is — indigo left-border teaser when `has_notes` is true.
|
||||
|
||||
### History Page — Multi-Select Mode
|
||||
|
||||
**Entering multi-select:**
|
||||
- Click the "Select" toggle button in the controls bar (admin only)
|
||||
- Long-press (500ms+) on a closed session card (admin only) — enters multi-select and selects that card
|
||||
|
||||
**While in multi-select:**
|
||||
- Checkboxes appear on each session card
|
||||
- Active sessions (`is_active = 1`) are greyed out with a disabled checkbox — not selectable
|
||||
- Clicking a card toggles its selection (instead of navigating to detail page)
|
||||
- The "End Session" button on active session cards is hidden
|
||||
- A floating action bar appears at the bottom with:
|
||||
- Left: "N selected" count
|
||||
- Right: context-aware action buttons:
|
||||
- **"Sessions" filter:** Archive + Delete
|
||||
- **"Archived" filter:** Unarchive + Delete
|
||||
- **"All" filter:** Archive + Unarchive + Delete
|
||||
- The action bar only appears when at least 1 session is selected
|
||||
- **Delete** always shows a confirmation modal: "Delete N sessions? This cannot be undone."
|
||||
- **Archive/Unarchive** execute immediately (non-destructive, reversible) with a toast confirmation
|
||||
|
||||
**Changing filter or limit while in multi-select:**
|
||||
- Clears all selections (since the visible session list changes)
|
||||
- Stays in multi-select mode
|
||||
|
||||
**Exiting multi-select:**
|
||||
- Click the "Done" / Select toggle button
|
||||
- Clears all selections and hides checkboxes + action bar
|
||||
|
||||
### Session Detail Page
|
||||
|
||||
#### Archive/Unarchive Button
|
||||
|
||||
- Placed alongside the existing Delete Session button at the bottom of the page
|
||||
- Only visible to authenticated admins, only for closed sessions
|
||||
- Shows "Archive" if `session.archived === 0`, "Unarchive" if `session.archived === 1`
|
||||
- Executes immediately with toast confirmation (no modal — it's reversible)
|
||||
|
||||
#### Archived Banner
|
||||
|
||||
- When viewing an archived session, a subtle banner appears at the top of the detail content: "This session is archived"
|
||||
- Includes an inline "Unarchive" button (admin only)
|
||||
|
||||
#### Sunday Badge
|
||||
|
||||
- Same amber "GAME NIGHT" badge shown next to "Session #N" in the page header
|
||||
- "· Sunday" in the date/time display
|
||||
|
||||
### Frontend Utilities
|
||||
|
||||
Add a helper to `dateUtils.js`:
|
||||
|
||||
```js
|
||||
export function isSunday(sqliteTimestamp) {
|
||||
return parseUTCTimestamp(sqliteTimestamp).getDay() === 0;
|
||||
}
|
||||
```
|
||||
|
||||
## What's NOT Changing
|
||||
|
||||
- **Database:** No new tables, just one column addition
|
||||
- **Session notes** (view/edit/delete) — untouched
|
||||
- **EndSessionModal** — untouched
|
||||
- **WebSocket events** — no archive-related real-time updates
|
||||
- **Other routes/pages** (Home, Picker, Manager, Login) — untouched
|
||||
- **`POST /sessions/:id/close`** — untouched
|
||||
- **New dependencies** — none required (no new npm packages)
|
||||
93
frontend/ICONS.md
Normal file
93
frontend/ICONS.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Icon Generation Guide
|
||||
|
||||
## Current Icons
|
||||
|
||||
- ✅ `public/favicon.svg` - Primary icon (SVG format)
|
||||
|
||||
## Missing Icons (Optional but Recommended)
|
||||
|
||||
For optimal PWA support, especially on iOS/Safari, you should generate PNG versions:
|
||||
|
||||
- `public/icon-192.png` - 192x192px PNG
|
||||
- `public/icon-512.png` - 512x512px PNG
|
||||
|
||||
## How to Generate PNG Icons
|
||||
|
||||
### Option 1: Online Converter (Easiest)
|
||||
|
||||
1. Go to https://realfavicongenerator.net/ or https://favicon.io/
|
||||
2. Upload `public/favicon.svg`
|
||||
3. Generate and download PNG versions
|
||||
4. Save as `icon-192.png` and `icon-512.png` in `frontend/public/`
|
||||
|
||||
### Option 2: Using ImageMagick (Command Line)
|
||||
|
||||
If you have ImageMagick installed:
|
||||
|
||||
```bash
|
||||
cd frontend/public
|
||||
|
||||
# Generate 192x192
|
||||
convert favicon.svg -resize 192x192 icon-192.png
|
||||
|
||||
# Generate 512x512
|
||||
convert favicon.svg -resize 512x512 icon-512.png
|
||||
```
|
||||
|
||||
### Option 3: Using Node.js Script
|
||||
|
||||
Install sharp library temporarily:
|
||||
|
||||
```bash
|
||||
npm install --save-dev sharp
|
||||
```
|
||||
|
||||
Create and run this script:
|
||||
|
||||
```javascript
|
||||
// generate-icons.js
|
||||
const sharp = require('sharp');
|
||||
const fs = require('fs');
|
||||
|
||||
const sizes = [192, 512];
|
||||
const svgBuffer = fs.readFileSync('./public/favicon.svg');
|
||||
|
||||
sizes.forEach(size => {
|
||||
sharp(svgBuffer)
|
||||
.resize(size, size)
|
||||
.png()
|
||||
.toFile(`./public/icon-${size}.png`)
|
||||
.then(() => console.log(`✅ Generated icon-${size}.png`))
|
||||
.catch(err => console.error(`❌ Failed to generate icon-${size}.png:`, err));
|
||||
});
|
||||
```
|
||||
|
||||
Run it:
|
||||
```bash
|
||||
node generate-icons.js
|
||||
```
|
||||
|
||||
Then uninstall sharp:
|
||||
```bash
|
||||
npm uninstall sharp
|
||||
```
|
||||
|
||||
## What Happens Without PNG Icons?
|
||||
|
||||
The app will still work as a PWA! Modern browsers (Chrome, Edge, Firefox) support SVG icons just fine. However:
|
||||
|
||||
- **iOS Safari** may not display the icon correctly on the home screen
|
||||
- Some older Android devices might show a generic icon
|
||||
- The manifest references PNG files as fallbacks
|
||||
|
||||
The SVG will be used as a fallback, which works on most platforms.
|
||||
|
||||
## Why We Don't Auto-Generate
|
||||
|
||||
PNG generation requires either:
|
||||
- Native image processing libraries (platform-dependent)
|
||||
- External dependencies that bloat the build
|
||||
- Build-time processing that slows down development
|
||||
|
||||
Since the app works fine with SVG on most platforms, we leave PNG generation as an optional step.
|
||||
|
||||
74
frontend/generate-manifest.js
Normal file
74
frontend/generate-manifest.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Import branding config dynamically
|
||||
const brandingModule = await import('./src/config/branding.js');
|
||||
const branding = brandingModule.branding;
|
||||
|
||||
const manifest = {
|
||||
name: branding.app.name,
|
||||
short_name: branding.app.shortName,
|
||||
description: branding.app.description,
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#1f2937",
|
||||
theme_color: branding.meta.themeColor,
|
||||
orientation: "any",
|
||||
scope: "/",
|
||||
icons: [
|
||||
{
|
||||
src: "/favicon.svg",
|
||||
sizes: "any",
|
||||
type: "image/svg+xml"
|
||||
},
|
||||
{
|
||||
src: "/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any maskable"
|
||||
},
|
||||
{
|
||||
src: "/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any maskable"
|
||||
},
|
||||
{
|
||||
src: "/favicon.svg",
|
||||
sizes: "512x512",
|
||||
type: "image/svg+xml",
|
||||
purpose: "any"
|
||||
}
|
||||
],
|
||||
screenshots: [],
|
||||
categories: ["entertainment", "games", "utilities"],
|
||||
shortcuts: [
|
||||
{
|
||||
name: "Pick a Game",
|
||||
short_name: "Pick",
|
||||
description: "Go directly to the game picker",
|
||||
url: "/picker",
|
||||
icons: []
|
||||
},
|
||||
{
|
||||
name: "Session History",
|
||||
short_name: "History",
|
||||
description: "View past gaming sessions",
|
||||
url: "/history",
|
||||
icons: []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Write manifest to public directory
|
||||
const publicDir = path.join(__dirname, 'public');
|
||||
const manifestPath = path.join(publicDir, 'manifest.json');
|
||||
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
||||
|
||||
console.log('✅ Generated manifest.json from branding config');
|
||||
|
||||
166
frontend/generate-png-icons.html
Normal file
166
frontend/generate-png-icons.html
Normal file
@@ -0,0 +1,166 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>PNG Icon Generator</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #4f46e5;
|
||||
margin-top: 0;
|
||||
}
|
||||
.preview {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin: 20px 0;
|
||||
align-items: center;
|
||||
}
|
||||
canvas {
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
button {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover {
|
||||
background: #4338ca;
|
||||
}
|
||||
.info {
|
||||
background: #eff6ff;
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding: 12px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.success {
|
||||
background: #f0fdf4;
|
||||
border-left-color: #22c55e;
|
||||
color: #166534;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎨 PWA Icon Generator</h1>
|
||||
|
||||
<div class="info">
|
||||
<strong>Instructions:</strong> This tool will generate PNG icons from your SVG favicon.
|
||||
Click the buttons below to download the required icon sizes for PWA support.
|
||||
</div>
|
||||
|
||||
<div class="preview">
|
||||
<div>
|
||||
<h3>192x192</h3>
|
||||
<canvas id="canvas192" width="192" height="192"></canvas>
|
||||
<br>
|
||||
<button onclick="downloadIcon(192)">📥 Download 192x192</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>512x512</h3>
|
||||
<canvas id="canvas512" width="512" height="512"></canvas>
|
||||
<br>
|
||||
<button onclick="downloadIcon(512)">📥 Download 512x512</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
|
||||
<div class="info">
|
||||
<strong>After downloading:</strong>
|
||||
<ol>
|
||||
<li>Save both files to <code>frontend/public/</code></li>
|
||||
<li>Rename them to <code>icon-192.png</code> and <code>icon-512.png</code></li>
|
||||
<li>Rebuild your Docker containers</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#4f46e5;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Dice/Box shape -->
|
||||
<rect x="10" y="10" width="80" height="80" rx="12" fill="url(#grad)"/>
|
||||
|
||||
<!-- Dots representing game selection -->
|
||||
<circle cx="30" cy="30" r="6" fill="white" opacity="0.9"/>
|
||||
<circle cx="50" cy="30" r="6" fill="white" opacity="0.9"/>
|
||||
<circle cx="70" cy="30" r="6" fill="white" opacity="0.9"/>
|
||||
|
||||
<circle cx="30" cy="50" r="6" fill="white" opacity="0.9"/>
|
||||
<circle cx="50" cy="50" r="6" fill="white" opacity="1"/>
|
||||
<circle cx="70" cy="50" r="6" fill="white" opacity="0.9"/>
|
||||
|
||||
<circle cx="30" cy="70" r="6" fill="white" opacity="0.9"/>
|
||||
<circle cx="50" cy="70" r="6" fill="white" opacity="0.9"/>
|
||||
<circle cx="70" cy="70" r="6" fill="white" opacity="0.9"/>
|
||||
</svg>`;
|
||||
|
||||
function drawIcon(size) {
|
||||
const canvas = document.getElementById(`canvas${size}`);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const img = new Image();
|
||||
const blob = new Blob([svgContent], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
img.onload = function() {
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
function downloadIcon(size) {
|
||||
const canvas = document.getElementById(`canvas${size}`);
|
||||
canvas.toBlob(function(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `icon-${size}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
const status = document.getElementById('status');
|
||||
status.innerHTML = `<div class="info success">✅ Downloaded icon-${size}.png! Save it to frontend/public/</div>`;
|
||||
setTimeout(() => status.innerHTML = '', 3000);
|
||||
});
|
||||
}
|
||||
|
||||
// Draw icons on page load
|
||||
window.addEventListener('load', () => {
|
||||
drawIcon(192);
|
||||
drawIcon(512);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,22 +4,37 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Branding -->
|
||||
<title>Jackbox Game Picker</title>
|
||||
<meta name="description" content="A web app for managing and picking Jackbox Party Pack games" />
|
||||
<meta name="keywords" content="jackbox, party pack, game picker, multiplayer games" />
|
||||
<meta name="author" content="Jackbox Game Picker" />
|
||||
<!-- Branding (populated by vite.config.js from branding.js) -->
|
||||
<title>HSO Jackbox Game Picker</title>
|
||||
<meta name="description" content="Spicing up Hyper Spaceout game nights!" />
|
||||
<meta name="keywords" content="hso, hyper spaceout, jackbox, party pack, game picker, multiplayer games" />
|
||||
<meta name="author" content="cottongin" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
<!-- Theme color -->
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Mobile Web App -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
<!-- iOS Safari -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="HSO JGP" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" sizes="167x167" href="/favicon.svg" />
|
||||
|
||||
<!-- Theme color (populated by vite.config.js from branding.js) -->
|
||||
<meta name="theme-color" content="#4F46E5" />
|
||||
|
||||
<!-- Open Graph / Social Media -->
|
||||
<!-- Open Graph / Social Media (populated by vite.config.js from branding.js) -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Jackbox Game Picker" />
|
||||
<meta property="og:description" content="A web app for managing and picking Jackbox Party Pack games" />
|
||||
<meta property="og:title" content="HSO Jackbox Game Picker" />
|
||||
<meta property="og:description" content="Spicing up Hyper Spaceout game nights!" />
|
||||
|
||||
<!-- Prevent flash of unstyled content in dark mode -->
|
||||
<script>
|
||||
|
||||
@@ -18,6 +18,20 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Service Worker - no caching!
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
add_header Service-Worker-Allowed "/";
|
||||
}
|
||||
|
||||
# PWA Manifest
|
||||
location = /manifest.json {
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
add_header Content-Type "application/manifest+json";
|
||||
}
|
||||
|
||||
# React routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
4464
frontend/package-lock.json
generated
Normal file
4464
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,15 @@
|
||||
"name": "jackbox-game-picker-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "Frontend for Jackbox Party Pack Game Picker",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"axios": "^1.6.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"axios": "^1.6.2"
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.20.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
@@ -20,8 +23,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"build": "node generate-manifest.js && vite build",
|
||||
"preview": "vite preview",
|
||||
"generate-manifest": "node generate-manifest.js"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
|
||||
BIN
frontend/public/icon-192.png
Normal file
BIN
frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
BIN
frontend/public/icon-512.png
Normal file
BIN
frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
69
frontend/public/sw.js
Normal file
69
frontend/public/sw.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const CACHE_NAME = 'jackbox-picker-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/favicon.ico'
|
||||
];
|
||||
|
||||
// Install service worker
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.addAll(urlsToCache))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate service worker
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch strategy: Network first, fallback to cache
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip chrome-extension and other non-http(s) requests
|
||||
if (!event.request.url.startsWith('http')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
// Don't cache API responses or non-successful responses
|
||||
if (event.request.url.includes('/api/') || !response || response.status !== 200) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Clone the response
|
||||
const responseToCache = response.clone();
|
||||
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
// Network failed, try cache
|
||||
return caches.match(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,11 +5,15 @@ import { ToastProvider } from './components/Toast';
|
||||
import { branding } from './config/branding';
|
||||
import Logo from './components/Logo';
|
||||
import ThemeToggle from './components/ThemeToggle';
|
||||
import InstallPrompt from './components/InstallPrompt';
|
||||
import SafariInstallPrompt from './components/SafariInstallPrompt';
|
||||
import PresenceBar from './components/PresenceBar';
|
||||
import Home from './pages/Home';
|
||||
import Login from './pages/Login';
|
||||
import Picker from './pages/Picker';
|
||||
import Manager from './pages/Manager';
|
||||
import History from './pages/History';
|
||||
import SessionDetail from './pages/SessionDetail';
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
@@ -153,12 +157,16 @@ function App() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Admin Presence */}
|
||||
<PresenceBar />
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8 flex-grow">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/history/:id" element={<SessionDetail />} />
|
||||
<Route path="/picker" element={<Picker />} />
|
||||
<Route path="/manager" element={<Manager />} />
|
||||
</Routes>
|
||||
@@ -177,6 +185,10 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* PWA Install Prompts */}
|
||||
<InstallPrompt />
|
||||
<SafariInstallPrompt />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
97
frontend/src/components/InstallPrompt.jsx
Normal file
97
frontend/src/components/InstallPrompt.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
function InstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if Safari (which doesn't support beforeinstallprompt)
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
if (isSafari) {
|
||||
return; // Don't show this prompt on Safari, use SafariInstallPrompt instead
|
||||
}
|
||||
|
||||
const handler = (e) => {
|
||||
// Prevent the mini-infobar from appearing on mobile
|
||||
e.preventDefault();
|
||||
// Save the event so it can be triggered later
|
||||
setDeferredPrompt(e);
|
||||
// Show our custom install prompt
|
||||
setShowPrompt(true);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
// Show the install prompt
|
||||
deferredPrompt.prompt();
|
||||
|
||||
// Wait for the user to respond to the prompt
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
console.log(`User response to install prompt: ${outcome}`);
|
||||
|
||||
// Clear the saved prompt
|
||||
setDeferredPrompt(null);
|
||||
setShowPrompt(false);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false);
|
||||
// Remember dismissal for this session
|
||||
sessionStorage.setItem('installPromptDismissed', 'true');
|
||||
};
|
||||
|
||||
// Don't show if already dismissed in this session
|
||||
if (sessionStorage.getItem('installPromptDismissed')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!showPrompt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:max-w-md z-50 animate-slideUp">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 text-3xl">
|
||||
📱
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
Install App
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Install Jackbox Game Picker for quick access and offline support!
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="flex-1 bg-indigo-600 dark:bg-indigo-700 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition font-medium text-sm"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition text-sm"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InstallPrompt;
|
||||
|
||||
41
frontend/src/components/PresenceBar.jsx
Normal file
41
frontend/src/components/PresenceBar.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { usePresence } from '../hooks/usePresence';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
function PresenceBar() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { viewers } = usePresence();
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
const otherViewers = viewers.filter(v => v !== 'me');
|
||||
if (otherViewers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-2 sm:px-4 pt-3">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider font-medium flex-shrink-0">
|
||||
who's here?
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{viewers.map((name, i) => (
|
||||
<span
|
||||
key={`${name}-${i}`}
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
name === 'me'
|
||||
? 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PresenceBar;
|
||||
120
frontend/src/components/RoomCodeModal.jsx
Normal file
120
frontend/src/components/RoomCodeModal.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
function RoomCodeModal({ isOpen, onConfirm, onCancel, gameName }) {
|
||||
const [roomCode, setRoomCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setRoomCode('');
|
||||
setError('');
|
||||
// Focus input when modal opens
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onCancel]);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const value = e.target.value.toUpperCase();
|
||||
// Only allow A-Z and 0-9, max 4 characters
|
||||
const filtered = value.replace(/[^A-Z0-9]/g, '').slice(0, 4);
|
||||
setRoomCode(filtered);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (roomCode.length !== 4) {
|
||||
setError('Room code must be exactly 4 characters');
|
||||
return;
|
||||
}
|
||||
onConfirm(roomCode);
|
||||
};
|
||||
|
||||
const handleOverlayClick = (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-md w-full p-6 animate-fade-in">
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-2">
|
||||
Enter Room Code
|
||||
</h2>
|
||||
{gameName && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
For: <span className="font-semibold">{gameName}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
4-Character Room Code (A-Z, 0-9)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={roomCode}
|
||||
onChange={handleInputChange}
|
||||
placeholder="ABCD"
|
||||
className="w-full px-4 py-3 text-center text-2xl font-mono font-bold tracking-widest border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 uppercase"
|
||||
maxLength={4}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500 dark:text-gray-400 font-mono">
|
||||
{roomCode.length}/4
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-4 py-3 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition font-semibold"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={roomCode.length !== 4}
|
||||
className="flex-1 px-4 py-3 bg-indigo-600 dark:bg-indigo-700 text-white rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition font-semibold disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600 dark:disabled:hover:bg-indigo-700"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoomCodeModal;
|
||||
|
||||
71
frontend/src/components/SafariInstallPrompt.jsx
Normal file
71
frontend/src/components/SafariInstallPrompt.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
function SafariInstallPrompt() {
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
const [isStandalone, setIsStandalone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if running in standalone mode (already installed)
|
||||
const standalone = window.navigator.standalone || window.matchMedia('(display-mode: standalone)').matches;
|
||||
setIsStandalone(standalone);
|
||||
|
||||
// Check if Safari on iOS or macOS
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
const isMacOS = navigator.platform.includes('Mac') && !isIOS;
|
||||
|
||||
// Show prompt if Safari and not already installed
|
||||
if ((isSafari || isIOS) && !standalone && !sessionStorage.getItem('safariInstallPromptDismissed')) {
|
||||
// Wait a bit before showing to not overwhelm user
|
||||
const timer = setTimeout(() => {
|
||||
setShowPrompt(true);
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false);
|
||||
sessionStorage.setItem('safariInstallPromptDismissed', 'true');
|
||||
};
|
||||
|
||||
// Don't show if already installed
|
||||
if (isStandalone || !showPrompt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:max-w-md z-50 animate-slideUp">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 text-3xl">
|
||||
🍎
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
Install as App
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Tap the Share button <span className="inline-block w-4 h-4 align-middle">
|
||||
<svg viewBox="0 0 50 50" className="fill-current">
|
||||
<path d="M30.3 13.7L25 8.4l-5.3 5.3-1.4-1.4L25 5.6l6.7 6.7z"/>
|
||||
<path d="M24 7h2v21h-2z"/>
|
||||
<path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z"/>
|
||||
</svg>
|
||||
</span> and select "Add to Home Screen"
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="w-full text-center px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition text-sm border border-gray-300 dark:border-gray-600 rounded-lg"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SafariInstallPrompt;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export const branding = {
|
||||
app: {
|
||||
name: 'HSO Jackbox Game Picker',
|
||||
shortName: 'HSO JGP',
|
||||
version: '0.3.0',
|
||||
shortName: 'Jackbox Game Picker',
|
||||
version: '0.6.4 - Fish Tank Edition',
|
||||
description: 'Spicing up Hyper Spaceout game nights!',
|
||||
},
|
||||
meta: {
|
||||
@@ -11,7 +11,7 @@ export const branding = {
|
||||
themeColor: '#4F46E5', // Indigo-600
|
||||
},
|
||||
links: {
|
||||
github: '', // Optional: Add your repo URL
|
||||
github: 'https://code.cottongin.xyz/HyperSpaceOut/jackboxpartypack-gamepicker', // Optional: Add your repo URL
|
||||
support: 'cottongin@cottongin.xyz', // Optional: Add support/contact URL
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { migratePreferences } from '../utils/adminPrefs';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
@@ -13,6 +14,7 @@ export const useAuth = () => {
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [token, setToken] = useState(localStorage.getItem('adminToken'));
|
||||
const [adminName, setAdminName] = useState(localStorage.getItem('adminName'));
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -20,10 +22,17 @@ export const AuthProvider = ({ children }) => {
|
||||
const verifyToken = async () => {
|
||||
if (token) {
|
||||
try {
|
||||
await axios.post('/api/auth/verify', {}, {
|
||||
const response = await axios.post('/api/auth/verify', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setIsAuthenticated(true);
|
||||
const name = response.data.user?.name;
|
||||
if (name) {
|
||||
setAdminName(name);
|
||||
localStorage.setItem('adminName', name);
|
||||
} else {
|
||||
logout();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token verification failed:', error);
|
||||
logout();
|
||||
@@ -38,27 +47,33 @@ export const AuthProvider = ({ children }) => {
|
||||
const login = async (key) => {
|
||||
try {
|
||||
const response = await axios.post('/api/auth/login', { key });
|
||||
const newToken = response.data.token;
|
||||
const { token: newToken, name } = response.data;
|
||||
localStorage.setItem('adminToken', newToken);
|
||||
localStorage.setItem('adminName', name);
|
||||
setToken(newToken);
|
||||
setAdminName(name);
|
||||
setIsAuthenticated(true);
|
||||
migratePreferences(name);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || 'Login failed'
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || 'Login failed'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('adminToken');
|
||||
localStorage.removeItem('adminName');
|
||||
setToken(null);
|
||||
setAdminName(null);
|
||||
setIsAuthenticated(false);
|
||||
};
|
||||
|
||||
const value = {
|
||||
token,
|
||||
adminName,
|
||||
isAuthenticated,
|
||||
loading,
|
||||
login,
|
||||
@@ -67,4 +82,3 @@ export const AuthProvider = ({ children }) => {
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
|
||||
83
frontend/src/hooks/usePresence.js
Normal file
83
frontend/src/hooks/usePresence.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const WS_RECONNECT_DELAY = 3000;
|
||||
const PING_INTERVAL = 30000;
|
||||
|
||||
export function usePresence() {
|
||||
const { token, adminName, isAuthenticated } = useAuth();
|
||||
const location = useLocation();
|
||||
const [viewers, setViewers] = useState([]);
|
||||
const wsRef = useRef(null);
|
||||
const pingRef = useRef(null);
|
||||
const reconnectRef = useRef(null);
|
||||
|
||||
const getWsUrl = useCallback(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${protocol}//${window.location.host}/api/sessions/live`;
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!isAuthenticated || !token) return;
|
||||
|
||||
const ws = new WebSocket(getWsUrl());
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: 'auth', token }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'auth_success') {
|
||||
ws.send(JSON.stringify({ type: 'page_focus', page: location.pathname }));
|
||||
|
||||
pingRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, PING_INTERVAL);
|
||||
}
|
||||
|
||||
if (msg.type === 'presence_update') {
|
||||
const currentPage = location.pathname;
|
||||
const onSamePage = msg.admins
|
||||
.filter(a => a.page === currentPage)
|
||||
.map(a => a.name === adminName ? 'me' : a.name);
|
||||
setViewers(onSamePage);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
clearInterval(pingRef.current);
|
||||
reconnectRef.current = setTimeout(connect, WS_RECONNECT_DELAY);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}, [isAuthenticated, token, adminName, location.pathname, getWsUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
clearTimeout(reconnectRef.current);
|
||||
clearInterval(pingRef.current);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'page_focus', page: location.pathname }));
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
return { viewers };
|
||||
}
|
||||
@@ -2,6 +2,21 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slideUp {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply antialiased;
|
||||
|
||||
@@ -18,3 +18,16 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
// Register service worker for PWA support
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then((registration) => {
|
||||
console.log('Service Worker registered:', registration);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('Service Worker registration failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import api from '../api/axios';
|
||||
import { formatLocalDateTime, formatLocalTime } from '../utils/dateUtils';
|
||||
@@ -7,27 +7,25 @@ import PopularityBadge from '../components/PopularityBadge';
|
||||
|
||||
function Home() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [activeSession, setActiveSession] = useState(null);
|
||||
const [sessionGames, setSessionGames] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadActiveSession();
|
||||
const loadSessionGames = useCallback(async (sessionId, silent = false) => {
|
||||
try {
|
||||
const gamesResponse = await api.get(`/sessions/${sessionId}/games`);
|
||||
// Reverse chronological order (most recent first)
|
||||
setSessionGames(gamesResponse.data.reverse());
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
console.error('Failed to load session games', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-refresh for active session
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
|
||||
// Refresh games every 3 seconds for active session
|
||||
const interval = setInterval(() => {
|
||||
loadSessionGames(activeSession.id, true); // silent refresh
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [activeSession]);
|
||||
|
||||
const loadActiveSession = async () => {
|
||||
const loadActiveSession = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get('/sessions/active');
|
||||
|
||||
@@ -44,17 +42,32 @@ function Home() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [loadSessionGames]);
|
||||
|
||||
const loadSessionGames = async (sessionId, silent = false) => {
|
||||
useEffect(() => {
|
||||
loadActiveSession();
|
||||
}, [loadActiveSession]);
|
||||
|
||||
// Auto-refresh for active session status and games
|
||||
useEffect(() => {
|
||||
// Poll for session status changes every 3 seconds
|
||||
const interval = setInterval(() => {
|
||||
loadActiveSession();
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [loadActiveSession]);
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
setCreating(true);
|
||||
try {
|
||||
const gamesResponse = await api.get(`/sessions/${sessionId}/games`);
|
||||
// Reverse chronological order (most recent first)
|
||||
setSessionGames(gamesResponse.data.reverse());
|
||||
await api.post('/sessions');
|
||||
// Navigate to picker page after creating session
|
||||
navigate('/picker');
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
console.error('Failed to load session games', error);
|
||||
}
|
||||
console.error('Failed to create session:', error);
|
||||
alert('Failed to create session. Please try again.');
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -131,6 +144,11 @@ function Home() {
|
||||
⏭️ Skipped
|
||||
</span>
|
||||
)}
|
||||
{game.room_code && (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-indigo-600 dark:bg-indigo-700 text-white px-2 py-1 rounded font-mono font-bold">
|
||||
🎮 {game.room_code}
|
||||
</span>
|
||||
)}
|
||||
<PopularityBadge
|
||||
upvotes={game.upvotes || 0}
|
||||
downvotes={game.downvotes || 0}
|
||||
@@ -169,12 +187,13 @@ function Home() {
|
||||
There is currently no game session in progress.
|
||||
</p>
|
||||
{isAuthenticated ? (
|
||||
<Link
|
||||
to="/picker"
|
||||
className="inline-block bg-indigo-600 dark:bg-indigo-700 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition"
|
||||
<button
|
||||
onClick={handleCreateSession}
|
||||
disabled={creating}
|
||||
className="inline-block bg-indigo-600 dark:bg-indigo-700 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Start a New Session
|
||||
</Link>
|
||||
{creating ? 'Creating Session...' : 'Start a New Session'}
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Admin access required to start a new session.
|
||||
@@ -186,10 +205,18 @@ function Home() {
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<Link
|
||||
to="/history"
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl transition"
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl hover:scale-[1.02] transition-all group"
|
||||
>
|
||||
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2">
|
||||
Session History
|
||||
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2 flex items-center justify-between">
|
||||
<span>Session History</span>
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 group-hover:translate-x-1 transition-all"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
View past gaming sessions and the games that were played
|
||||
@@ -199,10 +226,18 @@ function Home() {
|
||||
{isAuthenticated && (
|
||||
<Link
|
||||
to="/manager"
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl transition"
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl hover:scale-[1.02] transition-all group"
|
||||
>
|
||||
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2">
|
||||
Game Manager
|
||||
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2 flex items-center justify-between">
|
||||
<span>Game Manager</span>
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 group-hover:translate-x-1 transition-all"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Manage games, packs, and view statistics
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
671
frontend/src/pages/SessionDetail.jsx
Normal file
671
frontend/src/pages/SessionDetail.jsx
Normal file
@@ -0,0 +1,671 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import Markdown from 'react-markdown';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useToast } from '../components/Toast';
|
||||
import api from '../api/axios';
|
||||
import { formatLocalDateTime, formatLocalTime, isSunday } from '../utils/dateUtils';
|
||||
import PopularityBadge from '../components/PopularityBadge';
|
||||
|
||||
function SessionDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { error: showError, success } = useToast();
|
||||
|
||||
const [session, setSession] = useState(null);
|
||||
const [games, setGames] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editedNotes, setEditedNotes] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showDeleteNotesConfirm, setShowDeleteNotesConfirm] = useState(false);
|
||||
const [showDeleteSessionConfirm, setShowDeleteSessionConfirm] = useState(false);
|
||||
const [showChatImport, setShowChatImport] = useState(false);
|
||||
const [closingSession, setClosingSession] = useState(false);
|
||||
|
||||
const loadSession = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get(`/sessions/${id}`);
|
||||
setSession(res.data);
|
||||
} catch (err) {
|
||||
if (err.response?.status === 404) {
|
||||
navigate('/history', { replace: true });
|
||||
}
|
||||
console.error('Failed to load session', err);
|
||||
}
|
||||
}, [id, navigate]);
|
||||
|
||||
const loadGames = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get(`/sessions/${id}/games`);
|
||||
setGames([...res.data].reverse());
|
||||
} catch (err) {
|
||||
console.error('Failed to load session games', err);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadSession(), loadGames()]).finally(() => setLoading(false));
|
||||
}, [loadSession, loadGames]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || session.is_active !== 1) return;
|
||||
const interval = setInterval(() => {
|
||||
loadSession();
|
||||
loadGames();
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [session, loadSession, loadGames]);
|
||||
|
||||
const handleSaveNotes = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put(`/sessions/${id}/notes`, { notes: editedNotes });
|
||||
await loadSession();
|
||||
setEditing(false);
|
||||
success('Notes saved');
|
||||
} catch (err) {
|
||||
showError('Failed to save notes');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNotes = async () => {
|
||||
try {
|
||||
await api.delete(`/sessions/${id}/notes`);
|
||||
await loadSession();
|
||||
setEditing(false);
|
||||
setShowDeleteNotesConfirm(false);
|
||||
success('Notes deleted');
|
||||
} catch (err) {
|
||||
showError('Failed to delete notes');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSession = async () => {
|
||||
try {
|
||||
await api.delete(`/sessions/${id}`);
|
||||
success('Session deleted');
|
||||
navigate('/history', { replace: true });
|
||||
} catch (err) {
|
||||
showError('Failed to delete session: ' + (err.response?.data?.error || err.message));
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
const action = session.archived === 1 ? 'unarchive' : 'archive';
|
||||
try {
|
||||
await api.post(`/sessions/${id}/${action}`);
|
||||
await loadSession();
|
||||
success(`Session ${action}d`);
|
||||
} catch (err) {
|
||||
showError(err.response?.data?.error || `Failed to ${action} session`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseSession = async (sessionId, notes) => {
|
||||
try {
|
||||
await api.post(`/sessions/${sessionId}/close`, { notes });
|
||||
await loadSession();
|
||||
await loadGames();
|
||||
setClosingSession(false);
|
||||
success('Session ended successfully');
|
||||
} catch (err) {
|
||||
showError('Failed to close session');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (format) => {
|
||||
try {
|
||||
const response = await api.get(`/sessions/${id}/export?format=${format}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `session-${id}.${format === 'json' ? 'json' : 'txt'}`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
success(`Session exported as ${format.toUpperCase()}`);
|
||||
} catch (err) {
|
||||
showError('Failed to export session');
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = () => {
|
||||
setEditedNotes(session.notes || '');
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="text-xl text-gray-600 dark:text-gray-400">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="text-xl text-gray-600 dark:text-gray-400">Session not found</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Link
|
||||
to="/history"
|
||||
className="inline-flex items-center text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 mb-4 transition"
|
||||
>
|
||||
← Back to History
|
||||
</Link>
|
||||
|
||||
{session.archived === 1 && (
|
||||
<div className="bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg p-4 mb-4 flex justify-between items-center">
|
||||
<span className="text-gray-600 dark:text-gray-400 text-sm font-medium">
|
||||
This session is archived
|
||||
</span>
|
||||
{isAuthenticated && (
|
||||
<button
|
||||
onClick={handleArchive}
|
||||
className="text-sm bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700 transition"
|
||||
>
|
||||
Unarchive
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-800 dark:text-gray-100">
|
||||
Session #{session.id}
|
||||
</h1>
|
||||
{session.is_active === 1 && (
|
||||
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-sm px-3 py-1 rounded-full font-semibold animate-pulse inline-flex items-center gap-1">
|
||||
🟢 Active
|
||||
</span>
|
||||
)}
|
||||
{isSunday(session.created_at) && (
|
||||
<span className="bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200 text-xs px-2 py-0.5 rounded font-semibold">
|
||||
🎲 Game Night
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{formatLocalDateTime(session.created_at)}
|
||||
{isSunday(session.created_at) && (
|
||||
<span className="text-gray-400 dark:text-gray-500"> · Sunday</span>
|
||||
)}
|
||||
{' • '}
|
||||
{session.games_played} game{session.games_played !== 1 ? 's' : ''} played
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isAuthenticated && session.is_active === 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setClosingSession(true)}
|
||||
className="bg-orange-600 dark:bg-orange-700 text-white px-4 py-2 rounded-lg hover:bg-orange-700 dark:hover:bg-orange-800 transition text-sm"
|
||||
>
|
||||
End Session
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowChatImport(true)}
|
||||
className="bg-indigo-600 dark:bg-indigo-700 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition text-sm"
|
||||
>
|
||||
Import Chat Log
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleExport('txt')}
|
||||
className="bg-gray-600 dark:bg-gray-700 text-white px-4 py-2 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition text-sm"
|
||||
>
|
||||
Export TXT
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport('json')}
|
||||
className="bg-gray-600 dark:bg-gray-700 text-white px-4 py-2 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition text-sm"
|
||||
>
|
||||
Export JSON
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isAuthenticated && session.is_active === 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleArchive}
|
||||
className={`${
|
||||
session.archived === 1
|
||||
? 'bg-green-600 dark:bg-green-700 hover:bg-green-700 dark:hover:bg-green-800'
|
||||
: 'bg-gray-500 dark:bg-gray-600 hover:bg-gray-600 dark:hover:bg-gray-700'
|
||||
} text-white px-4 py-2 rounded-lg transition text-sm`}
|
||||
>
|
||||
{session.archived === 1 ? 'Unarchive' : 'Archive'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteSessionConfirm(true)}
|
||||
className="bg-red-600 dark:bg-red-700 text-white px-4 py-2 rounded-lg hover:bg-red-700 dark:hover:bg-red-800 transition text-sm"
|
||||
>
|
||||
Delete Session
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
|
||||
<NotesSection
|
||||
session={session}
|
||||
isAuthenticated={isAuthenticated}
|
||||
editing={editing}
|
||||
editedNotes={editedNotes}
|
||||
saving={saving}
|
||||
showDeleteNotesConfirm={showDeleteNotesConfirm}
|
||||
onStartEditing={startEditing}
|
||||
onSetEditedNotes={setEditedNotes}
|
||||
onSave={handleSaveNotes}
|
||||
onCancel={() => setEditing(false)}
|
||||
onDeleteNotes={handleDeleteNotes}
|
||||
onShowDeleteConfirm={() => setShowDeleteNotesConfirm(true)}
|
||||
onHideDeleteConfirm={() => setShowDeleteNotesConfirm(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showChatImport && (
|
||||
<div className="mb-6">
|
||||
<ChatImportPanel
|
||||
sessionId={id}
|
||||
onClose={() => setShowChatImport(false)}
|
||||
onImportComplete={() => {
|
||||
loadGames();
|
||||
setShowChatImport(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||
{games.length === 0 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400">No games played in this session</p>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-700 dark:text-gray-200">
|
||||
Games Played ({games.length})
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{games.map((game, index) => (
|
||||
<div key={game.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-700/50">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<div className="font-semibold text-lg text-gray-800 dark:text-gray-100">
|
||||
{games.length - index}. {game.title}
|
||||
</div>
|
||||
<div className="text-gray-600 dark:text-gray-400">{game.pack_name}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatLocalTime(game.played_at)}
|
||||
</div>
|
||||
{game.manually_added === 1 && (
|
||||
<span className="inline-block mt-1 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
|
||||
Manual
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div>
|
||||
<span className="font-semibold">Players:</span> {game.min_players}-{game.max_players}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Type:</span> {game.game_type || 'N/A'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-semibold"
|
||||
title="Popularity is cumulative across all sessions where this game was played"
|
||||
>
|
||||
Popularity:
|
||||
</span>
|
||||
<PopularityBadge
|
||||
upvotes={game.upvotes || 0}
|
||||
downvotes={game.downvotes || 0}
|
||||
popularityScore={game.popularity_score || 0}
|
||||
size="sm"
|
||||
showCounts={true}
|
||||
showNet={true}
|
||||
showRatio={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{closingSession && (
|
||||
<EndSessionModal
|
||||
sessionId={parseInt(id)}
|
||||
sessionGames={games}
|
||||
onClose={() => setClosingSession(false)}
|
||||
onConfirm={handleCloseSession}
|
||||
onShowChatImport={() => {
|
||||
setShowChatImport(true);
|
||||
setClosingSession(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDeleteSessionConfirm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-md w-full">
|
||||
<h2 className="text-2xl font-bold mb-4 text-red-600 dark:text-red-400">Delete Session?</h2>
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-6">
|
||||
Are you sure you want to delete Session #{session.id}?
|
||||
This will permanently delete all games and chat logs associated with this session. This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={handleDeleteSession}
|
||||
className="flex-1 bg-red-600 dark:bg-red-700 text-white py-3 rounded-lg hover:bg-red-700 dark:hover:bg-red-800 transition font-semibold"
|
||||
>
|
||||
Delete Permanently
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteSessionConfirm(false)}
|
||||
className="flex-1 bg-gray-600 dark:bg-gray-700 text-white py-3 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesSection({
|
||||
session,
|
||||
isAuthenticated,
|
||||
editing,
|
||||
editedNotes,
|
||||
saving,
|
||||
showDeleteNotesConfirm,
|
||||
onStartEditing,
|
||||
onSetEditedNotes,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDeleteNotes,
|
||||
onShowDeleteConfirm,
|
||||
onHideDeleteConfirm,
|
||||
}) {
|
||||
if (editing) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-100">Session Notes</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onShowDeleteConfirm}
|
||||
className="bg-red-600 dark:bg-red-700 text-white px-3 py-1.5 rounded text-sm hover:bg-red-700 dark:hover:bg-red-800 transition"
|
||||
>
|
||||
Delete Notes
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="bg-green-600 dark:bg-green-700 text-white px-3 py-1.5 rounded text-sm hover:bg-green-700 dark:hover:bg-green-800 transition disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="bg-gray-500 dark:bg-gray-600 text-white px-3 py-1.5 rounded text-sm hover:bg-gray-600 dark:hover:bg-gray-700 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={editedNotes}
|
||||
onChange={(e) => onSetEditedNotes(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-indigo-300 dark:border-indigo-600 rounded-lg h-48 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-mono text-sm leading-relaxed resize-y focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="Write your session notes here... Markdown is supported."
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Supports Markdown formatting</p>
|
||||
|
||||
{showDeleteNotesConfirm && (
|
||||
<div className="mt-3 p-4 bg-red-50 dark:bg-red-900/30 border border-red-300 dark:border-red-700 rounded-lg">
|
||||
<p className="text-red-700 dark:text-red-300 mb-3">Are you sure you want to delete these notes?</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onDeleteNotes}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 transition"
|
||||
>
|
||||
Yes, Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={onHideDeleteConfirm}
|
||||
className="bg-gray-500 text-white px-4 py-2 rounded text-sm hover:bg-gray-600 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-3">Session Notes</h2>
|
||||
{session.has_notes ? (
|
||||
<>
|
||||
<p className="text-gray-700 dark:text-gray-300">{session.notes_preview}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2 italic">Log in to view full notes</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400 italic">No notes for this session</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-100">Session Notes</h2>
|
||||
<button
|
||||
onClick={onStartEditing}
|
||||
className="bg-indigo-600 dark:bg-indigo-700 text-white px-3 py-1.5 rounded text-sm hover:bg-indigo-700 dark:hover:bg-indigo-800 transition"
|
||||
>
|
||||
{session.notes ? 'Edit' : 'Add Notes'}
|
||||
</button>
|
||||
</div>
|
||||
{session.notes ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-gray-700 dark:text-gray-300">
|
||||
<Markdown>{session.notes}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400 italic">No notes for this session</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EndSessionModal({ sessionId, sessionGames, onClose, onConfirm, onShowChatImport }) {
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
const hasPopularityData = sessionGames.some(game => game.popularity_score !== 0);
|
||||
const showPopularityWarning = sessionGames.length > 0 && !hasPopularityData;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-md w-full">
|
||||
<h2 className="text-2xl font-bold mb-4 dark:text-gray-100">End Session #{sessionId}</h2>
|
||||
|
||||
{showPopularityWarning && (
|
||||
<div className="mb-4 p-4 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-yellow-600 dark:text-yellow-400 text-xl">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-yellow-800 dark:text-yellow-200 mb-1">No Popularity Data</p>
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-300 mb-3">
|
||||
You haven't imported chat reactions yet. Import now to track which games your players loved!
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { onClose(); onShowChatImport(); }}
|
||||
className="text-sm bg-yellow-600 dark:bg-yellow-700 text-white px-4 py-2 rounded hover:bg-yellow-700 dark:hover:bg-yellow-800 transition"
|
||||
>
|
||||
Import Chat Log
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">
|
||||
Session Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg h-32 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Add any notes about this session..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => onConfirm(sessionId, notes)}
|
||||
className="flex-1 bg-orange-600 dark:bg-orange-700 text-white py-3 rounded-lg hover:bg-orange-700 dark:hover:bg-orange-800 transition"
|
||||
>
|
||||
End Session
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 bg-gray-600 dark:bg-gray-700 text-white py-3 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
|
||||
const [chatData, setChatData] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const { error, success } = useToast();
|
||||
|
||||
const handleFileUpload = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
setChatData(text);
|
||||
success('File loaded successfully');
|
||||
} catch (err) {
|
||||
error('Failed to read file: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!chatData.trim()) {
|
||||
error('Please enter chat data or upload a file');
|
||||
return;
|
||||
}
|
||||
setImporting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const parsedData = JSON.parse(chatData);
|
||||
const response = await api.post(`/sessions/${sessionId}/chat-import`, { chatData: parsedData });
|
||||
setResult(response.data);
|
||||
success('Chat log imported successfully');
|
||||
setTimeout(() => onImportComplete(), 2000);
|
||||
} catch (err) {
|
||||
error('Import failed: ' + (err.response?.data?.error || err.message));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-xl font-semibold mb-4 dark:text-gray-100">Import Chat Log</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Upload a JSON file or paste JSON array with format: [{"username": "...", "message": "...", "timestamp": "..."}]
|
||||
<br />
|
||||
The system will detect "thisgame++" and "thisgame--" patterns and update game popularity.
|
||||
</p>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">Upload JSON File</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileUpload}
|
||||
disabled={importing}
|
||||
className="block w-full text-sm text-gray-900 dark:text-gray-100 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 dark:file:bg-indigo-900/30 dark:file:text-indigo-300 hover:file:bg-indigo-100 dark:hover:file:bg-indigo-900/50 file:cursor-pointer cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 text-center text-gray-500 dark:text-gray-400 text-sm">— or —</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">Paste Chat JSON Data</label>
|
||||
<textarea
|
||||
value={chatData}
|
||||
onChange={(e) => setChatData(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg h-48 font-mono text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder='[{"username":"Alice","message":"thisgame++","timestamp":"2024-01-01T12:00:00Z"}]'
|
||||
disabled={importing}
|
||||
/>
|
||||
</div>
|
||||
{result && (
|
||||
<div className="mb-4 p-4 bg-green-50 dark:bg-green-900/30 border border-green-300 dark:border-green-700 rounded-lg">
|
||||
<p className="font-semibold text-green-800 dark:text-green-200">Import Successful!</p>
|
||||
<p className="text-sm text-green-700 dark:text-green-300">
|
||||
Imported {result.messagesImported} messages, processed {result.votesProcessed} votes
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={importing}
|
||||
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400 dark:disabled:bg-gray-600"
|
||||
>
|
||||
{importing ? 'Importing...' : 'Import'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-gray-600 dark:bg-gray-700 text-white px-6 py-2 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionDetail;
|
||||
19
frontend/src/utils/adminPrefs.js
Normal file
19
frontend/src/utils/adminPrefs.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const PREF_KEYS = ['history-filter', 'history-show-limit'];
|
||||
|
||||
export function prefixKey(adminName, key) {
|
||||
if (!adminName) return key;
|
||||
return `${adminName}:${key}`;
|
||||
}
|
||||
|
||||
export function migratePreferences(adminName) {
|
||||
if (!adminName) return;
|
||||
|
||||
for (const key of PREF_KEYS) {
|
||||
const oldValue = localStorage.getItem(key);
|
||||
const newKey = prefixKey(adminName, key);
|
||||
if (oldValue !== null && localStorage.getItem(newKey) === null) {
|
||||
localStorage.setItem(newKey, oldValue);
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,3 +47,53 @@ export function formatLocalDateTime(sqliteTimestamp) {
|
||||
return parseUTCTimestamp(sqliteTimestamp).toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a SQLite timestamp falls on a Sunday (in local timezone)
|
||||
* @param {string} sqliteTimestamp
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSunday(sqliteTimestamp) {
|
||||
return parseUTCTimestamp(sqliteTimestamp).getDay() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a locale-independent date key for grouping sessions by local calendar day
|
||||
* @param {string} sqliteTimestamp
|
||||
* @returns {string} - e.g., "2026-03-23"
|
||||
*/
|
||||
export function getLocalDateKey(sqliteTimestamp) {
|
||||
const d = parseUTCTimestamp(sqliteTimestamp);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a SQLite timestamp as a day header string (e.g., "Sunday, Mar 23, 2026")
|
||||
* @param {string} sqliteTimestamp
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDayHeader(sqliteTimestamp) {
|
||||
const d = parseUTCTimestamp(sqliteTimestamp);
|
||||
return d.toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a SQLite timestamp as a time-only string (e.g., "7:30 PM")
|
||||
* @param {string} sqliteTimestamp
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatTimeOnly(sqliteTimestamp) {
|
||||
const d = parseUTCTimestamp(sqliteTimestamp);
|
||||
return d.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
"./index.html",
|
||||
@@ -32,6 +32,6 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [require('@tailwindcss/typography')],
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,46 @@
|
||||
const { defineConfig } = require('vite');
|
||||
const react = require('@vitejs/plugin-react');
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { branding } from './src/config/branding.js';
|
||||
|
||||
module.exports = defineConfig({
|
||||
plugins: [react()],
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
{
|
||||
name: 'html-transform',
|
||||
transformIndexHtml(html) {
|
||||
return html
|
||||
.replace(/<title>.*?<\/title>/, `<title>${branding.app.name}</title>`)
|
||||
.replace(
|
||||
/<meta name="description" content=".*?"\/>/,
|
||||
`<meta name="description" content="${branding.app.description}"/>`
|
||||
)
|
||||
.replace(
|
||||
/<meta name="keywords" content=".*?"\/>/,
|
||||
`<meta name="keywords" content="${branding.meta.keywords}"/>`
|
||||
)
|
||||
.replace(
|
||||
/<meta name="author" content=".*?"\/>/,
|
||||
`<meta name="author" content="${branding.meta.author}"/>`
|
||||
)
|
||||
.replace(
|
||||
/<meta name="theme-color" content=".*?"\/>/,
|
||||
`<meta name="theme-color" content="${branding.meta.themeColor}"/>`
|
||||
)
|
||||
.replace(
|
||||
/<meta name="apple-mobile-web-app-title" content=".*?"\/>/,
|
||||
`<meta name="apple-mobile-web-app-title" content="${branding.app.shortName}"/>`
|
||||
)
|
||||
.replace(
|
||||
/<meta property="og:title" content=".*?"\/>/,
|
||||
`<meta property="og:title" content="${branding.app.name}"/>`
|
||||
)
|
||||
.replace(
|
||||
/<meta property="og:description" content=".*?"\/>/,
|
||||
`<meta property="og:description" content="${branding.app.description}"/>`
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
|
||||
8
jest.config.js
Normal file
8
jest.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/tests'],
|
||||
setupFiles: ['<rootDir>/tests/jest.setup.js'],
|
||||
testMatch: ['**/*.test.js'],
|
||||
testTimeout: 10000,
|
||||
moduleDirectories: ['node_modules', '<rootDir>/backend/node_modules'],
|
||||
};
|
||||
230
scripts/README.md
Normal file
230
scripts/README.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Jackbox Player Count Fetcher
|
||||
|
||||
Tools to retrieve the actual player count from a Jackbox game room in real-time.
|
||||
|
||||
## Available Implementations
|
||||
|
||||
### 1. Go + chromedp (Recommended) 🚀
|
||||
The most reliable method - automates joining through jackbox.tv to capture WebSocket data.
|
||||
|
||||
### 2. Browser HTML Interface 🌐
|
||||
Quick visual tool for manual testing - no installation required.
|
||||
|
||||
### 3. Node.js Script (Limited)
|
||||
Attempts direct WebSocket connection - may not work due to authentication requirements.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔍 Automatically joins jackbox.tv to capture WebSocket data
|
||||
- 📊 Returns actual player count (not just max capacity)
|
||||
- 👥 Lists all current players and their roles (host/player)
|
||||
- 🎮 Shows game state, lobby state, and audience count
|
||||
- 🎨 Pretty-printed output with colors
|
||||
|
||||
## Installation
|
||||
|
||||
### Go Version (Recommended)
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
go mod download
|
||||
```
|
||||
|
||||
**Prerequisites:** Go 1.21+ and Chrome/Chromium browser installed
|
||||
|
||||
### Browser Version (No Installation Required!)
|
||||
|
||||
Just open `get-player-count.html` in any web browser - no installation needed!
|
||||
|
||||
### Node.js Version
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
npm install
|
||||
```
|
||||
|
||||
**Note:** The Node.js version may not work reliably due to Jackbox WebSocket authentication requirements.
|
||||
|
||||
## Usage
|
||||
|
||||
### Go Version (Best) 🚀
|
||||
|
||||
```bash
|
||||
# Navigate to scripts directory
|
||||
cd scripts
|
||||
|
||||
# Run the script
|
||||
go run get-player-count.go JYET
|
||||
|
||||
# Or build and run
|
||||
go build -o get-player-count get-player-count.go
|
||||
./get-player-count JYET
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
1. Opens jackbox.tv in headless Chrome
|
||||
2. Automatically enters room code and joins as "Observer"
|
||||
3. Captures WebSocket messages from the browser
|
||||
4. Extracts player count from `client/welcome` message
|
||||
5. Enriches with data from REST API
|
||||
|
||||
### Browser Version 🌐
|
||||
|
||||
```bash
|
||||
# Just open in browser
|
||||
open get-player-count.html
|
||||
```
|
||||
|
||||
1. Open `get-player-count.html` in your web browser
|
||||
2. Enter a room code (e.g., "JYET")
|
||||
3. Click "Get Player Count"
|
||||
4. View results instantly
|
||||
|
||||
This version runs entirely in the browser and doesn't require any backend!
|
||||
|
||||
### Node.js Version (Limited)
|
||||
|
||||
```bash
|
||||
node get-jackbox-player-count.js JYET
|
||||
```
|
||||
|
||||
**Warning:** May fail due to WebSocket authentication requirements. Use the Go version for reliable results.
|
||||
|
||||
### JSON Output (for scripting)
|
||||
|
||||
```bash
|
||||
JSON_OUTPUT=true node get-jackbox-player-count.js JYET
|
||||
```
|
||||
|
||||
### As a Module
|
||||
|
||||
```javascript
|
||||
const { getRoomInfo, getPlayerCount } = require('./get-jackbox-player-count');
|
||||
|
||||
async function example() {
|
||||
const roomCode = 'JYET';
|
||||
|
||||
// Get room info
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
console.log('Game:', roomInfo.appTag);
|
||||
|
||||
// Get player count
|
||||
const result = await getPlayerCount(roomCode, roomInfo);
|
||||
console.log('Players:', result.playerCount);
|
||||
console.log('Player list:', result.players);
|
||||
}
|
||||
|
||||
example();
|
||||
```
|
||||
|
||||
## Output Example
|
||||
|
||||
```
|
||||
Jackbox Player Count Fetcher
|
||||
Room Code: JYET
|
||||
|
||||
Fetching room information...
|
||||
✓ Room found: triviadeath
|
||||
Max Players: 8
|
||||
|
||||
Connecting to WebSocket...
|
||||
URL: wss://i-007fc4f534bce7898.play.jackboxgames.com/api/v2/rooms/JYET
|
||||
|
||||
✓ WebSocket connected
|
||||
|
||||
═══════════════════════════════════════════
|
||||
Jackbox Room Status
|
||||
═══════════════════════════════════════════
|
||||
|
||||
Room Code: JYET
|
||||
Game: triviadeath
|
||||
Game State: Lobby
|
||||
Lobby State: CanStart
|
||||
Locked: No
|
||||
Full: No
|
||||
|
||||
Players: 3 / 8
|
||||
Audience: 0
|
||||
|
||||
Current Players:
|
||||
1. Host (host)
|
||||
2. E (player)
|
||||
3. F (player)
|
||||
|
||||
═══════════════════════════════════════════
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **REST API Call**: First queries `https://ecast.jackboxgames.com/api/v2/rooms/{ROOM_CODE}` to get room metadata
|
||||
2. **WebSocket Connection**: Establishes a WebSocket connection to the game server
|
||||
3. **Join as Observer**: Sends a `client/connect` message to join as an audience member
|
||||
4. **Parse Response**: Listens for the `client/welcome` message containing the full lobby state
|
||||
5. **Extract Player Count**: Counts the players in the `here` object
|
||||
|
||||
## API Response Structure
|
||||
|
||||
The script returns an object with the following structure:
|
||||
|
||||
```javascript
|
||||
{
|
||||
roomCode: "JYET",
|
||||
appTag: "triviadeath",
|
||||
playerCount: 3, // Actual player count
|
||||
audienceCount: 0, // Number of audience members
|
||||
maxPlayers: 8, // Maximum capacity
|
||||
gameState: "Lobby", // Current game state
|
||||
lobbyState: "CanStart", // Whether game can start
|
||||
locked: false, // Whether lobby is locked
|
||||
full: false, // Whether lobby is full
|
||||
players: [ // List of all players
|
||||
{ id: "1", role: "host", name: "Host" },
|
||||
{ id: "2", role: "player", name: "E" },
|
||||
{ id: "3", role: "player", name: "F" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Your App
|
||||
|
||||
You can integrate this into your Jackbox game picker application:
|
||||
|
||||
```javascript
|
||||
// In your backend API
|
||||
const { getRoomInfo, getPlayerCount } = require('./scripts/get-jackbox-player-count');
|
||||
|
||||
app.get('/api/room-status/:roomCode', async (req, res) => {
|
||||
try {
|
||||
const roomCode = req.params.roomCode.toUpperCase();
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
const playerData = await getPlayerCount(roomCode, roomInfo);
|
||||
res.json(playerData);
|
||||
} catch (error) {
|
||||
res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Room not found or invalid"
|
||||
- Double-check the room code is correct
|
||||
- Make sure the game is currently running (room codes expire after games end)
|
||||
|
||||
### "Connection timeout"
|
||||
- The game server might be unavailable
|
||||
- Check your internet connection
|
||||
- The room might have closed
|
||||
|
||||
### WebSocket connection fails
|
||||
- Ensure you have the `ws` package installed: `npm install`
|
||||
- Some networks/firewalls might block WebSocket connections
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `ws` (^8.14.0) - WebSocket client for Node.js
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
184
scripts/TESTING.md
Normal file
184
scripts/TESTING.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Testing the Jackbox Player Count Script
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Go 1.21+ installed
|
||||
2. Chrome or Chromium browser installed
|
||||
3. Active Jackbox lobby with a valid room code
|
||||
|
||||
## Running the Script
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
go run get-player-count.go JYET
|
||||
```
|
||||
|
||||
Replace `JYET` with your actual room code.
|
||||
|
||||
### Debug Mode
|
||||
|
||||
If the script isn't capturing data, run with debug output:
|
||||
|
||||
```bash
|
||||
DEBUG=true go run get-player-count.go JYET
|
||||
```
|
||||
|
||||
This will show:
|
||||
- Each WebSocket frame received
|
||||
- Parsed opcodes
|
||||
- Detailed connection info
|
||||
|
||||
## Expected Output
|
||||
|
||||
### Success
|
||||
|
||||
```
|
||||
🎮 Jackbox Player Count Fetcher
|
||||
Room Code: JYET
|
||||
|
||||
⏳ Navigating to jackbox.tv...
|
||||
✓ Loaded jackbox.tv
|
||||
⏳ Joining room JYET...
|
||||
✓ Clicked Play button, waiting for WebSocket data...
|
||||
✓ Captured lobby data from WebSocket
|
||||
|
||||
═══════════════════════════════════════════
|
||||
Jackbox Room Status
|
||||
═══════════════════════════════════════════
|
||||
|
||||
Room Code: JYET
|
||||
Game: triviadeath
|
||||
Game State: Lobby
|
||||
Lobby State: CanStart
|
||||
Locked: false
|
||||
Full: false
|
||||
|
||||
Players: 3 / 8
|
||||
Audience: 0
|
||||
|
||||
Current Players:
|
||||
1. Host (host)
|
||||
2. E (player)
|
||||
3. F (player)
|
||||
|
||||
═══════════════════════════════════════════
|
||||
```
|
||||
|
||||
### If No WebSocket Messages Captured
|
||||
|
||||
```
|
||||
Error: no WebSocket messages captured - connection may have failed
|
||||
Try running with DEBUG=true for more details
|
||||
```
|
||||
|
||||
**Possible causes:**
|
||||
- Room code is invalid
|
||||
- Game lobby is closed
|
||||
- Network connectivity issues
|
||||
|
||||
### If Messages Captured but No Player Data
|
||||
|
||||
```
|
||||
⚠️ Captured 15 WebSocket messages but couldn't find 'client/welcome'
|
||||
|
||||
Message types found:
|
||||
- room/update: 5
|
||||
- audience/count: 3
|
||||
- ping: 7
|
||||
|
||||
Error: could not find player count data in WebSocket messages
|
||||
Room may be invalid, closed, or not in lobby state
|
||||
```
|
||||
|
||||
**Possible causes:**
|
||||
- Game has already started (not in lobby)
|
||||
- Room code exists but game is in different state
|
||||
- WebSocket connected but welcome message not sent
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cookie Errors (Harmless)
|
||||
|
||||
You may see errors like:
|
||||
```
|
||||
ERROR: could not unmarshal event: parse error: expected string near offset 1247 of 'cookiePart...'
|
||||
```
|
||||
|
||||
**These are harmless** and are suppressed in the output. They're chromedp trying to parse cookie data that doesn't follow expected format.
|
||||
|
||||
### "Failed to load jackbox.tv"
|
||||
|
||||
- Check your internet connection
|
||||
- Verify https://jackbox.tv loads in a regular browser
|
||||
- Try running without `headless` mode (edit the Go file)
|
||||
|
||||
### "Failed to enter room code"
|
||||
|
||||
- Verify the room code is valid
|
||||
- Check that the lobby is actually open
|
||||
- Try running with DEBUG=true to see what's happening
|
||||
|
||||
### "Failed to click Play button"
|
||||
|
||||
- The button may still be disabled
|
||||
- Room code validation may have failed
|
||||
- Name field may not be filled
|
||||
|
||||
### No WebSocket Messages at All
|
||||
|
||||
This means the browser never connected to the game's WebSocket server:
|
||||
- Verify the room code is correct
|
||||
- Check that the game lobby is actually open and accepting players
|
||||
- The game may have a full lobby
|
||||
- The room may have expired
|
||||
|
||||
## Testing with Different Game States
|
||||
|
||||
### Lobby (Should Work)
|
||||
When the game is in the lobby waiting for players to join.
|
||||
|
||||
### During Game (May Not Work)
|
||||
Once the game starts, the WebSocket messages change. The `client/welcome` message may not be sent.
|
||||
|
||||
### After Game (Won't Work)
|
||||
Room codes expire after the game session ends.
|
||||
|
||||
## Manual Verification
|
||||
|
||||
You can verify the data by:
|
||||
|
||||
1. Open https://jackbox.tv in a regular browser
|
||||
2. Open Developer Tools (F12)
|
||||
3. Go to Network tab
|
||||
4. Filter by "WS" (WebSocket)
|
||||
5. Join the room with the same code
|
||||
6. Look for `client/welcome` message in WebSocket frames
|
||||
7. Compare the data with what the script outputs
|
||||
|
||||
## Common Room States
|
||||
|
||||
| State | `client/welcome` | Player Count Available |
|
||||
|-------|------------------|------------------------|
|
||||
| Lobby - Waiting | ✅ Yes | ✅ Yes |
|
||||
| Lobby - Full | ✅ Yes | ✅ Yes |
|
||||
| Game Starting | ⚠️ Maybe | ⚠️ Maybe |
|
||||
| Game In Progress | ❌ No | ❌ No |
|
||||
| Game Ended | ❌ No | ❌ No |
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- Takes ~5-10 seconds to complete
|
||||
- Most time is waiting for WebSocket connection
|
||||
- Headless Chrome startup adds ~1-2 seconds
|
||||
- Network latency affects timing
|
||||
|
||||
## Next Steps
|
||||
|
||||
If the script works:
|
||||
1. Extract the function into a library package
|
||||
2. Integrate with your bot
|
||||
3. Set up cron jobs or periodic polling
|
||||
4. Add result caching to reduce load
|
||||
|
||||
262
scripts/get-jackbox-player-count.js
Normal file
262
scripts/get-jackbox-player-count.js
Normal file
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Jackbox Player Count Fetcher
|
||||
*
|
||||
* This script connects to a Jackbox game room and retrieves the actual player count
|
||||
* by establishing a WebSocket connection and listening for game state updates.
|
||||
*
|
||||
* Usage:
|
||||
* node get-jackbox-player-count.js <ROOM_CODE>
|
||||
* node get-jackbox-player-count.js JYET
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
|
||||
// Try to load ws from multiple locations
|
||||
let WebSocket;
|
||||
try {
|
||||
WebSocket = require('ws');
|
||||
} catch (e) {
|
||||
try {
|
||||
WebSocket = require('../backend/node_modules/ws');
|
||||
} catch (e2) {
|
||||
console.error('Error: WebSocket library (ws) not found.');
|
||||
console.error('Please run: npm install ws');
|
||||
console.error('Or run this script from the backend directory where ws is already installed.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ANSI color codes for pretty output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
red: '\x1b[31m',
|
||||
cyan: '\x1b[36m'
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches room information from the Jackbox REST API
|
||||
*/
|
||||
async function getRoomInfo(roomCode) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`;
|
||||
|
||||
https.get(url, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json.ok) {
|
||||
resolve(json.body);
|
||||
} else {
|
||||
reject(new Error('Room not found or invalid'));
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the Jackbox WebSocket and retrieves player count
|
||||
* Note: Direct WebSocket connection requires proper authentication flow.
|
||||
* This uses the ecast endpoint which is designed for external connections.
|
||||
*/
|
||||
async function getPlayerCount(roomCode, roomInfo) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Use the audienceHost (ecast) instead of direct game host
|
||||
const wsUrl = `wss://${roomInfo.audienceHost}/api/v2/audience/${roomCode}/play`;
|
||||
|
||||
console.log(`${colors.blue}Connecting to WebSocket...${colors.reset}`);
|
||||
console.log(`${colors.cyan}URL: ${wsUrl}${colors.reset}\n`);
|
||||
|
||||
const ws = new WebSocket(wsUrl, {
|
||||
headers: {
|
||||
'Origin': 'https://jackbox.tv',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
let timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error('Connection timeout - room may be closed or unreachable'));
|
||||
}, 15000); // 15 second timeout
|
||||
|
||||
let receivedAnyData = false;
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log(`${colors.green}✓ WebSocket connected${colors.reset}\n`);
|
||||
|
||||
// For audience endpoint, we might not need to send join message
|
||||
// Just listen for messages
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
receivedAnyData = true;
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
console.log(`${colors.yellow}Received message:${colors.reset}`, message.opcode || 'unknown');
|
||||
|
||||
// Look for various message types that might contain player info
|
||||
if (message.opcode === 'client/welcome' && message.result) {
|
||||
clearTimeout(timeout);
|
||||
|
||||
const here = message.result.here || {};
|
||||
const playerCount = Object.keys(here).length;
|
||||
const audienceCount = message.result.entities?.audience?.[1]?.count || 0;
|
||||
const lobbyState = message.result.entities?.['bc:room']?.[1]?.val?.lobbyState || 'Unknown';
|
||||
const gameState = message.result.entities?.['bc:room']?.[1]?.val?.state || 'Unknown';
|
||||
|
||||
// Extract player details
|
||||
const players = [];
|
||||
for (const [id, playerData] of Object.entries(here)) {
|
||||
const roles = playerData.roles || {};
|
||||
if (roles.host) {
|
||||
players.push({ id, role: 'host', name: 'Host' });
|
||||
} else if (roles.player) {
|
||||
players.push({ id, role: 'player', name: roles.player.name || 'Unknown' });
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
roomCode,
|
||||
appTag: roomInfo.appTag,
|
||||
playerCount,
|
||||
audienceCount,
|
||||
maxPlayers: roomInfo.maxPlayers,
|
||||
gameState,
|
||||
lobbyState,
|
||||
locked: roomInfo.locked,
|
||||
full: roomInfo.full,
|
||||
players
|
||||
};
|
||||
|
||||
ws.close();
|
||||
resolve(result);
|
||||
} else if (message.opcode === 'room/count' || message.opcode === 'audience/count-group') {
|
||||
// Audience count updates
|
||||
console.log(`${colors.cyan}Audience count message received${colors.reset}`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors, might be non-JSON messages
|
||||
console.log(`${colors.yellow}Parse error:${colors.reset}`, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`WebSocket error: ${error.message}\n\n` +
|
||||
`${colors.yellow}Note:${colors.reset} Direct WebSocket access requires joining through jackbox.tv.\n` +
|
||||
`This limitation means we cannot directly query player count without joining the game.`));
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
if (!receivedAnyData) {
|
||||
reject(new Error('WebSocket closed without receiving data.\n\n' +
|
||||
`${colors.yellow}Note:${colors.reset} The Jackbox WebSocket API requires authentication that's only\n` +
|
||||
`available when joining through the official jackbox.tv interface.\n\n` +
|
||||
`${colors.cyan}Alternative:${colors.reset} Use the REST API to check if room is full, or join\n` +
|
||||
`through jackbox.tv in a browser to get real-time player counts.`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty prints the results
|
||||
*/
|
||||
function printResults(result) {
|
||||
console.log(`${colors.bright}═══════════════════════════════════════════${colors.reset}`);
|
||||
console.log(`${colors.bright} Jackbox Room Status${colors.reset}`);
|
||||
console.log(`${colors.bright}═══════════════════════════════════════════${colors.reset}\n`);
|
||||
|
||||
console.log(`${colors.cyan}Room Code:${colors.reset} ${result.roomCode}`);
|
||||
console.log(`${colors.cyan}Game:${colors.reset} ${result.appTag}`);
|
||||
console.log(`${colors.cyan}Game State:${colors.reset} ${result.gameState}`);
|
||||
console.log(`${colors.cyan}Lobby State:${colors.reset} ${result.lobbyState}`);
|
||||
console.log(`${colors.cyan}Locked:${colors.reset} ${result.locked ? 'Yes' : 'No'}`);
|
||||
console.log(`${colors.cyan}Full:${colors.reset} ${result.full ? 'Yes' : 'No'}`);
|
||||
console.log();
|
||||
|
||||
console.log(`${colors.bright}${colors.green}Players:${colors.reset} ${colors.bright}${result.playerCount}${colors.reset} / ${result.maxPlayers}`);
|
||||
console.log(`${colors.cyan}Audience:${colors.reset} ${result.audienceCount}`);
|
||||
console.log();
|
||||
|
||||
if (result.players.length > 0) {
|
||||
console.log(`${colors.bright}Current Players:${colors.reset}`);
|
||||
result.players.forEach((player, idx) => {
|
||||
const roleColor = player.role === 'host' ? colors.yellow : colors.green;
|
||||
console.log(` ${idx + 1}. ${roleColor}${player.name}${colors.reset} (${player.role})`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n${colors.bright}═══════════════════════════════════════════${colors.reset}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error(`${colors.red}Error: Room code required${colors.reset}`);
|
||||
console.log(`\nUsage: node get-jackbox-player-count.js <ROOM_CODE>`);
|
||||
console.log(`Example: node get-jackbox-player-count.js JYET\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const roomCode = args[0].toUpperCase();
|
||||
|
||||
console.log(`${colors.bright}Jackbox Player Count Fetcher${colors.reset}`);
|
||||
console.log(`${colors.cyan}Room Code: ${roomCode}${colors.reset}\n`);
|
||||
|
||||
try {
|
||||
// Step 1: Get room info from REST API
|
||||
console.log(`${colors.blue}Fetching room information...${colors.reset}`);
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
console.log(`${colors.green}✓ Room found: ${roomInfo.appTag}${colors.reset}`);
|
||||
console.log(`${colors.cyan} Max Players: ${roomInfo.maxPlayers}${colors.reset}\n`);
|
||||
|
||||
// Step 2: Connect to WebSocket and get player count
|
||||
const result = await getPlayerCount(roomCode, roomInfo);
|
||||
|
||||
// Step 3: Print results
|
||||
printResults(result);
|
||||
|
||||
// Return just the player count for scripting purposes
|
||||
if (process.env.JSON_OUTPUT === 'true') {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error(`${colors.red}Error: ${error.message}${colors.reset}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if executed directly
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
// Export for use as a module
|
||||
module.exports = {
|
||||
getRoomInfo,
|
||||
getPlayerCount
|
||||
};
|
||||
|
||||
394
scripts/get-player-count.go
Normal file
394
scripts/get-player-count.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
// PlayerInfo represents a player in the lobby
|
||||
type PlayerInfo struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// LobbyStatus contains all information about the current lobby
|
||||
type LobbyStatus struct {
|
||||
RoomCode string `json:"roomCode"`
|
||||
AppTag string `json:"appTag"`
|
||||
PlayerCount int `json:"playerCount"`
|
||||
AudienceCount int `json:"audienceCount"`
|
||||
MaxPlayers int `json:"maxPlayers"`
|
||||
GameState string `json:"gameState"`
|
||||
LobbyState string `json:"lobbyState"`
|
||||
Locked bool `json:"locked"`
|
||||
Full bool `json:"full"`
|
||||
Players []PlayerInfo `json:"players"`
|
||||
}
|
||||
|
||||
// WebSocketMessage represents a parsed WebSocket message
|
||||
type WebSocketMessage struct {
|
||||
PC int `json:"pc"`
|
||||
Opcode string `json:"opcode"`
|
||||
Result map[string]interface{} `json:"result"`
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: go run get-player-count.go <ROOM_CODE>")
|
||||
fmt.Println("Example: go run get-player-count.go JYET")
|
||||
fmt.Println("\nSet DEBUG=true for verbose output:")
|
||||
fmt.Println("DEBUG=true go run get-player-count.go JYET")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
roomCode := strings.ToUpper(strings.TrimSpace(os.Args[1]))
|
||||
|
||||
if len(roomCode) != 4 {
|
||||
fmt.Println("Error: Room code must be exactly 4 characters")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("🎮 Jackbox Player Count Fetcher\n")
|
||||
fmt.Printf("Room Code: %s\n\n", roomCode)
|
||||
|
||||
status, err := getPlayerCount(roomCode)
|
||||
if err != nil {
|
||||
log.Fatalf("Error: %v\n", err)
|
||||
}
|
||||
|
||||
printStatus(status)
|
||||
}
|
||||
|
||||
func getPlayerCount(roomCode string) (*LobbyStatus, error) {
|
||||
// Create chrome context with less verbose logging
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
chromedp.Flag("disable-web-security", true),
|
||||
)
|
||||
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
defer cancel()
|
||||
|
||||
// Create context without default logging to reduce cookie errors
|
||||
ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(func(s string, i ...interface{}) {
|
||||
// Only log non-cookie errors
|
||||
msg := fmt.Sprintf(s, i...)
|
||||
if !strings.Contains(msg, "cookiePart") && !strings.Contains(msg, "could not unmarshal") {
|
||||
log.Printf(msg)
|
||||
}
|
||||
}))
|
||||
defer cancel()
|
||||
|
||||
// Set timeout
|
||||
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var lobbyStatus *LobbyStatus
|
||||
welcomeMessageFound := false
|
||||
wsMessages := make([]string, 0)
|
||||
|
||||
// Listen for WebSocket frames - this is the most reliable method
|
||||
debugMode := os.Getenv("DEBUG") == "true"
|
||||
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
if debugMode {
|
||||
fmt.Printf("[DEBUG] Event type: %T\n", ev)
|
||||
}
|
||||
|
||||
switch ev := ev.(type) {
|
||||
case *network.EventWebSocketCreated:
|
||||
if debugMode {
|
||||
fmt.Printf("[DEBUG] WebSocket Created: %s\n", ev.URL)
|
||||
}
|
||||
case *network.EventWebSocketFrameReceived:
|
||||
// Capture all WebSocket frames
|
||||
wsMessages = append(wsMessages, ev.Response.PayloadData)
|
||||
|
||||
if debugMode {
|
||||
fmt.Printf("[DEBUG] WS Frame received (%d bytes)\n", len(ev.Response.PayloadData))
|
||||
if len(ev.Response.PayloadData) < 200 {
|
||||
fmt.Printf("[DEBUG] Data: %s\n", ev.Response.PayloadData)
|
||||
} else {
|
||||
fmt.Printf("[DEBUG] Data (truncated): %s...\n", ev.Response.PayloadData[:200])
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse immediately
|
||||
var wsMsg WebSocketMessage
|
||||
if err := json.Unmarshal([]byte(ev.Response.PayloadData), &wsMsg); err == nil {
|
||||
if debugMode {
|
||||
fmt.Printf("[DEBUG] Parsed opcode: %s\n", wsMsg.Opcode)
|
||||
}
|
||||
|
||||
if wsMsg.Opcode == "client/welcome" && wsMsg.Result != nil {
|
||||
lobbyStatus = parseWelcomeMessage(&wsMsg)
|
||||
welcomeMessageFound = true
|
||||
fmt.Println("✓ Captured lobby data from WebSocket")
|
||||
}
|
||||
} else if debugMode {
|
||||
fmt.Printf("[DEBUG] Failed to parse JSON: %v\n", err)
|
||||
}
|
||||
case *network.EventWebSocketFrameSent:
|
||||
if debugMode {
|
||||
fmt.Printf("[DEBUG] WS Frame sent: %s\n", ev.Response.PayloadData)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fmt.Println("⏳ Navigating to jackbox.tv...")
|
||||
|
||||
// Enable network tracking BEFORE navigation
|
||||
if err := chromedp.Run(ctx, network.Enable()); err != nil {
|
||||
return nil, fmt.Errorf("failed to enable network tracking: %w", err)
|
||||
}
|
||||
|
||||
err := chromedp.Run(ctx,
|
||||
chromedp.Navigate("https://jackbox.tv/"),
|
||||
chromedp.WaitVisible(`input[placeholder*="ENTER 4-LETTER CODE"]`, chromedp.ByQuery),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load jackbox.tv: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Loaded jackbox.tv\n")
|
||||
fmt.Printf("⏳ Joining room %s...\n", roomCode)
|
||||
|
||||
// Type room code and press Enter to join
|
||||
err = chromedp.Run(ctx,
|
||||
chromedp.Focus(`input[placeholder*="ENTER 4-LETTER CODE"]`, chromedp.ByQuery),
|
||||
chromedp.SendKeys(`input[placeholder*="ENTER 4-LETTER CODE"]`, roomCode+"\n", chromedp.ByQuery),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to enter room code: %w", err)
|
||||
}
|
||||
|
||||
if debugMode {
|
||||
fmt.Println("[DEBUG] Entered room code and pressed Enter")
|
||||
}
|
||||
|
||||
// Wait for room code validation and page transition
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
fmt.Println("✓ Clicked Play button, waiting for WebSocket data...")
|
||||
|
||||
// Check if we successfully joined (look for typical lobby UI elements)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
var pageText string
|
||||
err = chromedp.Run(ctx,
|
||||
chromedp.Text("body", &pageText, chromedp.ByQuery),
|
||||
)
|
||||
if err == nil && debugMode {
|
||||
if strings.Contains(pageText, "Sit back") || strings.Contains(pageText, "waiting") {
|
||||
fmt.Println("[DEBUG] Successfully joined lobby (found lobby text)")
|
||||
} else {
|
||||
fmt.Printf("[DEBUG] Page text: %s\n", pageText[:min(300, len(pageText))])
|
||||
}
|
||||
}
|
||||
|
||||
// Wait longer for WebSocket to connect and receive welcome message
|
||||
for i := 0; i < 15 && !welcomeMessageFound; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if i%4 == 0 {
|
||||
fmt.Printf("⏳ Waiting for lobby data... (%ds)\n", i/2)
|
||||
}
|
||||
}
|
||||
|
||||
// If we still didn't get it from WebSocket frames, try parsing all captured messages
|
||||
if !welcomeMessageFound && len(wsMessages) > 0 {
|
||||
fmt.Printf("⏳ Parsing %d captured WebSocket messages...\n", len(wsMessages))
|
||||
|
||||
for _, msg := range wsMessages {
|
||||
var wsMsg WebSocketMessage
|
||||
if err := json.Unmarshal([]byte(msg), &wsMsg); err == nil {
|
||||
if wsMsg.Opcode == "client/welcome" && wsMsg.Result != nil {
|
||||
lobbyStatus = parseWelcomeMessage(&wsMsg)
|
||||
welcomeMessageFound = true
|
||||
fmt.Println("✓ Found lobby data in captured messages")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lobbyStatus == nil {
|
||||
if len(wsMessages) == 0 {
|
||||
return nil, fmt.Errorf("no WebSocket messages captured - connection may have failed\nTry running with DEBUG=true for more details")
|
||||
}
|
||||
|
||||
// Show what we captured
|
||||
fmt.Printf("\n⚠️ Captured %d WebSocket messages but couldn't find 'client/welcome'\n", len(wsMessages))
|
||||
fmt.Println("\nMessage types found:")
|
||||
opcodes := make(map[string]int)
|
||||
for _, msg := range wsMessages {
|
||||
var wsMsg WebSocketMessage
|
||||
if err := json.Unmarshal([]byte(msg), &wsMsg); err == nil {
|
||||
opcodes[wsMsg.Opcode]++
|
||||
}
|
||||
}
|
||||
for opcode, count := range opcodes {
|
||||
fmt.Printf(" - %s: %d\n", opcode, count)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("could not find player count data in WebSocket messages\nRoom may be invalid, closed, or not in lobby state")
|
||||
}
|
||||
|
||||
lobbyStatus.RoomCode = roomCode
|
||||
|
||||
// Fetch additional room info from REST API
|
||||
if err := enrichWithRestAPI(lobbyStatus); err != nil {
|
||||
fmt.Printf("Warning: Could not fetch additional room info: %v\n", err)
|
||||
}
|
||||
|
||||
return lobbyStatus, nil
|
||||
}
|
||||
|
||||
func parseWelcomeMessage(msg *WebSocketMessage) *LobbyStatus {
|
||||
status := &LobbyStatus{
|
||||
Players: []PlayerInfo{},
|
||||
}
|
||||
|
||||
// Parse "here" object for players
|
||||
if here, ok := msg.Result["here"].(map[string]interface{}); ok {
|
||||
status.PlayerCount = len(here)
|
||||
|
||||
for id, playerData := range here {
|
||||
if pd, ok := playerData.(map[string]interface{}); ok {
|
||||
player := PlayerInfo{ID: id}
|
||||
|
||||
if roles, ok := pd["roles"].(map[string]interface{}); ok {
|
||||
if _, hasHost := roles["host"]; hasHost {
|
||||
player.Role = "host"
|
||||
player.Name = "Host"
|
||||
} else if playerRole, ok := roles["player"].(map[string]interface{}); ok {
|
||||
player.Role = "player"
|
||||
if name, ok := playerRole["name"].(string); ok {
|
||||
player.Name = name
|
||||
} else {
|
||||
player.Name = "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status.Players = append(status.Players, player)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse entities for additional info
|
||||
if entities, ok := msg.Result["entities"].(map[string]interface{}); ok {
|
||||
// Audience count
|
||||
if audience, ok := entities["audience"].([]interface{}); ok && len(audience) > 1 {
|
||||
if audienceData, ok := audience[1].(map[string]interface{}); ok {
|
||||
if count, ok := audienceData["count"].(float64); ok {
|
||||
status.AudienceCount = int(count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Room state
|
||||
if bcRoom, ok := entities["bc:room"].([]interface{}); ok && len(bcRoom) > 1 {
|
||||
if roomData, ok := bcRoom[1].(map[string]interface{}); ok {
|
||||
if val, ok := roomData["val"].(map[string]interface{}); ok {
|
||||
if gameState, ok := val["state"].(string); ok {
|
||||
status.GameState = gameState
|
||||
}
|
||||
if lobbyState, ok := val["lobbyState"].(string); ok {
|
||||
status.LobbyState = lobbyState
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func enrichWithRestAPI(status *LobbyStatus) error {
|
||||
// Fetch additional room info from REST API
|
||||
url := fmt.Sprintf("https://ecast.jackboxgames.com/api/v2/rooms/%s", status.RoomCode)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
OK bool `json:"ok"`
|
||||
Body struct {
|
||||
AppTag string `json:"appTag"`
|
||||
MaxPlayers int `json:"maxPlayers"`
|
||||
Locked bool `json:"locked"`
|
||||
Full bool `json:"full"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.OK {
|
||||
status.AppTag = result.Body.AppTag
|
||||
status.MaxPlayers = result.Body.MaxPlayers
|
||||
status.Locked = result.Body.Locked
|
||||
status.Full = result.Body.Full
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printStatus(status *LobbyStatus) {
|
||||
fmt.Println()
|
||||
fmt.Println("═══════════════════════════════════════════")
|
||||
fmt.Println(" Jackbox Room Status")
|
||||
fmt.Println("═══════════════════════════════════════════")
|
||||
fmt.Println()
|
||||
fmt.Printf("Room Code: %s\n", status.RoomCode)
|
||||
fmt.Printf("Game: %s\n", status.AppTag)
|
||||
fmt.Printf("Game State: %s\n", status.GameState)
|
||||
fmt.Printf("Lobby State: %s\n", status.LobbyState)
|
||||
fmt.Printf("Locked: %t\n", status.Locked)
|
||||
fmt.Printf("Full: %t\n", status.Full)
|
||||
fmt.Println()
|
||||
fmt.Printf("Players: %d / %d\n", status.PlayerCount, status.MaxPlayers)
|
||||
fmt.Printf("Audience: %d\n", status.AudienceCount)
|
||||
fmt.Println()
|
||||
|
||||
if len(status.Players) > 0 {
|
||||
fmt.Println("Current Players:")
|
||||
for i, player := range status.Players {
|
||||
fmt.Printf(" %d. %s (%s)\n", i+1, player.Name, player.Role)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Println("═══════════════════════════════════════════")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
468
scripts/get-player-count.html
Normal file
468
scripts/get-player-count.html
Normal file
@@ -0,0 +1,468 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Jackbox Player Count Fetcher</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 6px;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status.loading {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.results.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-value.highlight {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.players-list {
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.players-list h3 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.player-item {
|
||||
padding: 10px;
|
||||
margin: 5px 0;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.player-role {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.player-role.host {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.player-role.player {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.badge.success {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.badge.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎮 Jackbox Player Count Fetcher</h1>
|
||||
<p class="subtitle">Enter a room code to get real-time player information</p>
|
||||
|
||||
<div class="input-group">
|
||||
<input type="text" id="roomCode" placeholder="Enter room code (e.g., JYET)" maxlength="4">
|
||||
<button id="fetchBtn" onclick="fetchPlayerCount()">Get Player Count</button>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<div id="results" class="results">
|
||||
<div class="result-card">
|
||||
<div class="stat-grid">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Players</div>
|
||||
<div class="stat-value highlight" id="playerCount">-</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Max Players</div>
|
||||
<div class="stat-value" id="maxPlayers">-</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Audience</div>
|
||||
<div class="stat-value" id="audienceCount">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Room Code:</span>
|
||||
<span class="info-value" id="displayRoomCode">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Game:</span>
|
||||
<span class="info-value" id="gameTag">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Game State:</span>
|
||||
<span class="info-value" id="gameState">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Lobby State:</span>
|
||||
<span class="info-value" id="lobbyState">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Locked:</span>
|
||||
<span class="info-value" id="locked">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Full:</span>
|
||||
<span class="info-value" id="full">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playersContainer" class="players-list" style="display: none;">
|
||||
<h3>Current Players</h3>
|
||||
<div id="playersList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Allow Enter key to trigger fetch
|
||||
document.getElementById('roomCode').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
fetchPlayerCount();
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchPlayerCount() {
|
||||
const roomCode = document.getElementById('roomCode').value.toUpperCase().trim();
|
||||
|
||||
if (!roomCode || roomCode.length !== 4) {
|
||||
showStatus('Please enter a valid 4-letter room code', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus('Fetching room information...', 'loading');
|
||||
document.getElementById('fetchBtn').disabled = true;
|
||||
document.getElementById('results').classList.remove('visible');
|
||||
|
||||
try {
|
||||
// Step 1: Get room info
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
|
||||
showStatus('Connecting to WebSocket...', 'loading');
|
||||
|
||||
// Step 2: Connect to WebSocket and get player count
|
||||
const result = await getPlayerCount(roomCode, roomInfo);
|
||||
|
||||
showStatus('✓ Successfully retrieved player information', 'success');
|
||||
displayResults(result);
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById('status').style.display = 'none';
|
||||
}, 3000);
|
||||
|
||||
} catch (error) {
|
||||
showStatus(`Error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
document.getElementById('fetchBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getRoomInfo(roomCode) {
|
||||
const response = await fetch(`https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.ok) {
|
||||
throw new Error('Room not found or invalid');
|
||||
}
|
||||
|
||||
return data.body;
|
||||
}
|
||||
|
||||
async function getPlayerCount(roomCode, roomInfo) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wsUrl = `wss://${roomInfo.host}/api/v2/rooms/${roomCode}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error('Connection timeout'));
|
||||
}, 10000);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Join as audience to get lobby state without affecting the game
|
||||
ws.send(JSON.stringify({
|
||||
opcode: 'client/connect',
|
||||
params: {
|
||||
name: 'WebObserver',
|
||||
role: 'audience',
|
||||
format: 'json'
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.opcode === 'client/welcome' && message.result) {
|
||||
clearTimeout(timeout);
|
||||
|
||||
const here = message.result.here || {};
|
||||
const playerCount = Object.keys(here).length;
|
||||
const audienceCount = message.result.entities?.audience?.[1]?.count || 0;
|
||||
const lobbyState = message.result.entities?.['bc:room']?.[1]?.val?.lobbyState || 'Unknown';
|
||||
const gameState = message.result.entities?.['bc:room']?.[1]?.val?.state || 'Unknown';
|
||||
|
||||
const players = [];
|
||||
for (const [id, playerData] of Object.entries(here)) {
|
||||
const roles = playerData.roles || {};
|
||||
if (roles.host) {
|
||||
players.push({ id, role: 'host', name: 'Host' });
|
||||
} else if (roles.player) {
|
||||
players.push({ id, role: 'player', name: roles.player.name || 'Unknown' });
|
||||
}
|
||||
}
|
||||
|
||||
ws.close();
|
||||
resolve({
|
||||
roomCode,
|
||||
appTag: roomInfo.appTag,
|
||||
playerCount,
|
||||
audienceCount,
|
||||
maxPlayers: roomInfo.maxPlayers,
|
||||
gameState,
|
||||
lobbyState,
|
||||
locked: roomInfo.locked,
|
||||
full: roomInfo.full,
|
||||
players
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function showStatus(message, type) {
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = message;
|
||||
status.className = `status ${type}`;
|
||||
}
|
||||
|
||||
function displayResults(result) {
|
||||
document.getElementById('results').classList.add('visible');
|
||||
|
||||
document.getElementById('playerCount').textContent = result.playerCount;
|
||||
document.getElementById('maxPlayers').textContent = result.maxPlayers;
|
||||
document.getElementById('audienceCount').textContent = result.audienceCount;
|
||||
document.getElementById('displayRoomCode').textContent = result.roomCode;
|
||||
document.getElementById('gameTag').textContent = result.appTag;
|
||||
document.getElementById('gameState').textContent = result.gameState;
|
||||
document.getElementById('lobbyState').textContent = result.lobbyState;
|
||||
|
||||
document.getElementById('locked').innerHTML = result.locked
|
||||
? '<span class="badge error">Yes</span>'
|
||||
: '<span class="badge success">No</span>';
|
||||
|
||||
document.getElementById('full').innerHTML = result.full
|
||||
? '<span class="badge error">Yes</span>'
|
||||
: '<span class="badge success">No</span>';
|
||||
|
||||
if (result.players && result.players.length > 0) {
|
||||
const playersList = document.getElementById('playersList');
|
||||
playersList.innerHTML = result.players.map(player => `
|
||||
<div class="player-item">
|
||||
<span class="player-name">${player.name}</span>
|
||||
<span class="player-role ${player.role}">${player.role}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('playersContainer').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('playersContainer').style.display = 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user