Compare commits
78 Commits
8ba32e128c
...
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
|
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.
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -39,8 +39,12 @@ Thumbs.db
|
|||||||
.local/
|
.local/
|
||||||
.old-chrome-extension/
|
.old-chrome-extension/
|
||||||
|
|
||||||
|
# Admin config (real keys)
|
||||||
|
backend/config/admins.json
|
||||||
|
|
||||||
# Cursor
|
# Cursor
|
||||||
.cursor/
|
.cursor/
|
||||||
|
.superpowers/
|
||||||
chat-summaries/
|
chat-summaries/
|
||||||
plan.md
|
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.
|
||||||
245
README.md
245
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Jackbox Party Pack Game Picker
|
# 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
|
## Features
|
||||||
|
|
||||||
@@ -13,29 +13,42 @@ A full-stack web application that helps groups pick games to play from various J
|
|||||||
### Admin Features
|
### Admin Features
|
||||||
- **Game Picker**: Randomly select games with intelligent filters
|
- **Game Picker**: Randomly select games with intelligent filters
|
||||||
- Filter by player count, drawing games, game length, and family-friendly status
|
- 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)
|
- Automatic repeat avoidance (prevents same game or alternating pattern)
|
||||||
- Manual game selection option
|
- Manual game selection option
|
||||||
- Real-time session tracking
|
- Real-time session tracking
|
||||||
|
|
||||||
- **Game Manager**: Complete CRUD operations for games and packs
|
- **Game Manager**: Complete CRUD operations for games and packs
|
||||||
- Enable/disable individual games or entire packs
|
- Enable/disable individual games or entire packs
|
||||||
|
- Set favor bias on games and packs to influence pick weighting
|
||||||
- Import/export games via CSV
|
- Import/export games via CSV
|
||||||
- View statistics (play counts, popularity scores)
|
- View statistics (play counts, upvotes, downvotes, popularity scores)
|
||||||
- Add, edit, and delete games
|
- Add, edit, and delete games
|
||||||
|
|
||||||
- **Session Management**: Track gaming sessions over time
|
- **Session Management**: Track gaming sessions over time
|
||||||
- Create and close sessions
|
- Create and close sessions
|
||||||
- View session history
|
- View session history with pagination and filters
|
||||||
- Import chat logs to calculate game popularity
|
- Archive/unarchive sessions
|
||||||
- Track which games were played when
|
- 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
|
- **Chat Log Import**: Process chat messages to assess game popularity
|
||||||
- Supports "thisgame++" and "thisgame--" voting
|
- Supports "thisgame++" and "thisgame--" voting
|
||||||
- Automatically matches votes to games based on timestamps
|
- 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
|
- **Live Voting API**: Real-time vote processing from external bots
|
||||||
- Accept live votes via REST API
|
- 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)
|
- Automatic deduplication (1-second window)
|
||||||
- Timestamp-based game matching
|
- Timestamp-based game matching
|
||||||
- JWT authentication for security
|
- JWT authentication for security
|
||||||
@@ -48,16 +61,18 @@ A full-stack web application that helps groups pick games to play from various J
|
|||||||
|
|
||||||
### Public Features
|
### Public Features
|
||||||
- View active session and games currently being played
|
- 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
|
- See game statistics and popularity
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Frontend**: React 18 with Vite, Tailwind CSS, React Router
|
- **Frontend**: React 18 with Vite, Tailwind CSS, React Router 6
|
||||||
- **Backend**: Node.js with Express
|
- **Backend**: Node.js with Express 4
|
||||||
- **Database**: SQLite with better-sqlite3
|
- **Database**: SQLite with better-sqlite3
|
||||||
|
- **Real-time**: WebSocket server (`ws`) for presence, subscriptions, and live events
|
||||||
- **Authentication**: JWT-based admin authentication
|
- **Authentication**: JWT-based admin authentication
|
||||||
- **Deployment**: Docker with docker-compose
|
- **Deployment**: Docker with docker-compose (Node 22 Alpine + nginx Alpine)
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -76,13 +91,12 @@ A full-stack web application that helps groups pick games to play from various J
|
|||||||
|
|
||||||
Create a `.env` file in the root directory:
|
Create a `.env` file in the root directory:
|
||||||
```env
|
```env
|
||||||
PORT=5000
|
|
||||||
NODE_ENV=production
|
|
||||||
DB_PATH=/app/data/jackbox.db
|
|
||||||
JWT_SECRET=your-secret-jwt-key-change-this
|
JWT_SECRET=your-secret-jwt-key-change-this
|
||||||
ADMIN_KEY=your-admin-key-here
|
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**
|
3. **Build and start the containers**
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
@@ -94,7 +108,7 @@ A full-stack web application that helps groups pick games to play from various J
|
|||||||
|
|
||||||
5. **Login as admin**
|
5. **Login as admin**
|
||||||
- Navigate to the login page
|
- 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.
|
The database will be automatically initialized and populated with games from `games-list.csv` on first run.
|
||||||
|
|
||||||
@@ -146,10 +160,32 @@ The backend will run on http://localhost:5000
|
|||||||
npm run dev
|
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
|
## 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
|
### Branding and Metadata
|
||||||
|
|
||||||
All app branding, metadata, and PWA configuration is centralized in `frontend/src/config/branding.js`. Edit this file to customize:
|
All app branding, metadata, and PWA configuration is centralized in `frontend/src/config/branding.js`. Edit this file to customize:
|
||||||
@@ -174,7 +210,32 @@ cd frontend
|
|||||||
npm run generate-manifest
|
npm run generate-manifest
|
||||||
```
|
```
|
||||||
|
|
||||||
The manifest is automatically generated during the build process, so you don't need to edit it directly.
|
### 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
|
## Project Structure
|
||||||
|
|
||||||
@@ -183,19 +244,28 @@ The manifest is automatically generated during the build process, so you don't n
|
|||||||
├── backend/
|
├── backend/
|
||||||
│ ├── routes/ # API route handlers
|
│ ├── routes/ # API route handlers
|
||||||
│ │ ├── auth.js # Authentication endpoints
|
│ │ ├── auth.js # Authentication endpoints
|
||||||
│ │ ├── games.js # Game CRUD and management
|
│ │ ├── games.js # Game CRUD, packs, favor bias
|
||||||
│ │ ├── sessions.js # Session management
|
│ │ ├── sessions.js # Sessions, archives, notes, room codes, export
|
||||||
│ │ ├── picker.js # Game picker algorithm
|
│ │ ├── picker.js # Game picker algorithm
|
||||||
│ │ ├── stats.js # Statistics endpoints
|
│ │ ├── stats.js # Statistics endpoints
|
||||||
│ │ ├── votes.js # Live voting endpoint
|
│ │ ├── votes.js # Live voting endpoint
|
||||||
│ │ └── webhooks.js # Webhook management
|
│ │ └── webhooks.js # Webhook management
|
||||||
│ ├── middleware/ # Express middleware
|
│ ├── middleware/ # Express middleware
|
||||||
│ │ └── auth.js # JWT authentication
|
│ │ ├── auth.js # JWT authentication (required)
|
||||||
│ ├── utils/ # Utility functions
|
│ │ └── optional-auth.js # JWT authentication (optional, for public routes)
|
||||||
│ │ └── webhooks.js # Webhook trigger and signature
|
│ ├── config/ # Configuration files
|
||||||
│ ├── database.js # SQLite database setup
|
│ │ ├── admins.example.json # Example named admins config
|
||||||
│ ├── bootstrap.js # Database initialization
|
│ │ ├── admins.json # Named admins (gitignored)
|
||||||
│ ├── server.js # Express app entry point
|
│ │ └── 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
|
│ ├── package.json
|
||||||
│ └── Dockerfile
|
│ └── Dockerfile
|
||||||
├── frontend/
|
├── frontend/
|
||||||
@@ -205,9 +275,25 @@ The manifest is automatically generated during the build process, so you don't n
|
|||||||
│ │ │ ├── Login.jsx
|
│ │ │ ├── Login.jsx
|
||||||
│ │ │ ├── Picker.jsx
|
│ │ │ ├── Picker.jsx
|
||||||
│ │ │ ├── Manager.jsx
|
│ │ │ ├── Manager.jsx
|
||||||
│ │ │ └── History.jsx
|
│ │ │ ├── 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
|
│ │ ├── context/ # React context providers
|
||||||
│ │ │ └── AuthContext.jsx
|
│ │ │ ├── AuthContext.jsx
|
||||||
|
│ │ │ └── ThemeContext.jsx
|
||||||
|
│ │ ├── hooks/ # Custom React hooks
|
||||||
|
│ │ │ └── usePresence.js
|
||||||
|
│ │ ├── config/ # Frontend configuration
|
||||||
|
│ │ │ └── branding.js
|
||||||
│ │ ├── api/ # API client
|
│ │ ├── api/ # API client
|
||||||
│ │ │ └── axios.js
|
│ │ │ └── axios.js
|
||||||
│ │ ├── App.jsx
|
│ │ ├── App.jsx
|
||||||
@@ -217,48 +303,80 @@ The manifest is automatically generated during the build process, so you don't n
|
|||||||
│ ├── vite.config.js
|
│ ├── vite.config.js
|
||||||
│ ├── tailwind.config.js
|
│ ├── tailwind.config.js
|
||||||
│ ├── package.json
|
│ ├── package.json
|
||||||
│ ├── nginx.conf # Nginx config for Docker
|
│ ├── nginx.conf # Nginx config for Docker (proxy + SPA)
|
||||||
│ └── Dockerfile
|
│ └── 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
|
├── docker-compose.yml
|
||||||
├── games-list.csv # Initial game data
|
├── jest.config.js
|
||||||
|
├── games-list.csv # Initial game seed data
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
|
### Health
|
||||||
|
- `GET /health` - Health check (returns `{ status: "ok" }`)
|
||||||
|
|
||||||
### Authentication
|
### 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
|
- `POST /api/auth/verify` - Verify JWT token
|
||||||
|
|
||||||
### Games
|
### Games
|
||||||
- `GET /api/games` - List all games (with filters)
|
- `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
|
- `GET /api/games/:id` - Get single game
|
||||||
- `POST /api/games` - Create game (admin)
|
- `POST /api/games` - Create game (admin)
|
||||||
- `PUT /api/games/:id` - Update game (admin)
|
- `PUT /api/games/:id` - Update game (admin)
|
||||||
- `DELETE /api/games/:id` - Delete game (admin)
|
- `DELETE /api/games/:id` - Delete game (admin)
|
||||||
- `PATCH /api/games/:id/toggle` - Toggle game enabled status (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/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)
|
- `GET /api/games/export/csv` - Export games to CSV (admin)
|
||||||
- `POST /api/games/import/csv` - Import games from CSV (admin)
|
- `POST /api/games/import/csv` - Import games from CSV (admin)
|
||||||
|
|
||||||
### Sessions
|
### 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/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` - Create new session (admin)
|
||||||
|
- `POST /api/sessions/bulk` - Bulk create sessions (admin)
|
||||||
- `POST /api/sessions/:id/close` - Close session (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
|
- `GET /api/sessions/:id/games` - Get games in session
|
||||||
- `POST /api/sessions/:id/games` - Add game to session (admin)
|
- `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)
|
- `POST /api/sessions/:id/chat-import` - Import chat log (admin)
|
||||||
|
- `GET /api/sessions/:id/export` - Export session data (admin)
|
||||||
|
|
||||||
### Game Picker
|
### Game Picker
|
||||||
- `POST /api/pick` - Pick random game with filters
|
- `POST /api/pick` - Pick random game with filters and favor bias weighting
|
||||||
|
|
||||||
### Statistics
|
### Statistics
|
||||||
- `GET /api/stats` - Get overall statistics
|
- `GET /api/stats` - Get overall statistics
|
||||||
|
|
||||||
### Live Votes
|
### Votes
|
||||||
|
- `GET /api/votes` - Paginated vote history with filtering
|
||||||
- `POST /api/votes/live` - Submit real-time vote (admin)
|
- `POST /api/votes/live` - Submit real-time vote (admin)
|
||||||
|
|
||||||
### Webhooks
|
### Webhooks
|
||||||
@@ -270,6 +388,9 @@ The manifest is automatically generated during the build process, so you don't n
|
|||||||
- `POST /api/webhooks/test/:id` - Test webhook (admin)
|
- `POST /api/webhooks/test/:id` - Test webhook (admin)
|
||||||
- `GET /api/webhooks/:id/logs` - Get webhook logs (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
|
## Usage Guide
|
||||||
|
|
||||||
### Starting a Game Session
|
### Starting a Game Session
|
||||||
@@ -290,9 +411,10 @@ The manifest is automatically generated during the build process, so you don't n
|
|||||||
2. Navigate to the Manager page
|
2. Navigate to the Manager page
|
||||||
3. View statistics and pack information
|
3. View statistics and pack information
|
||||||
4. Toggle individual games or entire packs on/off
|
4. Toggle individual games or entire packs on/off
|
||||||
5. Add new games with the "+ Add Game" button
|
5. Adjust favor bias to weight certain games or packs in the picker
|
||||||
6. Edit or delete existing games
|
6. Add new games with the "+ Add Game" button
|
||||||
7. Import/Export games via CSV
|
7. Edit or delete existing games
|
||||||
|
8. Import/Export games via CSV
|
||||||
|
|
||||||
### Closing a Session and Importing Chat Logs
|
### Closing a Session and Importing Chat Logs
|
||||||
|
|
||||||
@@ -318,6 +440,10 @@ The manifest is automatically generated during the build process, so you don't n
|
|||||||
6. Click "Close Session" to finalize
|
6. Click "Close Session" to finalize
|
||||||
7. Add optional notes about the session
|
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
|
## Chat Log Format
|
||||||
|
|
||||||
The chat import feature expects a JSON array where each message has:
|
The chat import feature expects a JSON array where each message has:
|
||||||
@@ -328,12 +454,12 @@ The chat import feature expects a JSON array where each message has:
|
|||||||
The system will:
|
The system will:
|
||||||
1. Parse each message for vote patterns
|
1. Parse each message for vote patterns
|
||||||
2. Match the timestamp to the game being played at that time
|
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
|
4. Store the chat log in the database
|
||||||
|
|
||||||
## Bot Integration
|
## Bot Integration
|
||||||
|
|
||||||
For integrating external bots (e.g., for live voting and game notifications), see **[BOT_INTEGRATION.md](docs/BOT_INTEGRATION.md)** for detailed documentation including:
|
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
|
- Live voting API usage
|
||||||
- **WebSocket integration (recommended)** for real-time game notifications
|
- **WebSocket integration (recommended)** for real-time game notifications
|
||||||
@@ -370,10 +496,10 @@ go run get-player-count.go JYET
|
|||||||
- Extracts actual player count from lobby state
|
- Extracts actual player count from lobby state
|
||||||
|
|
||||||
These tools retrieve:
|
These tools retrieve:
|
||||||
- ✅ Actual player count (not just max capacity)
|
- Actual player count (not just max capacity)
|
||||||
- ✅ List of current players and their roles (host/player)
|
- List of current players and their roles (host/player)
|
||||||
- ✅ Game state and lobby status
|
- Game state and lobby status
|
||||||
- ✅ Audience count
|
- Audience count
|
||||||
|
|
||||||
**Note:** Direct WebSocket connection is not possible without authentication, so the tools join through jackbox.tv to capture the data.
|
**Note:** Direct WebSocket connection is not possible without authentication, so the tools join through jackbox.tv to capture the data.
|
||||||
|
|
||||||
@@ -382,13 +508,17 @@ These tools retrieve:
|
|||||||
### games
|
### games
|
||||||
- id, pack_name, title, min_players, max_players, length_minutes
|
- id, pack_name, title, min_players, max_players, length_minutes
|
||||||
- has_audience, family_friendly, game_type, secondary_type
|
- has_audience, family_friendly, game_type, secondary_type
|
||||||
- play_count, popularity_score, upvotes, downvotes, enabled, created_at
|
- play_count, popularity_score, upvotes, downvotes, favor_bias, enabled, created_at
|
||||||
|
|
||||||
|
### packs
|
||||||
|
- id, name (unique), favor_bias, created_at
|
||||||
|
|
||||||
### sessions
|
### sessions
|
||||||
- id, created_at, closed_at, is_active, notes
|
- id, created_at, closed_at, is_active, notes, archived
|
||||||
|
|
||||||
### session_games
|
### session_games
|
||||||
- id, session_id, game_id, played_at, manually_added, status
|
- id, session_id, game_id, played_at, manually_added, status
|
||||||
|
- room_code, player_count, player_count_check_status
|
||||||
|
|
||||||
### chat_logs
|
### chat_logs
|
||||||
- id, session_id, chatter_name, message, timestamp, parsed_vote, message_hash
|
- id, session_id, chatter_name, message, timestamp, parsed_vote, message_hash
|
||||||
@@ -418,8 +548,10 @@ The picker uses the following logic:
|
|||||||
- Exclude those games from selection pool
|
- Exclude those games from selection pool
|
||||||
- This prevents immediate repeats and alternating patterns
|
- This prevents immediate repeats and alternating patterns
|
||||||
|
|
||||||
3. **Random selection**:
|
3. **Weighted random selection**:
|
||||||
- Pick a random game from remaining eligible games
|
- 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
|
- Return game details and pool size
|
||||||
|
|
||||||
## Docker Deployment
|
## Docker Deployment
|
||||||
@@ -442,36 +574,28 @@ docker-compose down
|
|||||||
docker-compose up -d --build
|
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
|
### 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
|
## Troubleshooting
|
||||||
|
|
||||||
### Database not initializing
|
### Database not initializing
|
||||||
- Ensure `games-list.csv` is in the root directory
|
- Ensure `games-list.csv` is in the root directory
|
||||||
- Check backend logs: `docker-compose logs backend`
|
- 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
|
### 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
|
- 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
|
- Try restarting the backend service
|
||||||
|
|
||||||
### Frontend can't reach backend
|
### Frontend can't reach backend
|
||||||
- Verify both containers are running: `docker-compose ps`
|
- Verify both containers are running: `docker-compose ps`
|
||||||
- Check network connectivity: `docker-compose logs frontend`
|
- Check network connectivity: `docker-compose logs frontend`
|
||||||
- Ensure nginx.conf proxy settings are correct
|
- Ensure nginx.conf proxy settings are correct
|
||||||
|
- For local dev, confirm the Vite proxy target matches your backend URL
|
||||||
|
|
||||||
### Games not showing up
|
### Games not showing up
|
||||||
- Check if games are enabled in the Manager
|
- Check if games are enabled in the Manager
|
||||||
@@ -485,4 +609,3 @@ MIT
|
|||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Feel free to submit issues and pull requests!
|
Feel free to submit issues and pull requests!
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
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
|
// Session games table
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS session_games (
|
CREATE TABLE IF NOT EXISTS session_games (
|
||||||
|
|||||||
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,24 +5,26 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"better-sqlite3": "^12.8.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"better-sqlite3": "^9.2.2",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"dotenv": "^16.3.1",
|
|
||||||
"csv-parse": "^5.5.3",
|
"csv-parse": "^5.5.3",
|
||||||
"csv-stringify": "^6.4.5",
|
"csv-stringify": "^6.4.5",
|
||||||
"ws": "^8.14.0",
|
"dotenv": "^16.3.1",
|
||||||
"puppeteer": "^24.0.0"
|
"express": "^4.18.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"ws": "^8.14.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2"
|
"jest": "^29.7.0",
|
||||||
|
"nodemon": "^3.0.2",
|
||||||
|
"supertest": "^6.3.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { JWT_SECRET, authenticateToken } = require('../middleware/auth');
|
const { JWT_SECRET, authenticateToken } = require('../middleware/auth');
|
||||||
|
const { findAdminByKey } = require('../config/load-admins');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
if (!process.env.ADMIN_KEY) {
|
|
||||||
throw new Error('ADMIN_KEY environment variable is required');
|
|
||||||
}
|
|
||||||
const ADMIN_KEY = process.env.ADMIN_KEY;
|
|
||||||
|
|
||||||
// Login with admin key
|
|
||||||
router.post('/login', (req, res) => {
|
router.post('/login', (req, res) => {
|
||||||
const { key } = req.body;
|
const { key } = req.body;
|
||||||
|
|
||||||
@@ -17,26 +12,30 @@ router.post('/login', (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Admin key is required' });
|
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' });
|
return res.status(401).json({ error: 'Invalid admin key' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token
|
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ role: 'admin', timestamp: Date.now() },
|
{ role: 'admin', name: admin.name, timestamp: Date.now() },
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: '24h' }
|
{ expiresIn: '24h' }
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
token,
|
token,
|
||||||
|
name: admin.name,
|
||||||
message: 'Authentication successful',
|
message: 'Authentication successful',
|
||||||
expiresIn: '24h'
|
expiresIn: '24h'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify token validity
|
|
||||||
router.post('/verify', authenticateToken, (req, res) => {
|
router.post('/verify', authenticateToken, (req, res) => {
|
||||||
|
if (!req.user.name) {
|
||||||
|
return res.status(403).json({ error: 'Token missing admin identity, please re-login' });
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
valid: true,
|
valid: true,
|
||||||
user: req.user
|
user: req.user
|
||||||
@@ -44,4 +43,3 @@ router.post('/verify', authenticateToken, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ const { authenticateToken } = require('../middleware/auth');
|
|||||||
const db = require('../database');
|
const db = require('../database');
|
||||||
const { triggerWebhook } = require('../utils/webhooks');
|
const { triggerWebhook } = require('../utils/webhooks');
|
||||||
const { getWebSocketManager } = require('../utils/websocket-manager');
|
const { getWebSocketManager } = require('../utils/websocket-manager');
|
||||||
const { stopPlayerCountCheck } = require('../utils/player-count-checker');
|
const { startMonitor, stopMonitor, getMonitorSnapshot } = require('../utils/ecast-shard-client');
|
||||||
const { startRoomMonitor, stopRoomMonitor } = require('../utils/room-monitor');
|
const { computeNotesPreview } = require('../utils/notes-preview');
|
||||||
|
const { optionalAuthenticateToken } = require('../middleware/optional-auth');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -20,17 +21,79 @@ function createMessageHash(username, message, timestamp) {
|
|||||||
// Get all sessions
|
// Get all sessions
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
try {
|
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(`
|
const sessions = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
s.*,
|
s.id,
|
||||||
|
s.created_at,
|
||||||
|
s.closed_at,
|
||||||
|
s.is_active,
|
||||||
|
s.archived,
|
||||||
|
s.notes,
|
||||||
COUNT(sg.id) as games_played
|
COUNT(sg.id) as games_played
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
LEFT JOIN session_games sg ON s.id = sg.session_id
|
LEFT JOIN session_games sg ON s.id = sg.session_id
|
||||||
|
${whereClause}
|
||||||
GROUP BY s.id
|
GROUP BY s.id
|
||||||
ORDER BY s.created_at DESC
|
ORDER BY s.created_at DESC
|
||||||
|
${limitClause}
|
||||||
|
${offsetClause}
|
||||||
`).all();
|
`).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) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
@@ -62,7 +125,7 @@ router.get('/active', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get single session by ID
|
// Get single session by ID
|
||||||
router.get('/:id', (req, res) => {
|
router.get('/:id', optionalAuthenticateToken, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const session = db.prepare(`
|
const session = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -78,7 +141,14 @@ router.get('/:id', (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Session not found' });
|
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) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
@@ -134,6 +204,62 @@ router.post('/', authenticateToken, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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)
|
// Close/finalize session (admin only)
|
||||||
router.post('/:id/close', authenticateToken, (req, res) => {
|
router.post('/:id/close', authenticateToken, (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -228,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
|
// Get games played in a session
|
||||||
router.get('/:id/games', (req, res) => {
|
router.get('/:id/games', (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -254,6 +458,40 @@ router.get('/:id/games', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get vote breakdown for a session
|
||||||
|
router.get('/:id/votes', (req, res) => {
|
||||||
|
try {
|
||||||
|
const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(req.params.id);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return res.status(404).json({ error: 'Session not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const votes = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
lv.game_id,
|
||||||
|
g.title,
|
||||||
|
g.pack_name,
|
||||||
|
SUM(CASE WHEN lv.vote_type = 1 THEN 1 ELSE 0 END) AS upvotes,
|
||||||
|
SUM(CASE WHEN lv.vote_type = -1 THEN 1 ELSE 0 END) AS downvotes,
|
||||||
|
SUM(lv.vote_type) AS net_score,
|
||||||
|
COUNT(*) AS total_votes
|
||||||
|
FROM live_votes lv
|
||||||
|
JOIN games g ON lv.game_id = g.id
|
||||||
|
WHERE lv.session_id = ?
|
||||||
|
GROUP BY lv.game_id
|
||||||
|
ORDER BY net_score DESC
|
||||||
|
`).all(req.params.id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
session_id: parseInt(req.params.id),
|
||||||
|
votes,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Add game to session (admin only)
|
// Add game to session (admin only)
|
||||||
router.post('/:id/games', authenticateToken, (req, res) => {
|
router.post('/:id/games', authenticateToken, (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -281,6 +519,14 @@ router.post('/:id/games', authenticateToken, (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Game not found' });
|
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)
|
// Set all current 'playing' games to 'played' (except skipped ones)
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE session_games
|
UPDATE session_games
|
||||||
@@ -360,7 +606,7 @@ router.post('/:id/games', authenticateToken, (req, res) => {
|
|||||||
// Automatically start room monitoring if room code was provided
|
// Automatically start room monitoring if room code was provided
|
||||||
if (room_code) {
|
if (room_code) {
|
||||||
try {
|
try {
|
||||||
startRoomMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players);
|
startMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error starting room monitor:', error);
|
console.error('Error starting room monitor:', error);
|
||||||
}
|
}
|
||||||
@@ -557,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' });
|
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') {
|
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(`
|
db.prepare(`
|
||||||
UPDATE session_games
|
UPDATE session_games
|
||||||
SET status = CASE
|
SET status = CASE
|
||||||
@@ -583,8 +838,7 @@ router.patch('/:sessionId/games/:gameId/status', authenticateToken, (req, res) =
|
|||||||
// Stop room monitor and player count check if game is no longer playing
|
// Stop room monitor and player count check if game is no longer playing
|
||||||
if (status !== 'playing') {
|
if (status !== 'playing') {
|
||||||
try {
|
try {
|
||||||
stopRoomMonitor(sessionId, gameId);
|
stopMonitor(sessionId, gameId);
|
||||||
stopPlayerCountCheck(sessionId, gameId);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error stopping room monitor/player count check:', error);
|
console.error('Error stopping room monitor/player count check:', error);
|
||||||
}
|
}
|
||||||
@@ -603,8 +857,7 @@ router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => {
|
|||||||
|
|
||||||
// Stop room monitor and player count check before deleting
|
// Stop room monitor and player count check before deleting
|
||||||
try {
|
try {
|
||||||
stopRoomMonitor(sessionId, gameId);
|
stopMonitor(sessionId, gameId);
|
||||||
stopPlayerCountCheck(sessionId, gameId);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error stopping room monitor/player count check:', error);
|
console.error('Error stopping room monitor/player count check:', error);
|
||||||
}
|
}
|
||||||
@@ -807,6 +1060,55 @@ 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)
|
// Start player count check for a session game (admin only)
|
||||||
router.post('/:sessionId/games/:gameId/start-player-check', authenticateToken, (req, res) => {
|
router.post('/:sessionId/games/:gameId/start-player-check', authenticateToken, (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -829,7 +1131,7 @@ router.post('/:sessionId/games/:gameId/start-player-check', authenticateToken, (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start room monitoring (will hand off to player count check when game starts)
|
// Start room monitoring (will hand off to player count check when game starts)
|
||||||
startRoomMonitor(sessionId, gameId, game.room_code, game.max_players);
|
startMonitor(sessionId, gameId, game.room_code, game.max_players);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Room monitor started',
|
message: 'Room monitor started',
|
||||||
@@ -846,8 +1148,7 @@ router.post('/:sessionId/games/:gameId/stop-player-check', authenticateToken, (r
|
|||||||
const { sessionId, gameId } = req.params;
|
const { sessionId, gameId } = req.params;
|
||||||
|
|
||||||
// Stop both room monitor and player count check
|
// Stop both room monitor and player count check
|
||||||
stopRoomMonitor(sessionId, gameId);
|
stopMonitor(sessionId, gameId);
|
||||||
stopPlayerCountCheck(sessionId, gameId);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Room monitor and player count check stopped',
|
message: 'Room monitor and player count check stopped',
|
||||||
|
|||||||
@@ -1,9 +1,91 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const db = require('../database');
|
const db = require('../database');
|
||||||
|
const { getWebSocketManager } = require('../utils/websocket-manager');
|
||||||
|
|
||||||
const router = express.Router();
|
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
|
// Live vote endpoint - receives real-time votes from bot
|
||||||
router.post('/live', authenticateToken, (req, res) => {
|
router.post('/live', authenticateToken, (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -43,7 +125,7 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
|
|
||||||
// Get all games played in this session with timestamps
|
// Get all games played in this session with timestamps
|
||||||
const sessionGames = db.prepare(`
|
const sessionGames = db.prepare(`
|
||||||
SELECT sg.game_id, sg.played_at, g.title, g.upvotes, g.downvotes, g.popularity_score
|
SELECT sg.game_id, sg.played_at, g.title, g.pack_name, g.upvotes, g.downvotes, g.popularity_score
|
||||||
FROM session_games sg
|
FROM session_games sg
|
||||||
JOIN games g ON sg.game_id = g.id
|
JOIN games g ON sg.game_id = g.id
|
||||||
WHERE sg.session_id = ?
|
WHERE sg.session_id = ?
|
||||||
@@ -149,6 +231,16 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
|
|
||||||
processVote();
|
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
|
// Get updated game stats
|
||||||
const updatedGame = db.prepare(`
|
const updatedGame = db.prepare(`
|
||||||
SELECT id, title, upvotes, downvotes, popularity_score
|
SELECT id, title, upvotes, downvotes, popularity_score
|
||||||
@@ -156,6 +248,33 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).get(matchedGame.game_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
|
// Get session stats
|
||||||
const sessionStats = db.prepare(`
|
const sessionStats = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const http = require('http');
|
|||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const { bootstrapGames } = require('./bootstrap');
|
const { bootstrapGames } = require('./bootstrap');
|
||||||
const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager');
|
const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager');
|
||||||
|
const { cleanupAllShards } = require('./utils/ecast-shard-client');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 5000;
|
const PORT = process.env.PORT || 5000;
|
||||||
@@ -12,9 +13,6 @@ const PORT = process.env.PORT || 5000;
|
|||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Bootstrap database with games
|
|
||||||
bootstrapGames();
|
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', message: 'Jackbox Game Picker API is running' });
|
res.json({ status: 'ok', message: 'Jackbox Game Picker API is running' });
|
||||||
@@ -50,8 +48,21 @@ const server = http.createServer(app);
|
|||||||
const wsManager = new WebSocketManager(server);
|
const wsManager = new WebSocketManager(server);
|
||||||
setWebSocketManager(wsManager);
|
setWebSocketManager(wsManager);
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
bootstrapGames();
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Server is running on port ${PORT}`);
|
console.log(`Server is running on port ${PORT}`);
|
||||||
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
|
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 };
|
||||||
|
|||||||
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 };
|
||||||
@@ -39,4 +39,37 @@ async function checkRoomStatus(roomCode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { checkRoomStatus };
|
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 };
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
const puppeteer = require('puppeteer');
|
|
||||||
const db = require('../database');
|
|
||||||
const { getWebSocketManager } = require('./websocket-manager');
|
|
||||||
const { checkRoomStatus } = require('./jackbox-api');
|
|
||||||
|
|
||||||
// Store active check jobs
|
|
||||||
const activeChecks = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Watch a game from start to finish as audience member
|
|
||||||
* Collects analytics throughout the entire game lifecycle
|
|
||||||
*/
|
|
||||||
async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
|
|
||||||
let browser;
|
|
||||||
const checkKey = `${sessionId}-${gameId}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`[Player Count] Opening audience connection for ${checkKey} (max: ${maxPlayers})`);
|
|
||||||
|
|
||||||
browser = await puppeteer.launch({
|
|
||||||
headless: 'new',
|
|
||||||
args: [
|
|
||||||
'--no-sandbox',
|
|
||||||
'--disable-setuid-sandbox',
|
|
||||||
'--disable-dev-shm-usage',
|
|
||||||
'--disable-accelerated-2d-canvas',
|
|
||||||
'--no-first-run',
|
|
||||||
'--no-zygote',
|
|
||||||
'--disable-gpu'
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
const page = await browser.newPage();
|
|
||||||
await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
|
||||||
|
|
||||||
// Track all player counts we've seen
|
|
||||||
const seenPlayerCounts = new Set();
|
|
||||||
let bestPlayerCount = null;
|
|
||||||
let startPlayerCount = null; // Authoritative count from 'start' action
|
|
||||||
let gameEnded = false;
|
|
||||||
let audienceJoined = false;
|
|
||||||
let frameCount = 0;
|
|
||||||
|
|
||||||
// Enable CDP and listen for WebSocket frames BEFORE navigating
|
|
||||||
const client = await page.target().createCDPSession();
|
|
||||||
await client.send('Network.enable');
|
|
||||||
|
|
||||||
client.on('Network.webSocketFrameReceived', ({ response }) => {
|
|
||||||
if (response.payloadData && !gameEnded) {
|
|
||||||
frameCount++;
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(response.payloadData);
|
|
||||||
|
|
||||||
if (process.env.DEBUG && frameCount % 10 === 0) {
|
|
||||||
console.log(`[Frame ${frameCount}] opcode: ${data.opcode}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for bc:room with player count data
|
|
||||||
let roomVal = null;
|
|
||||||
|
|
||||||
if (data.opcode === 'client/welcome' && data.result?.entities?.['bc:room']) {
|
|
||||||
roomVal = data.result.entities['bc:room'][1]?.val;
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
console.log(`[Frame ${frameCount}] Found bc:room in client/welcome`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// First client/welcome means Jackbox accepted our audience join
|
|
||||||
if (!audienceJoined) {
|
|
||||||
audienceJoined = true;
|
|
||||||
console.log(`[Audience] Successfully joined room ${roomCode} as audience`);
|
|
||||||
|
|
||||||
const wsManager = getWebSocketManager();
|
|
||||||
if (wsManager) {
|
|
||||||
wsManager.broadcastEvent('audience.joined', {
|
|
||||||
sessionId,
|
|
||||||
gameId,
|
|
||||||
roomCode
|
|
||||||
}, parseInt(sessionId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.opcode === 'object' && data.result?.key === 'bc:room') {
|
|
||||||
roomVal = data.result.val;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (roomVal) {
|
|
||||||
// Check if game has ended
|
|
||||||
if (roomVal.gameResults?.players) {
|
|
||||||
const finalCount = roomVal.gameResults.players.length;
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
console.log(`[Frame ${frameCount}] 🎉 GAME ENDED - Final count: ${finalCount} players`);
|
|
||||||
|
|
||||||
if (startPlayerCount !== null && startPlayerCount !== finalCount) {
|
|
||||||
console.log(`[Frame ${frameCount}] ⚠️ WARNING: Start count (${startPlayerCount}) != Final count (${finalCount})`);
|
|
||||||
} else if (startPlayerCount !== null) {
|
|
||||||
console.log(`[Frame ${frameCount}] ✓ Verified: Start count matches final count (${finalCount})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bestPlayerCount = finalCount;
|
|
||||||
gameEnded = true;
|
|
||||||
updatePlayerCount(sessionId, gameId, finalCount, 'completed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract player counts from analytics (game in progress)
|
|
||||||
if (roomVal.analytics && Array.isArray(roomVal.analytics)) {
|
|
||||||
for (const analytic of roomVal.analytics) {
|
|
||||||
if (analytic.action === 'start' && analytic.value && typeof analytic.value === 'number') {
|
|
||||||
if (startPlayerCount === null) {
|
|
||||||
startPlayerCount = analytic.value;
|
|
||||||
bestPlayerCount = analytic.value;
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
console.log(`[Frame ${frameCount}] 🎯 Found 'start' action: ${analytic.value} players (authoritative)`);
|
|
||||||
}
|
|
||||||
updatePlayerCount(sessionId, gameId, startPlayerCount, 'checking');
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startPlayerCount !== null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (analytic.value && typeof analytic.value === 'number' && analytic.value > 0 && analytic.value <= 100) {
|
|
||||||
seenPlayerCounts.add(analytic.value);
|
|
||||||
|
|
||||||
const clampedValue = Math.min(analytic.value, maxPlayers);
|
|
||||||
|
|
||||||
if (bestPlayerCount === null || clampedValue > bestPlayerCount) {
|
|
||||||
bestPlayerCount = clampedValue;
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
if (analytic.value > maxPlayers) {
|
|
||||||
console.log(`[Frame ${frameCount}] 📊 Found player count ${analytic.value} in action '${analytic.action}' (clamped to ${clampedValue})`);
|
|
||||||
} else {
|
|
||||||
console.log(`[Frame ${frameCount}] 📊 Found player count ${analytic.value} in action '${analytic.action}' (best so far)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'checking');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if room is no longer locked (game ended another way)
|
|
||||||
if (roomVal.locked === false && bestPlayerCount !== null) {
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
console.log(`[Frame ${frameCount}] Room unlocked, game likely ended. Final count: ${bestPlayerCount}`);
|
|
||||||
}
|
|
||||||
gameEnded = true;
|
|
||||||
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (process.env.DEBUG && frameCount % 50 === 0) {
|
|
||||||
console.log(`[Frame ${frameCount}] Parse error:`, e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Navigate and join audience
|
|
||||||
if (process.env.DEBUG) console.log('[Audience] Navigating to jackbox.tv...');
|
|
||||||
await page.goto('https://jackbox.tv/', { waitUntil: 'networkidle2', timeout: 30000 });
|
|
||||||
|
|
||||||
if (process.env.DEBUG) console.log('[Audience] Waiting for form...');
|
|
||||||
await page.waitForSelector('input#roomcode', { timeout: 10000 });
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
sessionStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (process.env.DEBUG) console.log('[Audience] Typing room code:', roomCode);
|
|
||||||
const roomInput = await page.$('input#roomcode');
|
|
||||||
await roomInput.type(roomCode.toUpperCase(), { delay: 50 });
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
|
|
||||||
if (process.env.DEBUG) console.log('[Audience] Typing name...');
|
|
||||||
const nameInput = await page.$('input#username');
|
|
||||||
await nameInput.type('CountBot', { delay: 30 });
|
|
||||||
|
|
||||||
if (process.env.DEBUG) console.log('[Audience] Waiting for JOIN AUDIENCE button...');
|
|
||||||
await page.waitForFunction(() => {
|
|
||||||
const buttons = Array.from(document.querySelectorAll('button'));
|
|
||||||
return buttons.some(b => b.textContent.toUpperCase().includes('JOIN AUDIENCE') && !b.disabled);
|
|
||||||
}, { timeout: 10000 });
|
|
||||||
|
|
||||||
if (process.env.DEBUG) console.log('[Audience] Clicking JOIN AUDIENCE...');
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const buttons = Array.from(document.querySelectorAll('button'));
|
|
||||||
const btn = buttons.find(b => b.textContent.toUpperCase().includes('JOIN AUDIENCE') && !b.disabled);
|
|
||||||
if (btn) btn.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (process.env.DEBUG) console.log('[Audience] 👀 Watching game... (will monitor until game ends)');
|
|
||||||
|
|
||||||
// Keep watching until game ends or we're told to stop
|
|
||||||
const checkInterval = setInterval(async () => {
|
|
||||||
const game = db.prepare(`
|
|
||||||
SELECT status, player_count_check_status
|
|
||||||
FROM session_games
|
|
||||||
WHERE session_id = ? AND id = ?
|
|
||||||
`).get(sessionId, gameId);
|
|
||||||
|
|
||||||
if (!game || game.status === 'skipped' || game.status === 'played' || game.player_count_check_status === 'stopped') {
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
console.log(`[Audience] Stopping watch - game status changed`);
|
|
||||||
}
|
|
||||||
clearInterval(checkInterval);
|
|
||||||
gameEnded = true;
|
|
||||||
if (browser) await browser.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gameEnded) {
|
|
||||||
clearInterval(checkInterval);
|
|
||||||
if (browser) await browser.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomStatus = await checkRoomStatus(roomCode);
|
|
||||||
if (!roomStatus.exists) {
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
console.log(`[Audience] Room no longer exists - game ended`);
|
|
||||||
}
|
|
||||||
gameEnded = true;
|
|
||||||
clearInterval(checkInterval);
|
|
||||||
if (bestPlayerCount !== null) {
|
|
||||||
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
|
|
||||||
} else {
|
|
||||||
updatePlayerCount(sessionId, gameId, null, 'failed');
|
|
||||||
}
|
|
||||||
if (browser) await browser.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
const check = activeChecks.get(checkKey);
|
|
||||||
if (check) {
|
|
||||||
check.watchInterval = checkInterval;
|
|
||||||
check.browser = browser;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Audience] Error watching game:', error.message);
|
|
||||||
if (browser) {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
if (bestPlayerCount !== null) {
|
|
||||||
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
|
|
||||||
} else {
|
|
||||||
updatePlayerCount(sessionId, gameId, null, 'failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update player count in database and broadcast via WebSocket
|
|
||||||
*/
|
|
||||||
function updatePlayerCount(sessionId, gameId, playerCount, status) {
|
|
||||||
try {
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE session_games
|
|
||||||
SET player_count = ?, player_count_check_status = ?
|
|
||||||
WHERE session_id = ? AND id = ?
|
|
||||||
`).run(playerCount, status, sessionId, gameId);
|
|
||||||
|
|
||||||
const wsManager = getWebSocketManager();
|
|
||||||
if (wsManager) {
|
|
||||||
wsManager.broadcastEvent('player-count.updated', {
|
|
||||||
sessionId,
|
|
||||||
gameId,
|
|
||||||
playerCount,
|
|
||||||
status
|
|
||||||
}, parseInt(sessionId));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Player Count] Updated game ${gameId}: ${playerCount} players (${status})`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Player Count] Failed to update database:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start player count checking for a game.
|
|
||||||
* Called by room-monitor once the game is confirmed started (room locked).
|
|
||||||
* Goes straight to joining the audience — no polling needed.
|
|
||||||
*/
|
|
||||||
async function startPlayerCountCheck(sessionId, gameId, roomCode, maxPlayers = 8) {
|
|
||||||
const checkKey = `${sessionId}-${gameId}`;
|
|
||||||
|
|
||||||
if (activeChecks.has(checkKey)) {
|
|
||||||
console.log(`[Player Count] Already checking ${checkKey}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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') {
|
|
||||||
console.log(`[Player Count] Check already completed for ${checkKey}, skipping`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (game && game.player_count_check_status === 'failed') {
|
|
||||||
console.log(`[Player Count] Retrying failed check for ${checkKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Player Count] Starting audience watch for game ${gameId} (room ${roomCode}, max ${maxPlayers})`);
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE session_games
|
|
||||||
SET player_count_check_status = 'checking'
|
|
||||||
WHERE session_id = ? AND id = ?
|
|
||||||
`).run(sessionId, gameId);
|
|
||||||
|
|
||||||
activeChecks.set(checkKey, {
|
|
||||||
sessionId,
|
|
||||||
gameId,
|
|
||||||
roomCode,
|
|
||||||
watchInterval: null,
|
|
||||||
browser: null
|
|
||||||
});
|
|
||||||
|
|
||||||
await watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop checking player count for a game
|
|
||||||
*/
|
|
||||||
async function stopPlayerCountCheck(sessionId, gameId) {
|
|
||||||
const checkKey = `${sessionId}-${gameId}`;
|
|
||||||
const check = activeChecks.get(checkKey);
|
|
||||||
|
|
||||||
if (check) {
|
|
||||||
if (check.watchInterval) {
|
|
||||||
clearInterval(check.watchInterval);
|
|
||||||
}
|
|
||||||
if (check.browser) {
|
|
||||||
try {
|
|
||||||
await check.browser.close();
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore errors closing browser
|
|
||||||
}
|
|
||||||
}
|
|
||||||
activeChecks.delete(checkKey);
|
|
||||||
|
|
||||||
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 = 'stopped'
|
|
||||||
WHERE session_id = ? AND id = ?
|
|
||||||
`).run(sessionId, gameId);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Player Count] Stopped check for ${checkKey}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up all active checks (for graceful shutdown)
|
|
||||||
*/
|
|
||||||
async function cleanupAllChecks() {
|
|
||||||
for (const [, check] of activeChecks.entries()) {
|
|
||||||
if (check.watchInterval) {
|
|
||||||
clearInterval(check.watchInterval);
|
|
||||||
}
|
|
||||||
if (check.browser) {
|
|
||||||
try {
|
|
||||||
await check.browser.close();
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
activeChecks.clear();
|
|
||||||
console.log('[Player Count] Cleaned up all active checks');
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
startPlayerCountCheck,
|
|
||||||
stopPlayerCountCheck,
|
|
||||||
cleanupAllChecks
|
|
||||||
};
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
const db = require('../database');
|
|
||||||
const { getWebSocketManager } = require('./websocket-manager');
|
|
||||||
const { checkRoomStatus } = require('./jackbox-api');
|
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 10000;
|
|
||||||
|
|
||||||
// Active room monitors, keyed by "{sessionId}-{gameId}"
|
|
||||||
const activeMonitors = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcast game.started event when room becomes locked
|
|
||||||
*/
|
|
||||||
function broadcastGameStarted(sessionId, gameId, roomCode, maxPlayers) {
|
|
||||||
try {
|
|
||||||
const wsManager = getWebSocketManager();
|
|
||||||
if (wsManager) {
|
|
||||||
wsManager.broadcastEvent('game.started', {
|
|
||||||
sessionId,
|
|
||||||
gameId,
|
|
||||||
roomCode,
|
|
||||||
maxPlayers
|
|
||||||
}, parseInt(sessionId));
|
|
||||||
}
|
|
||||||
console.log(`[Room Monitor] Broadcasted game.started for room ${roomCode} (max: ${maxPlayers})`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Room Monitor] Failed to broadcast game.started:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start monitoring a Jackbox room for game start (locked state).
|
|
||||||
*
|
|
||||||
* Polls the Jackbox REST API every 10 seconds. When the room becomes
|
|
||||||
* locked, broadcasts a game.started WebSocket event and then hands off
|
|
||||||
* to the player-count-checker to join as audience.
|
|
||||||
*/
|
|
||||||
async function startRoomMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
|
|
||||||
const monitorKey = `${sessionId}-${gameId}`;
|
|
||||||
|
|
||||||
if (activeMonitors.has(monitorKey)) {
|
|
||||||
console.log(`[Room Monitor] Already monitoring ${monitorKey}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Room Monitor] Starting monitor for room ${roomCode} (${monitorKey})`);
|
|
||||||
|
|
||||||
const onGameStarted = (realMaxPlayers) => {
|
|
||||||
broadcastGameStarted(sessionId, gameId, roomCode, realMaxPlayers);
|
|
||||||
// Lazy require breaks the circular dependency with player-count-checker
|
|
||||||
const { startPlayerCountCheck } = require('./player-count-checker');
|
|
||||||
console.log(`[Room Monitor] Room ${roomCode} locked — handing off to player count checker`);
|
|
||||||
startPlayerCountCheck(sessionId, gameId, roomCode, realMaxPlayers);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pollRoom = async () => {
|
|
||||||
const game = db.prepare(`
|
|
||||||
SELECT status FROM session_games
|
|
||||||
WHERE session_id = ? AND id = ?
|
|
||||||
`).get(sessionId, gameId);
|
|
||||||
|
|
||||||
if (!game || game.status === 'skipped' || game.status === 'played') {
|
|
||||||
console.log(`[Room Monitor] Stopping — game status changed for ${monitorKey}`);
|
|
||||||
stopRoomMonitor(sessionId, gameId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomStatus = await checkRoomStatus(roomCode);
|
|
||||||
|
|
||||||
if (!roomStatus.exists) {
|
|
||||||
console.log(`[Room Monitor] Room ${roomCode} does not exist — stopping`);
|
|
||||||
stopRoomMonitor(sessionId, gameId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (roomStatus.locked) {
|
|
||||||
stopRoomMonitor(sessionId, gameId);
|
|
||||||
onGameStarted(roomStatus.maxPlayers);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (roomStatus.full) {
|
|
||||||
console.log(`[Room Monitor] Room ${roomCode} is full but not locked yet — waiting`);
|
|
||||||
} else {
|
|
||||||
console.log(`[Room Monitor] Room ${roomCode} lobby still open — waiting`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Poll immediately, then every POLL_INTERVAL_MS
|
|
||||||
activeMonitors.set(monitorKey, {
|
|
||||||
sessionId,
|
|
||||||
gameId,
|
|
||||||
roomCode,
|
|
||||||
interval: null
|
|
||||||
});
|
|
||||||
|
|
||||||
await pollRoom();
|
|
||||||
|
|
||||||
// If the monitor was already stopped (room locked or gone on first check), bail
|
|
||||||
if (!activeMonitors.has(monitorKey)) return;
|
|
||||||
|
|
||||||
const interval = setInterval(() => pollRoom(), POLL_INTERVAL_MS);
|
|
||||||
const monitor = activeMonitors.get(monitorKey);
|
|
||||||
if (monitor) monitor.interval = interval;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop monitoring a room
|
|
||||||
*/
|
|
||||||
function stopRoomMonitor(sessionId, gameId) {
|
|
||||||
const monitorKey = `${sessionId}-${gameId}`;
|
|
||||||
const monitor = activeMonitors.get(monitorKey);
|
|
||||||
|
|
||||||
if (monitor) {
|
|
||||||
if (monitor.interval) clearInterval(monitor.interval);
|
|
||||||
activeMonitors.delete(monitorKey);
|
|
||||||
console.log(`[Room Monitor] Stopped monitor for ${monitorKey}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up all active monitors (for graceful shutdown)
|
|
||||||
*/
|
|
||||||
function cleanupAllMonitors() {
|
|
||||||
for (const [, monitor] of activeMonitors.entries()) {
|
|
||||||
if (monitor.interval) clearInterval(monitor.interval);
|
|
||||||
}
|
|
||||||
activeMonitors.clear();
|
|
||||||
console.log('[Room Monitor] Cleaned up all active monitors');
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
startRoomMonitor,
|
|
||||||
stopRoomMonitor,
|
|
||||||
cleanupAllMonitors
|
|
||||||
};
|
|
||||||
@@ -32,6 +32,8 @@ class WebSocketManager {
|
|||||||
const clientInfo = {
|
const clientInfo = {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
userId: null,
|
userId: null,
|
||||||
|
adminName: null,
|
||||||
|
currentPage: null,
|
||||||
subscribedSessions: new Set(),
|
subscribedSessions: new Set(),
|
||||||
lastPing: Date.now()
|
lastPing: Date.now()
|
||||||
};
|
};
|
||||||
@@ -97,6 +99,15 @@ class WebSocketManager {
|
|||||||
this.send(ws, { type: 'pong' });
|
this.send(ws, { type: 'pong' });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'page_focus':
|
||||||
|
if (!clientInfo.authenticated) {
|
||||||
|
this.sendError(ws, 'Not authenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clientInfo.currentPage = message.page || null;
|
||||||
|
this.broadcastPresence();
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
this.sendError(ws, `Unknown message type: ${message.type}`);
|
this.sendError(ws, `Unknown message type: ${message.type}`);
|
||||||
}
|
}
|
||||||
@@ -117,7 +128,13 @@ class WebSocketManager {
|
|||||||
|
|
||||||
if (clientInfo) {
|
if (clientInfo) {
|
||||||
clientInfo.authenticated = true;
|
clientInfo.authenticated = true;
|
||||||
clientInfo.userId = decoded.role; // 'admin' for now
|
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, {
|
this.send(ws, {
|
||||||
type: 'auth_success',
|
type: 'auth_success',
|
||||||
@@ -283,9 +300,31 @@ class WebSocketManager {
|
|||||||
|
|
||||||
this.clients.delete(ws);
|
this.clients.delete(ws);
|
||||||
console.log('[WebSocket] Client disconnected and cleaned up');
|
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
|
* Start heartbeat to detect dead connections
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DB_PATH=/app/data/jackbox.db
|
- DB_PATH=/app/data/jackbox.db
|
||||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||||
- ADMIN_KEY=${ADMIN_KEY:?ADMIN_KEY is required}
|
- ADMIN_KEY=${ADMIN_KEY:-}
|
||||||
|
- ADMIN_CONFIG_PATH=${ADMIN_CONFIG_PATH:-}
|
||||||
- DEBUG=false
|
- DEBUG=false
|
||||||
volumes:
|
volumes:
|
||||||
- jackbox-data:/app/data
|
- jackbox-data:/app/data
|
||||||
- ./games-list.csv:/app/games-list.csv:ro
|
- ./games-list.csv:/app/games-list.csv:ro
|
||||||
|
# - ./backend/config/admins.json:/app/config/admins.json:ro
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ All REST endpoints are prefixed with `/api/` except `/health`.
|
|||||||
- `GET /api/sessions/active`
|
- `GET /api/sessions/active`
|
||||||
- `GET /api/sessions/{id}`
|
- `GET /api/sessions/{id}`
|
||||||
- `GET /api/sessions/{id}/games`
|
- `GET /api/sessions/{id}/games`
|
||||||
|
- `GET /api/sessions/{id}/votes`
|
||||||
|
- `GET /api/sessions/{sessionId}/games/{sessionGameId}/status-live`
|
||||||
|
- `GET /api/votes`
|
||||||
- `GET /api/stats`
|
- `GET /api/stats`
|
||||||
- `POST /api/pick`
|
- `POST /api/pick`
|
||||||
- `GET /health`
|
- `GET /health`
|
||||||
@@ -82,7 +85,7 @@ SQLite stores booleans as integers (0/1). In request bodies, pass JavaScript boo
|
|||||||
|
|
||||||
## Pagination
|
## Pagination
|
||||||
|
|
||||||
No pagination. All list endpoints return full result sets.
|
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
|
## Quick Reference
|
||||||
|
|
||||||
@@ -128,6 +131,7 @@ No pagination. All list endpoints return full result sets.
|
|||||||
| DELETE | `/api/sessions/{id}` | Yes | Delete a session |
|
| DELETE | `/api/sessions/{id}` | Yes | Delete a session |
|
||||||
| POST | `/api/sessions/{id}/close` | Yes | Close 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}/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}/games` | Yes | Add a game to a session |
|
||||||
| POST | `/api/sessions/{id}/chat-import` | Yes | Import chat log for vote processing |
|
| POST | `/api/sessions/{id}/chat-import` | Yes | Import chat log for vote processing |
|
||||||
| GET | `/api/sessions/{id}/export` | Yes | Export session |
|
| GET | `/api/sessions/{id}/export` | Yes | Export session |
|
||||||
@@ -136,6 +140,7 @@ No pagination. All list endpoints return full result sets.
|
|||||||
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/room-code` | Yes | Update room code for session game |
|
| 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}/start-player-check` | Yes | Start room monitor for player count |
|
||||||
| POST | `/api/sessions/{sessionId}/games/{sessionGameId}/stop-player-check` | Yes | Stop room monitor |
|
| 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 |
|
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/player-count` | Yes | Update player count for session game |
|
||||||
|
|
||||||
### Picker
|
### Picker
|
||||||
@@ -154,6 +159,7 @@ No pagination. All list endpoints return full result sets.
|
|||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|--------|------|------|-------------|
|
|--------|------|------|-------------|
|
||||||
|
| GET | `/api/votes` | No | Paginated vote history with filtering |
|
||||||
| POST | `/api/votes/live` | Yes | Record a live vote (up/down) |
|
| POST | `/api/votes/live` | Yes | Record a live vote (up/down) |
|
||||||
|
|
||||||
### Webhooks
|
### Webhooks
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ Sessions represent a gaming night. Only one session can be active at a time. Gam
|
|||||||
| POST | `/api/sessions/{id}/close` | Bearer | Close a session |
|
| POST | `/api/sessions/{id}/close` | Bearer | Close a session |
|
||||||
| DELETE | `/api/sessions/{id}` | Bearer | Delete a closed session |
|
| DELETE | `/api/sessions/{id}` | Bearer | Delete a closed session |
|
||||||
| GET | `/api/sessions/{id}/games` | No | List games in 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` | Bearer | Add a game to a session |
|
| POST | `/api/sessions/{id}/games` | Bearer | Add a game to a session |
|
||||||
| POST | `/api/sessions/{id}/chat-import` | Bearer | Import chat log for vote processing |
|
| POST | `/api/sessions/{id}/chat-import` | Bearer | Import chat log for vote processing |
|
||||||
| GET | `/api/sessions/{id}/export` | Bearer | Export session (JSON or TXT) |
|
| GET | `/api/sessions/{id}/export` | Bearer | Export session (JSON or TXT) |
|
||||||
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/status` | Bearer | Update session game status |
|
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/status` | Bearer | Update session game status |
|
||||||
| DELETE | `/api/sessions/{sessionId}/games/{sessionGameId}` | Bearer | Remove game from session |
|
| DELETE | `/api/sessions/{sessionId}/games/{sessionGameId}` | Bearer | Remove game from session |
|
||||||
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/room-code` | Bearer | Update room code for session game |
|
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/room-code` | Bearer | Update room code for session game |
|
||||||
|
| GET | `/api/sessions/{sessionId}/games/{sessionGameId}/status-live` | No | Get live game status from shard monitor |
|
||||||
| POST | `/api/sessions/{sessionId}/games/{sessionGameId}/start-player-check` | Bearer | Start room monitor |
|
| POST | `/api/sessions/{sessionId}/games/{sessionGameId}/start-player-check` | Bearer | Start room monitor |
|
||||||
| POST | `/api/sessions/{sessionId}/games/{sessionGameId}/stop-player-check` | Bearer | Stop room monitor |
|
| POST | `/api/sessions/{sessionId}/games/{sessionGameId}/stop-player-check` | Bearer | Stop room monitor |
|
||||||
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/player-count` | Bearer | Update player count for session game |
|
| PATCH | `/api/sessions/{sessionId}/games/{sessionGameId}/player-count` | Bearer | Update player count for session game |
|
||||||
@@ -369,6 +371,57 @@ curl "http://localhost:5000/api/sessions/5/games"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## GET /api/sessions/{id}/votes
|
||||||
|
|
||||||
|
Get per-game vote breakdown for a session. Aggregates votes from the `live_votes` table by game. Results ordered by `net_score` DESC.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| id | integer | Session ID |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
**200 OK**
|
||||||
|
|
||||||
|
```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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns 200 with an empty `votes` array when the session has no votes.
|
||||||
|
|
||||||
|
### Error Responses
|
||||||
|
|
||||||
|
| Status | Body | When |
|
||||||
|
|--------|------|------|
|
||||||
|
| 404 | `{ "error": "Session not found" }` | Invalid session ID |
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:5000/api/sessions/5/votes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## POST /api/sessions/{id}/games
|
## POST /api/sessions/{id}/games
|
||||||
|
|
||||||
Add a game to a session. Side effects: increments game `play_count`, sets previous `playing` games to `played` (skipped games stay skipped), triggers `game.added` webhook and WebSocket event, and auto-starts room monitor if `room_code` is provided.
|
Add a game to a session. Side effects: increments game `play_count`, sets previous `playing` games to `played` (skipped games stay skipped), triggers `game.added` webhook and WebSocket event, and auto-starts room monitor if `room_code` is provided.
|
||||||
@@ -780,6 +833,82 @@ curl -o session-5.txt "http://localhost:5000/api/sessions/5/export" \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## GET /api/sessions/{sessionId}/games/{sessionGameId}/status-live
|
||||||
|
|
||||||
|
Get the live game status from an active shard monitor. If no monitor is running, falls back to data from the database. No authentication required.
|
||||||
|
|
||||||
|
The same data is broadcast every 20 seconds via the `game.status` WebSocket event to subscribed clients.
|
||||||
|
|
||||||
|
**Note:** `sessionGameId` is the `session_games.id` row ID, NOT `games.id`.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
None required.
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| sessionId | integer | Session ID |
|
||||||
|
| sessionGameId | integer | Session game ID (`session_games.id`) |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
**200 OK** — Live shard data (when monitor is active):
|
||||||
|
|
||||||
|
```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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**200 OK** — DB fallback (when no monitor is active):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessionId": 1,
|
||||||
|
"gameId": 5,
|
||||||
|
"roomCode": "LSBN",
|
||||||
|
"appTag": null,
|
||||||
|
"maxPlayers": 8,
|
||||||
|
"playerCount": 4,
|
||||||
|
"players": [],
|
||||||
|
"lobbyState": null,
|
||||||
|
"gameState": null,
|
||||||
|
"gameStarted": false,
|
||||||
|
"gameFinished": true,
|
||||||
|
"monitoring": false,
|
||||||
|
"title": "Drawful 2",
|
||||||
|
"packName": "Jackbox Party Pack 8",
|
||||||
|
"status": "completed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Responses
|
||||||
|
|
||||||
|
| Status | Body | When |
|
||||||
|
|--------|------|------|
|
||||||
|
| 404 | `{ "error": "Session game not found" }` | Invalid sessionId or sessionGameId |
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:5000/api/sessions/5/games/14/status-live"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## POST /api/sessions/{sessionId}/games/{sessionGameId}/start-player-check
|
## POST /api/sessions/{sessionId}/games/{sessionGameId}/start-player-check
|
||||||
|
|
||||||
Start the room monitor for a session game. The game must have a room code.
|
Start the room monitor for a session game. The game must have a room code.
|
||||||
|
|||||||
@@ -6,10 +6,69 @@ Real-time popularity voting. Bots or integrations send votes during live gaming
|
|||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|--------|------|------|-------------|
|
|--------|------|------|-------------|
|
||||||
|
| GET | `/api/votes` | None | Paginated vote history with filtering |
|
||||||
| POST | `/api/votes/live` | Bearer | Submit a live vote (up/down) |
|
| 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
|
## POST /api/votes/live
|
||||||
|
|
||||||
Submit a real-time up/down vote for the game currently being played. Automatically finds the active session and matches the vote to the correct game using the provided timestamp and session game intervals.
|
Submit a real-time up/down vote 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.
|
||||||
@@ -40,6 +99,7 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
|||||||
- Matches the vote timestamp to the game being played at that time (uses interval between consecutive `played_at` timestamps).
|
- 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.
|
- 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).
|
- **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
|
### Response
|
||||||
|
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ curl -X DELETE "http://localhost:5000/api/sessions/5" \
|
|||||||
|
|
||||||
## 9. Real-time Updates via WebSocket
|
## 9. Real-time Updates via WebSocket
|
||||||
|
|
||||||
The API provides real-time updates over WebSocket for session events: `session.started`, `game.added`, `session.ended`, and `player-count.updated`. Connect to `/api/sessions/live`, authenticate with your JWT, and subscribe to session IDs to receive these events without polling.
|
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).
|
For connection setup, message types, and event payloads, see [WebSocket Protocol](../websocket.md).
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,17 @@ A bot sends individual votes during the stream. Each vote is processed immediate
|
|||||||
|
|
||||||
See [Votes live endpoint](../endpoints/votes.md#post-apivoteslive).
|
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
|
## 4. Timestamp Matching Explained
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ The WebSocket server runs at `/api/sessions/live` on the same host and port as t
|
|||||||
| `game.added` | Session subscribers | `POST /api/sessions/{id}/games` |
|
| `game.added` | Session subscribers | `POST /api/sessions/{id}/games` |
|
||||||
| `session.ended` | Session subscribers | `POST /api/sessions/{id}/close` |
|
| `session.ended` | Session subscribers | `POST /api/sessions/{id}/close` |
|
||||||
| `player-count.updated` | Session subscribers | `PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count` |
|
| `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 }`.
|
`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 }`.
|
||||||
|
|
||||||
@@ -157,7 +158,7 @@ All events use this envelope:
|
|||||||
|---------|----------|-----------|
|
|---------|----------|-----------|
|
||||||
| **Connection** | Stateless HTTP | Persistent |
|
| **Connection** | Stateless HTTP | Persistent |
|
||||||
| **Auth** | Secret in config | JWT per connection |
|
| **Auth** | Secret in config | JWT per connection |
|
||||||
| **Events** | `game.added` | `session.started`, `game.added`, `session.ended`, `player-count.updated` |
|
| **Events** | `game.added` | `session.started`, `game.added`, `session.ended`, `player-count.updated`, `vote.received` |
|
||||||
| **Latency** | Higher (HTTP round trip) | Lower (push) |
|
| **Latency** | Higher (HTTP round trip) | Lower (push) |
|
||||||
| **Reliability** | Logged, auditable | Best-effort |
|
| **Reliability** | Logged, auditable | Best-effort |
|
||||||
|
|
||||||
|
|||||||
@@ -979,6 +979,46 @@ paths:
|
|||||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||||
"403": { $ref: "#/components/responses/Forbidden" }
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/api/sessions/{id}/votes:
|
||||||
|
get:
|
||||||
|
operationId: getSessionVotes
|
||||||
|
summary: Get per-game vote breakdown for a session
|
||||||
|
tags: [Sessions]
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer }
|
||||||
|
description: Session ID
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Per-game vote aggregates for the session
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [session_id, votes]
|
||||||
|
properties:
|
||||||
|
session_id:
|
||||||
|
type: integer
|
||||||
|
votes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
game_id: { type: integer }
|
||||||
|
title: { type: string }
|
||||||
|
pack_name: { type: string }
|
||||||
|
upvotes: { type: integer }
|
||||||
|
downvotes: { type: integer }
|
||||||
|
net_score: { type: integer }
|
||||||
|
total_votes: { type: integer }
|
||||||
|
"404":
|
||||||
|
description: Session not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/Error" }
|
||||||
|
|
||||||
/api/sessions/{id}/chat-import:
|
/api/sessions/{id}/chat-import:
|
||||||
post:
|
post:
|
||||||
operationId: importSessionChat
|
operationId: importSessionChat
|
||||||
@@ -1399,6 +1439,72 @@ paths:
|
|||||||
upvotes: { type: integer }
|
upvotes: { type: integer }
|
||||||
downvotes: { type: integer }
|
downvotes: { type: integer }
|
||||||
|
|
||||||
|
/api/votes:
|
||||||
|
get:
|
||||||
|
operationId: listVotes
|
||||||
|
summary: Paginated vote history with filtering
|
||||||
|
tags: [Votes]
|
||||||
|
parameters:
|
||||||
|
- name: session_id
|
||||||
|
in: query
|
||||||
|
schema: { type: integer }
|
||||||
|
description: Filter by session
|
||||||
|
- name: game_id
|
||||||
|
in: query
|
||||||
|
schema: { type: integer }
|
||||||
|
description: Filter by game
|
||||||
|
- name: username
|
||||||
|
in: query
|
||||||
|
schema: { type: string }
|
||||||
|
description: Filter by voter
|
||||||
|
- name: vote_type
|
||||||
|
in: query
|
||||||
|
schema: { type: string, enum: [up, down] }
|
||||||
|
description: Filter by direction
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
schema: { type: integer, default: 1 }
|
||||||
|
description: Page number
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema: { type: integer, default: 50, maximum: 100 }
|
||||||
|
description: Results per page (max 100)
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Paginated vote records
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [votes, pagination]
|
||||||
|
properties:
|
||||||
|
votes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer }
|
||||||
|
session_id: { type: integer }
|
||||||
|
game_id: { type: integer }
|
||||||
|
game_title: { type: string }
|
||||||
|
pack_name: { type: string }
|
||||||
|
username: { type: string }
|
||||||
|
vote_type: { type: string, enum: [up, down] }
|
||||||
|
timestamp: { type: string, format: date-time }
|
||||||
|
created_at: { type: string, format: date-time }
|
||||||
|
pagination:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
page: { type: integer }
|
||||||
|
limit: { type: integer }
|
||||||
|
total: { type: integer }
|
||||||
|
total_pages: { type: integer }
|
||||||
|
"400":
|
||||||
|
description: Invalid filter parameter
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/Error" }
|
||||||
|
|
||||||
/api/votes/live:
|
/api/votes/live:
|
||||||
post:
|
post:
|
||||||
operationId: recordLiveVote
|
operationId: recordLiveVote
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
The WebSocket API provides real-time updates for Jackbox gaming sessions. Use it to:
|
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
|
- Receive notifications when sessions start, end, or when games are added
|
||||||
- Track player counts as they are updated
|
- 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
|
- 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.
|
The WebSocket server runs on the same host and port as the HTTP API. Connect to `/api/sessions/live` to establish a live connection.
|
||||||
@@ -128,7 +130,15 @@ Must be authenticated.
|
|||||||
| `session.started` | New session created (broadcast to all authenticated clients) |
|
| `session.started` | New session created (broadcast to all authenticated clients) |
|
||||||
| `game.added` | Game added to a session (broadcast to subscribers) |
|
| `game.added` | Game added to a session (broadcast to subscribers) |
|
||||||
| `session.ended` | Session closed (broadcast to subscribers) |
|
| `session.ended` | Session closed (broadcast to subscribers) |
|
||||||
| `player-count.updated` | Player count changed (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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -202,10 +212,140 @@ All server-sent events use this envelope:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
### player-count.updated
|
||||||
|
|
||||||
- **Broadcast to:** Clients subscribed to the session
|
- **Broadcast to:** Clients subscribed to the session
|
||||||
- **Triggered by:** `PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count`
|
- **Triggered by:** `PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count` (manual override only)
|
||||||
|
|
||||||
**Data:**
|
**Data:**
|
||||||
```json
|
```json
|
||||||
@@ -217,6 +357,33 @@ All server-sent events use this envelope:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
## 7. Error Handling
|
||||||
@@ -369,10 +536,42 @@ ws.onmessage = (event) => {
|
|||||||
subscribedSessions.delete(msg.data.session.id);
|
subscribedSessions.delete(msg.data.session.id);
|
||||||
break;
|
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':
|
case 'player-count.updated':
|
||||||
console.log('Player count:', msg.data.playerCount, 'for game', msg.data.gameId);
|
console.log('Player count:', msg.data.playerCount, 'for game', msg.data.gameId);
|
||||||
break;
|
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 'error':
|
||||||
case 'auth_error':
|
case 'auth_error':
|
||||||
console.error('Error:', msg.message);
|
console.error('Error:', msg.message);
|
||||||
|
|||||||
@@ -85,13 +85,53 @@ ws://localhost:5000/api/sessions/live
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Pong
|
||||||
{ "type": "pong" }
|
{ "type": "pong" }
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Live Voting
|
## 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
|
### Submit Live Vote
|
||||||
|
|
||||||
|
|||||||
@@ -696,8 +696,9 @@ curl -X GET "http://localhost:5000/api/webhooks/1/logs" \
|
|||||||
- `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`.
|
- `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.
|
- `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.
|
- `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` and `session.ended` events for it.
|
> **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)
|
### Event Lifecycle (for a game with room code)
|
||||||
|
|
||||||
@@ -711,7 +712,7 @@ When a game is added with a room code, events fire in this order:
|
|||||||
|
|
||||||
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.
|
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 (e.g., `vote.recorded`).
|
More events may be added in the future.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
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
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)
|
||||||
1316
frontend/package-lock.json
generated
1316
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,12 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"axios": "^1.6.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.20.1",
|
"react-markdown": "^10.1.0",
|
||||||
"axios": "^1.6.2"
|
"react-router-dom": "^6.20.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
@@ -26,4 +28,3 @@
|
|||||||
"generate-manifest": "node generate-manifest.js"
|
"generate-manifest": "node generate-manifest.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import Logo from './components/Logo';
|
|||||||
import ThemeToggle from './components/ThemeToggle';
|
import ThemeToggle from './components/ThemeToggle';
|
||||||
import InstallPrompt from './components/InstallPrompt';
|
import InstallPrompt from './components/InstallPrompt';
|
||||||
import SafariInstallPrompt from './components/SafariInstallPrompt';
|
import SafariInstallPrompt from './components/SafariInstallPrompt';
|
||||||
|
import PresenceBar from './components/PresenceBar';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Picker from './pages/Picker';
|
import Picker from './pages/Picker';
|
||||||
import Manager from './pages/Manager';
|
import Manager from './pages/Manager';
|
||||||
import History from './pages/History';
|
import History from './pages/History';
|
||||||
|
import SessionDetail from './pages/SessionDetail';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isAuthenticated, logout } = useAuth();
|
const { isAuthenticated, logout } = useAuth();
|
||||||
@@ -155,12 +157,16 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Admin Presence */}
|
||||||
|
<PresenceBar />
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="container mx-auto px-4 py-8 flex-grow">
|
<main className="container mx-auto px-4 py-8 flex-grow">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/history" element={<History />} />
|
<Route path="/history" element={<History />} />
|
||||||
|
<Route path="/history/:id" element={<SessionDetail />} />
|
||||||
<Route path="/picker" element={<Picker />} />
|
<Route path="/picker" element={<Picker />} />
|
||||||
<Route path="/manager" element={<Manager />} />
|
<Route path="/manager" element={<Manager />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
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;
|
||||||
@@ -2,7 +2,7 @@ export const branding = {
|
|||||||
app: {
|
app: {
|
||||||
name: 'HSO Jackbox Game Picker',
|
name: 'HSO Jackbox Game Picker',
|
||||||
shortName: 'Jackbox Game Picker',
|
shortName: 'Jackbox Game Picker',
|
||||||
version: '0.5.1 - Thode Goes Wild Edition',
|
version: '0.6.4 - Fish Tank Edition',
|
||||||
description: 'Spicing up Hyper Spaceout game nights!',
|
description: 'Spicing up Hyper Spaceout game nights!',
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
@@ -11,7 +11,7 @@ export const branding = {
|
|||||||
themeColor: '#4F46E5', // Indigo-600
|
themeColor: '#4F46E5', // Indigo-600
|
||||||
},
|
},
|
||||||
links: {
|
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
|
support: 'cottongin@cottongin.xyz', // Optional: Add support/contact URL
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useState, useContext, useEffect } from 'react';
|
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { migratePreferences } from '../utils/adminPrefs';
|
||||||
|
|
||||||
const AuthContext = createContext();
|
const AuthContext = createContext();
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ export const useAuth = () => {
|
|||||||
|
|
||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [token, setToken] = useState(localStorage.getItem('adminToken'));
|
const [token, setToken] = useState(localStorage.getItem('adminToken'));
|
||||||
|
const [adminName, setAdminName] = useState(localStorage.getItem('adminName'));
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -20,10 +22,17 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const verifyToken = async () => {
|
const verifyToken = async () => {
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/auth/verify', {}, {
|
const response = await axios.post('/api/auth/verify', {}, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
|
const name = response.data.user?.name;
|
||||||
|
if (name) {
|
||||||
|
setAdminName(name);
|
||||||
|
localStorage.setItem('adminName', name);
|
||||||
|
} else {
|
||||||
|
logout();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Token verification failed:', error);
|
console.error('Token verification failed:', error);
|
||||||
logout();
|
logout();
|
||||||
@@ -38,10 +47,13 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const login = async (key) => {
|
const login = async (key) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/api/auth/login', { key });
|
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('adminToken', newToken);
|
||||||
|
localStorage.setItem('adminName', name);
|
||||||
setToken(newToken);
|
setToken(newToken);
|
||||||
|
setAdminName(name);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
|
migratePreferences(name);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -53,12 +65,15 @@ export const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('adminToken');
|
localStorage.removeItem('adminToken');
|
||||||
|
localStorage.removeItem('adminName');
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
setAdminName(null);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
token,
|
token,
|
||||||
|
adminName,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
loading,
|
loading,
|
||||||
login,
|
login,
|
||||||
@@ -67,4 +82,3 @@ export const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
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 };
|
||||||
|
}
|
||||||
@@ -1,119 +1,89 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import api from '../api/axios';
|
import api from '../api/axios';
|
||||||
import { formatLocalDateTime, formatLocalDate, formatLocalTime } from '../utils/dateUtils';
|
import { formatDayHeader, formatTimeOnly, getLocalDateKey, isSunday } from '../utils/dateUtils';
|
||||||
import PopularityBadge from '../components/PopularityBadge';
|
import { prefixKey } from '../utils/adminPrefs';
|
||||||
|
|
||||||
function History() {
|
function History() {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated, adminName } = useAuth();
|
||||||
const { error, success } = useToast();
|
const { error, success } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [sessions, setSessions] = useState([]);
|
const [sessions, setSessions] = useState([]);
|
||||||
const [selectedSession, setSelectedSession] = useState(null);
|
|
||||||
const [sessionGames, setSessionGames] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showChatImport, setShowChatImport] = useState(false);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [absoluteTotal, setAbsoluteTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [prevLastDate, setPrevLastDate] = useState(null);
|
||||||
const [closingSession, setClosingSession] = useState(null);
|
const [closingSession, setClosingSession] = useState(null);
|
||||||
const [showAllSessions, setShowAllSessions] = useState(false);
|
|
||||||
const [deletingSession, setDeletingSession] = useState(null);
|
const [filter, setFilter] = useState(() => localStorage.getItem(prefixKey(adminName, 'history-filter')) || 'default');
|
||||||
|
const [limit, setLimit] = useState(() => localStorage.getItem(prefixKey(adminName, 'history-show-limit')) || '5');
|
||||||
|
|
||||||
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
||||||
|
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
|
const longPressTimer = useRef(null);
|
||||||
|
const longPressFired = useRef(false);
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/sessions');
|
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 }
|
||||||
|
});
|
||||||
setSessions(response.data);
|
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);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load sessions', err);
|
console.error('Failed to load sessions', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [filter, limit, page]);
|
||||||
|
|
||||||
const refreshSessionGames = useCallback(async (sessionId, silent = false) => {
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/sessions/${sessionId}/games`);
|
|
||||||
// Reverse chronological order (most recent first) - create new array to avoid mutation
|
|
||||||
setSessionGames([...response.data].reverse());
|
|
||||||
} catch (err) {
|
|
||||||
if (!silent) {
|
|
||||||
console.error('Failed to load session games', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSessions();
|
loadSessions();
|
||||||
}, [loadSessions]);
|
}, [loadSessions]);
|
||||||
|
|
||||||
// Auto-select active session if navigating from picker
|
|
||||||
useEffect(() => {
|
|
||||||
if (sessions.length > 0 && !selectedSession) {
|
|
||||||
const activeSession = sessions.find(s => s.is_active === 1);
|
|
||||||
if (activeSession) {
|
|
||||||
loadSessionGames(activeSession.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [sessions, selectedSession]);
|
|
||||||
|
|
||||||
// Poll for session list updates (to detect when sessions end/start)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
loadSessions();
|
loadSessions();
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadSessions]);
|
}, [loadSessions]);
|
||||||
|
|
||||||
// Poll for updates on active session games
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSession) return;
|
if (adminName) {
|
||||||
|
const savedFilter = localStorage.getItem(prefixKey(adminName, 'history-filter'));
|
||||||
const currentSession = sessions.find(s => s.id === selectedSession);
|
const savedLimit = localStorage.getItem(prefixKey(adminName, 'history-show-limit'));
|
||||||
if (!currentSession || currentSession.is_active !== 1) return;
|
if (savedFilter) setFilter(savedFilter);
|
||||||
|
if (savedLimit) setLimit(savedLimit);
|
||||||
// Refresh games every 3 seconds for active session
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
refreshSessionGames(selectedSession, true); // silent refresh
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [selectedSession, sessions, refreshSessionGames]);
|
|
||||||
|
|
||||||
const handleExport = async (sessionId, format) => {
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/sessions/${sessionId}/export?format=${format}`, {
|
|
||||||
responseType: 'blob'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create download link
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.setAttribute('download', `session-${sessionId}.${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) {
|
|
||||||
console.error('Failed to export session', err);
|
|
||||||
error('Failed to export session');
|
|
||||||
}
|
}
|
||||||
|
}, [adminName]);
|
||||||
|
|
||||||
|
const handleFilterChange = (newFilter) => {
|
||||||
|
setFilter(newFilter);
|
||||||
|
localStorage.setItem(prefixKey(adminName, 'history-filter'), newFilter);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSessionGames = async (sessionId, silent = false) => {
|
const handleLimitChange = (newLimit) => {
|
||||||
try {
|
setLimit(newLimit);
|
||||||
const response = await api.get(`/sessions/${sessionId}/games`);
|
localStorage.setItem(prefixKey(adminName, 'history-show-limit'), newLimit);
|
||||||
// Reverse chronological order (most recent first) - create new array to avoid mutation
|
setSelectedIds(new Set());
|
||||||
setSessionGames([...response.data].reverse());
|
setPage(1);
|
||||||
if (!silent) {
|
|
||||||
setSelectedSession(sessionId);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!silent) {
|
|
||||||
console.error('Failed to load session games', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseSession = async (sessionId, notes) => {
|
const handleCloseSession = async (sessionId, notes) => {
|
||||||
@@ -121,31 +91,82 @@ function History() {
|
|||||||
await api.post(`/sessions/${sessionId}/close`, { notes });
|
await api.post(`/sessions/${sessionId}/close`, { notes });
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
setClosingSession(null);
|
setClosingSession(null);
|
||||||
if (selectedSession === sessionId) {
|
|
||||||
// Reload the session details to show updated state
|
|
||||||
loadSessionGames(sessionId);
|
|
||||||
}
|
|
||||||
success('Session ended successfully');
|
success('Session ended successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error('Failed to close session');
|
error('Failed to close session');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteSession = async (sessionId) => {
|
// Multi-select handlers
|
||||||
try {
|
const toggleSelection = (sessionId) => {
|
||||||
await api.delete(`/sessions/${sessionId}`);
|
setSelectedIds(prev => {
|
||||||
await loadSessions();
|
const next = new Set(prev);
|
||||||
setDeletingSession(null);
|
if (next.has(sessionId)) {
|
||||||
if (selectedSession === sessionId) {
|
next.delete(sessionId);
|
||||||
setSelectedSession(null);
|
} else {
|
||||||
setSessionGames([]);
|
next.add(sessionId);
|
||||||
}
|
}
|
||||||
success('Session deleted successfully');
|
return next;
|
||||||
} catch (err) {
|
});
|
||||||
error('Failed to delete session: ' + (err.response?.data?.error || err.message));
|
};
|
||||||
|
|
||||||
|
const exitSelectMode = () => {
|
||||||
|
setSelectMode(false);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
setShowBulkDeleteConfirm(false);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = (sessionId) => {
|
||||||
|
if (!isAuthenticated || selectMode) return;
|
||||||
|
longPressFired.current = false;
|
||||||
|
longPressTimer.current = setTimeout(() => {
|
||||||
|
longPressFired.current = true;
|
||||||
|
setSelectMode(true);
|
||||||
|
setSelectedIds(new Set([sessionId]));
|
||||||
|
setPage(1);
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = () => {
|
||||||
|
if (longPressTimer.current) {
|
||||||
|
clearTimeout(longPressTimer.current);
|
||||||
|
longPressTimer.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBulkAction = async (action) => {
|
||||||
|
try {
|
||||||
|
await api.post('/sessions/bulk', {
|
||||||
|
action,
|
||||||
|
ids: Array.from(selectedIds)
|
||||||
|
});
|
||||||
|
success(`${selectedIds.size} session${selectedIds.size !== 1 ? 's' : ''} ${action}d`);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
setShowBulkDeleteConfirm(false);
|
||||||
|
await loadSessions();
|
||||||
|
} catch (err) {
|
||||||
|
error(err.response?.data?.error || `Failed to ${action} sessions`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
@@ -155,68 +176,180 @@ function History() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<h1 className="text-4xl font-bold mb-8 text-gray-800 dark:text-gray-100">Session History</h1>
|
<h1 className="text-4xl font-bold mb-8 text-gray-800 dark:text-gray-100">Session History</h1>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
|
||||||
{/* Sessions List */}
|
|
||||||
<div className="md:col-span-1">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||||
<div className="flex justify-between items-center mb-4">
|
{/* Controls Bar */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-100">Sessions</h2>
|
<div className="flex flex-wrap justify-between items-center gap-3 mb-4">
|
||||||
{sessions.length > 3 && (
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<div className="flex items-center gap-1.5">
|
||||||
onClick={() => setShowAllSessions(!showAllSessions)}
|
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Filter:</span>
|
||||||
className="text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition"
|
<select
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => handleFilterChange(e.target.value)}
|
||||||
|
className="px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 cursor-pointer"
|
||||||
>
|
>
|
||||||
{showAllSessions ? 'Show Recent' : `Show All (${sessions.length})`}
|
<option value="default">Sessions</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
<option value="all">All</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Show:</span>
|
||||||
|
<select
|
||||||
|
value={limit}
|
||||||
|
onChange={(e) => handleLimitChange(e.target.value)}
|
||||||
|
className="px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="5">5</option>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="all">All</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{sessions.length === absoluteTotal
|
||||||
|
? `${absoluteTotal} session${absoluteTotal !== 1 ? 's' : ''} total`
|
||||||
|
: `${sessions.length} visible (${absoluteTotal} total)`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
{isAuthenticated && (
|
||||||
|
<button
|
||||||
|
onClick={selectMode ? exitSelectMode : () => { setSelectMode(true); setPage(1); }}
|
||||||
|
className={`px-3 py-1.5 rounded text-sm font-medium transition ${
|
||||||
|
selectMode
|
||||||
|
? 'bg-indigo-600 dark:bg-indigo-700 text-white hover:bg-indigo-700 dark:hover:bg-indigo-800'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectMode ? '✓ Done' : 'Select'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session List */}
|
||||||
{sessions.length === 0 ? (
|
{sessions.length === 0 ? (
|
||||||
<p className="text-gray-500 dark:text-gray-400">No sessions found</p>
|
<p className="text-gray-500 dark:text-gray-400">No sessions found</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1 max-h-[600px] overflow-y-auto">
|
<div className="space-y-2">
|
||||||
{(showAllSessions ? sessions : sessions.slice(0, 3)).map(session => (
|
{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;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
className={`border rounded-lg transition ${
|
className={`border rounded-lg transition ${
|
||||||
selectedSession === session.id
|
selectMode && isActive
|
||||||
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/30'
|
? 'opacity-50 cursor-not-allowed border-gray-300 dark:border-gray-600'
|
||||||
: 'border-gray-300 dark:border-gray-600 hover:border-indigo-300 dark:hover:border-indigo-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}
|
||||||
>
|
>
|
||||||
{/* Main session info - clickable */}
|
<div className="p-4">
|
||||||
<div
|
<div className="flex items-start gap-3">
|
||||||
onClick={() => loadSessionGames(session.id)}
|
{selectMode && (
|
||||||
className="p-3 cursor-pointer"
|
<div className={`mt-0.5 w-5 h-5 flex-shrink-0 rounded border-2 flex items-center justify-center ${
|
||||||
>
|
isActive
|
||||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2">
|
? '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-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex justify-between items-center mb-1">
|
||||||
<span className="font-semibold text-sm text-gray-800 dark:text-gray-100">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-semibold text-gray-800 dark:text-gray-100">
|
||||||
Session #{session.id}
|
Session #{session.id}
|
||||||
</span>
|
</span>
|
||||||
{session.is_active === 1 && (
|
{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 flex-shrink-0">
|
<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
|
Active
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<div className="flex flex-wrap gap-x-2 text-xs text-gray-500 dark:text-gray-400">
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
<span>{formatLocalDate(session.created_at)}</span>
|
{session.games_played} game{session.games_played !== 1 ? 's' : ''}
|
||||||
<span>•</span>
|
</span>
|
||||||
<span>{session.games_played} game{session.games_played !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons for authenticated users */}
|
{!selectMode && isAuthenticated && isActive && (
|
||||||
{isAuthenticated && (
|
<div className="px-4 pb-4 pt-0">
|
||||||
<div className="px-3 pb-3 pt-0 flex gap-2">
|
|
||||||
{session.is_active === 1 ? (
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -226,194 +359,116 @@ function History() {
|
|||||||
>
|
>
|
||||||
End Session
|
End Session
|
||||||
</button>
|
</button>
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setDeletingSession(session.id);
|
|
||||||
}}
|
|
||||||
className="w-full bg-red-600 dark:bg-red-700 text-white px-4 py-2 rounded text-sm hover:bg-red-700 dark:hover:bg-red-800 transition"
|
|
||||||
>
|
|
||||||
Delete Session
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Session Details */}
|
{/* Pagination bar */}
|
||||||
<div className="md:col-span-2">
|
{limit !== 'all' && (() => {
|
||||||
{selectedSession ? (
|
const limitNum = parseInt(limit, 10);
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6">
|
const totalPages = Math.ceil(totalCount / limitNum);
|
||||||
<div className="flex flex-col gap-4 mb-6">
|
if (totalPages <= 1) return null;
|
||||||
<div className="flex-1">
|
return (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
|
<div className="flex justify-center items-center gap-4 py-3 mt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-xl sm:text-2xl font-semibold text-gray-800 dark:text-gray-100">
|
<button
|
||||||
Session #{selectedSession}
|
onClick={() => { setPage(p => p - 1); setSelectedIds(new Set()); }}
|
||||||
</h2>
|
disabled={page <= 1}
|
||||||
{sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
|
className={`px-4 py-1.5 rounded-md text-sm font-medium transition ${
|
||||||
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs sm:text-sm px-2 sm:px-3 py-1 rounded-full font-semibold animate-pulse inline-flex items-center gap-1 w-fit">
|
page <= 1
|
||||||
🟢 Active
|
? '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>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400">
|
|
||||||
{sessions.find(s => s.id === selectedSession)?.created_at &&
|
|
||||||
formatLocalDateTime(sessions.find(s => s.id === selectedSession).created_at)}
|
|
||||||
</p>
|
|
||||||
{sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
|
|
||||||
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-500 mt-1 italic">
|
|
||||||
Games update automatically
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
|
||||||
{isAuthenticated && sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowChatImport(true)}
|
onClick={() => { setPage(p => p + 1); setSelectedIds(new Set()); }}
|
||||||
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 sm:text-base w-full sm:w-auto"
|
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'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Import Chat Log
|
Next →
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
{isAuthenticated && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => handleExport(selectedSession, '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 sm:text-base w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
Export as Text
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleExport(selectedSession, '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 sm:text-base w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
Export as JSON
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{showChatImport && (
|
{/* Multi-select Action Bar */}
|
||||||
<ChatImportPanel
|
{selectMode && selectedIds.size > 0 && (
|
||||||
sessionId={selectedSession}
|
<div className="sticky bottom-4 mt-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 flex justify-between items-center">
|
||||||
onClose={() => setShowChatImport(false)}
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
onImportComplete={() => {
|
{selectedIds.size} selected
|
||||||
loadSessionGames(selectedSession);
|
|
||||||
setShowChatImport(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sessionGames.length === 0 ? (
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">No games played in this session</p>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold mb-4 text-gray-700 dark:text-gray-200">
|
|
||||||
Games Played ({sessionGames.length})
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{sessionGames.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">
|
|
||||||
{sessionGames.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>
|
</span>
|
||||||
)}
|
<div className="flex gap-2">
|
||||||
</div>
|
{filter !== 'archived' && (
|
||||||
</div>
|
<button
|
||||||
|
onClick={() => handleBulkAction('archive')}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600 dark:text-gray-400">
|
className="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700 transition"
|
||||||
<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:
|
Archive
|
||||||
</span>
|
</button>
|
||||||
<PopularityBadge
|
)}
|
||||||
upvotes={game.upvotes || 0}
|
{filter !== 'default' && (
|
||||||
downvotes={game.downvotes || 0}
|
<button
|
||||||
popularityScore={game.popularity_score || 0}
|
onClick={() => handleBulkAction('unarchive')}
|
||||||
size="sm"
|
className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 transition"
|
||||||
showCounts={true}
|
>
|
||||||
showNet={true}
|
Unarchive
|
||||||
showRatio={true}
|
</button>
|
||||||
/>
|
)}
|
||||||
</div>
|
<button
|
||||||
</div>
|
onClick={() => setShowBulkDeleteConfirm(true)}
|
||||||
</div>
|
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700 transition"
|
||||||
))}
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 flex items-center justify-center h-64">
|
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-lg">Select a session to view details</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* End Session Modal */}
|
{/* End Session Modal */}
|
||||||
{closingSession && (
|
{closingSession && (
|
||||||
<EndSessionModal
|
<EndSessionModal
|
||||||
sessionId={closingSession}
|
sessionId={closingSession}
|
||||||
sessionGames={closingSession === selectedSession ? sessionGames : []}
|
|
||||||
onClose={() => setClosingSession(null)}
|
onClose={() => setClosingSession(null)}
|
||||||
onConfirm={handleCloseSession}
|
onConfirm={handleCloseSession}
|
||||||
onShowChatImport={() => {
|
|
||||||
setShowChatImport(true);
|
|
||||||
if (closingSession !== selectedSession) {
|
|
||||||
loadSessionGames(closingSession);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
{/* Bulk Delete Confirmation Modal */}
|
||||||
{deletingSession && (
|
{showBulkDeleteConfirm && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<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">
|
<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>
|
<h2 className="text-2xl font-bold mb-4 text-red-600 dark:text-red-400">
|
||||||
|
Delete {selectedIds.size} Session{selectedIds.size !== 1 ? 's' : ''}?
|
||||||
|
</h2>
|
||||||
<p className="text-gray-700 dark:text-gray-300 mb-6">
|
<p className="text-gray-700 dark:text-gray-300 mb-6">
|
||||||
Are you sure you want to delete Session #{deletingSession}?
|
This will permanently delete {selectedIds.size} session{selectedIds.size !== 1 ? 's' : ''} and all associated games and chat logs. This action cannot be undone.
|
||||||
This will permanently delete all games and chat logs associated with this session. This action cannot be undone.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteSession(deletingSession)}
|
onClick={() => handleBulkAction('delete')}
|
||||||
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"
|
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
|
Delete Permanently
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeletingSession(null)}
|
onClick={() => setShowBulkDeleteConfirm(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"
|
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
|
Cancel
|
||||||
@@ -426,44 +481,13 @@ function History() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EndSessionModal({ sessionId, sessionGames, onClose, onConfirm, onShowChatImport }) {
|
function EndSessionModal({ sessionId, onClose, onConfirm }) {
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
// Check if any games have been voted on (popularity != 0)
|
|
||||||
const hasPopularityData = sessionGames.some(game => game.popularity_score !== 0);
|
|
||||||
const showPopularityWarning = sessionGames.length > 0 && !hasPopularityData;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<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">
|
<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>
|
<h2 className="text-2xl font-bold mb-4 dark:text-gray-100">End Session #{sessionId}</h2>
|
||||||
|
|
||||||
{/* Popularity Warning */}
|
|
||||||
{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">
|
<div className="mb-4">
|
||||||
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">
|
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">
|
||||||
Session Notes (optional)
|
Session Notes (optional)
|
||||||
@@ -475,7 +499,6 @@ function EndSessionModal({ sessionId, sessionGames, onClose, onConfirm, onShowCh
|
|||||||
placeholder="Add any notes about this session..."
|
placeholder="Add any notes about this session..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => onConfirm(sessionId, notes)}
|
onClick={() => onConfirm(sessionId, notes)}
|
||||||
@@ -495,183 +518,4 @@ function EndSessionModal({ sessionId, sessionGames, onClose, onConfirm, onShowCh
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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-gray-50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-lg p-6 mb-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.
|
|
||||||
<br />
|
|
||||||
<span className="text-xs italic">
|
|
||||||
Note: Popularity is cumulative - votes are added to each game's all-time totals.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* File Upload */}
|
|
||||||
<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-800 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
|
|
||||||
{result.duplicatesSkipped > 0 && (
|
|
||||||
<span className="block mt-1 text-xs italic opacity-75">
|
|
||||||
({result.duplicatesSkipped} duplicate{result.duplicatesSkipped !== 1 ? 's' : ''} skipped)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
{result.votesByGame && Object.keys(result.votesByGame).length > 0 && (
|
|
||||||
<div className="mt-2 text-sm text-green-700 dark:text-green-300">
|
|
||||||
<p className="font-semibold">Votes by game:</p>
|
|
||||||
<ul className="list-disc list-inside">
|
|
||||||
{Object.values(result.votesByGame).map((vote, i) => (
|
|
||||||
<li key={i}>
|
|
||||||
{vote.title}: +{vote.upvotes} / -{vote.downvotes}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<p className="text-xs mt-2 italic opacity-80">
|
|
||||||
Note: Popularity is cumulative across all sessions. If a game is played multiple times, votes apply to the game itself.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Debug Info */}
|
|
||||||
{result.debug && (
|
|
||||||
<details className="mt-4">
|
|
||||||
<summary className="cursor-pointer font-semibold text-green-800 dark:text-green-200">
|
|
||||||
Debug Info (click to expand)
|
|
||||||
</summary>
|
|
||||||
<div className="mt-2 text-xs text-green-700 dark:text-green-300 space-y-2">
|
|
||||||
{/* Session Timeline */}
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold">Session Timeline:</p>
|
|
||||||
<ul className="list-disc list-inside ml-2">
|
|
||||||
{result.debug.sessionGamesTimeline?.map((game, i) => (
|
|
||||||
<li key={i}>
|
|
||||||
{game.title} - {new Date(game.played_at).toLocaleString()}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Vote Matches */}
|
|
||||||
{result.debug.voteMatches && result.debug.voteMatches.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold">Vote Matches ({result.debug.voteMatches.length}):</p>
|
|
||||||
<ul className="list-disc list-inside ml-2 max-h-48 overflow-y-auto">
|
|
||||||
{result.debug.voteMatches.map((match, i) => (
|
|
||||||
<li key={i}>
|
|
||||||
{match.username}: {match.vote} at {new Date(match.timestamp).toLocaleString()} → matched to "{match.matched_game}" (played at {new Date(match.game_played_at).toLocaleString()})
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</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 History;
|
export default History;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import api from '../api/axios';
|
import api from '../api/axios';
|
||||||
@@ -8,7 +8,7 @@ import { formatLocalTime } from '../utils/dateUtils';
|
|||||||
import PopularityBadge from '../components/PopularityBadge';
|
import PopularityBadge from '../components/PopularityBadge';
|
||||||
|
|
||||||
function Picker() {
|
function Picker() {
|
||||||
const { isAuthenticated, loading: authLoading } = useAuth();
|
const { isAuthenticated, loading: authLoading, token } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [activeSession, setActiveSession] = useState(null);
|
const [activeSession, setActiveSession] = useState(null);
|
||||||
@@ -124,13 +124,13 @@ function Picker() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [isAuthenticated, authLoading, navigate, loadData]);
|
}, [isAuthenticated, authLoading, navigate, loadData]);
|
||||||
|
|
||||||
// Poll for active session status changes
|
// Fallback poll for session status — WebSocket events handle most updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated || authLoading) return;
|
if (!isAuthenticated || authLoading) return;
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
checkActiveSession();
|
checkActiveSession();
|
||||||
}, 3000);
|
}, 60000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isAuthenticated, authLoading, checkActiveSession]);
|
}, [isAuthenticated, authLoading, checkActiveSession]);
|
||||||
@@ -939,7 +939,7 @@ function Picker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame }) {
|
function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame }) {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated, token } = useAuth();
|
||||||
const [games, setGames] = useState([]);
|
const [games, setGames] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [confirmingRemove, setConfirmingRemove] = useState(null);
|
const [confirmingRemove, setConfirmingRemove] = useState(null);
|
||||||
@@ -952,33 +952,51 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
|
|||||||
const [editingPlayerCount, setEditingPlayerCount] = useState(null);
|
const [editingPlayerCount, setEditingPlayerCount] = useState(null);
|
||||||
const [newPlayerCount, setNewPlayerCount] = useState('');
|
const [newPlayerCount, setNewPlayerCount] = useState('');
|
||||||
|
|
||||||
|
const playingGameRef = useRef(playingGame);
|
||||||
|
playingGameRef.current = playingGame;
|
||||||
|
|
||||||
const loadGames = useCallback(async () => {
|
const loadGames = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/sessions/${sessionId}/games`);
|
const response = await api.get(`/sessions/${sessionId}/games`);
|
||||||
// Reverse chronological order (most recent first)
|
const freshGames = response.data;
|
||||||
setGames(response.data.reverse());
|
setGames(freshGames.reverse());
|
||||||
|
|
||||||
|
const currentPlaying = freshGames.find(g => g.status === 'playing');
|
||||||
|
const prev = playingGameRef.current;
|
||||||
|
if (currentPlaying) {
|
||||||
|
if (!prev || prev.id !== currentPlaying.id || prev.player_count !== currentPlaying.player_count) {
|
||||||
|
setPlayingGame(currentPlaying);
|
||||||
|
}
|
||||||
|
} else if (prev) {
|
||||||
|
const still = freshGames.find(g => g.id === prev.id);
|
||||||
|
if (!still || still.status !== 'playing') {
|
||||||
|
setPlayingGame(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load session games');
|
console.error('Failed to load session games');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [sessionId]);
|
}, [sessionId, setPlayingGame]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadGames();
|
loadGames();
|
||||||
}, [sessionId, onGamesUpdate, loadGames]);
|
}, [sessionId, onGamesUpdate, loadGames]);
|
||||||
|
|
||||||
// Auto-refresh games list every 3 seconds
|
// Fallback polling — WebSocket events handle most updates; this is a safety net
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
loadGames();
|
loadGames();
|
||||||
}, 3000);
|
}, 60000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadGames]);
|
}, [loadGames]);
|
||||||
|
|
||||||
// Setup WebSocket connection for real-time player count updates
|
// Setup WebSocket connection for real-time session updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${protocol}//${window.location.hostname}:${window.location.port || (window.location.protocol === 'https:' ? 443 : 80)}/api/sessions/live`;
|
const wsUrl = `${protocol}//${window.location.hostname}:${window.location.port || (window.location.protocol === 'https:' ? 443 : 80)}/api/sessions/live`;
|
||||||
|
|
||||||
@@ -986,22 +1004,34 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
|
|||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
console.log('[WebSocket] Connected for player count updates');
|
console.log('[WebSocket] Connected, authenticating...');
|
||||||
// Subscribe to session events
|
ws.send(JSON.stringify({ type: 'auth', token }));
|
||||||
ws.send(JSON.stringify({
|
|
||||||
type: 'subscribe',
|
|
||||||
sessionId: parseInt(sessionId)
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
|
|
||||||
// Handle player count updates
|
if (message.type === 'auth_success') {
|
||||||
if (message.event === 'player-count.updated') {
|
console.log('[WebSocket] Authenticated, subscribing to session', sessionId);
|
||||||
console.log('[WebSocket] Player count updated:', message.data);
|
ws.send(JSON.stringify({ type: 'subscribe', sessionId: parseInt(sessionId) }));
|
||||||
// Reload games to get updated player counts
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadEvents = [
|
||||||
|
'room.connected',
|
||||||
|
'lobby.player-joined',
|
||||||
|
'lobby.updated',
|
||||||
|
'game.started',
|
||||||
|
'game.ended',
|
||||||
|
'room.disconnected',
|
||||||
|
'player-count.updated',
|
||||||
|
'game.added',
|
||||||
|
'game.status',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (reloadEvents.includes(message.type)) {
|
||||||
|
console.log(`[WebSocket] ${message.type}:`, message.data);
|
||||||
loadGames();
|
loadGames();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1027,7 +1057,7 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WebSocket] Failed to connect:', error);
|
console.error('[WebSocket] Failed to connect:', error);
|
||||||
}
|
}
|
||||||
}, [sessionId, loadGames]);
|
}, [sessionId, token, loadGames]);
|
||||||
|
|
||||||
const handleUpdateStatus = async (gameId, newStatus) => {
|
const handleUpdateStatus = async (gameId, newStatus) => {
|
||||||
try {
|
try {
|
||||||
@@ -1303,14 +1333,14 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
|
|||||||
{/* Player Count Display */}
|
{/* Player Count Display */}
|
||||||
{game.player_count_check_status && game.player_count_check_status !== 'not_started' && (
|
{game.player_count_check_status && game.player_count_check_status !== 'not_started' && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{game.player_count_check_status === 'waiting' && (
|
{game.player_count_check_status === 'monitoring' && !game.player_count && (
|
||||||
<span className="inline-flex items-center gap-1 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
|
<span className="inline-flex items-center gap-1 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
|
||||||
⏳ Waiting...
|
📡 Monitoring...
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{game.player_count_check_status === 'checking' && (
|
{(game.player_count_check_status === 'checking' || (game.player_count_check_status === 'monitoring' && game.player_count)) && (
|
||||||
<span className="inline-flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded">
|
<span className="inline-flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded">
|
||||||
🔍 {game.player_count ? `${game.player_count} players (checking...)` : 'Checking...'}
|
📡 {game.player_count ? `${game.player_count} players` : 'Monitoring...'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{game.player_count_check_status === 'completed' && game.player_count && (
|
{game.player_count_check_status === 'completed' && game.player_count && (
|
||||||
@@ -1406,7 +1436,7 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Stop button for active checks */}
|
{/* Stop button for active checks */}
|
||||||
{isAuthenticated && (game.player_count_check_status === 'waiting' || game.player_count_check_status === 'checking') && (
|
{isAuthenticated && (game.player_count_check_status === 'monitoring' || game.player_count_check_status === 'checking') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStopPlayerCountCheck(game.id)}
|
onClick={() => handleStopPlayerCountCheck(game.id)}
|
||||||
className="text-xs text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400"
|
className="text-xs text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400"
|
||||||
|
|||||||
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();
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [require('@tailwindcss/typography')],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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'],
|
||||||
|
};
|
||||||
157
scripts/ws-lifecycle-test.js
Normal file
157
scripts/ws-lifecycle-test.js
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const ROOM = process.argv[2] || 'SCWX';
|
||||||
|
const HOST = 'ecast-prod-use2.jackboxgames.com';
|
||||||
|
|
||||||
|
function ts() { return new Date().toISOString().slice(11, 23); }
|
||||||
|
function log(tag, ...args) { console.log(`[${ts()}][${tag}]`, ...args); }
|
||||||
|
|
||||||
|
function connect(name, opts = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let params = `role=player&name=${encodeURIComponent(name)}&format=json`;
|
||||||
|
if (opts.secret) {
|
||||||
|
params += `&secret=${opts.secret}`;
|
||||||
|
params += `&id=${opts.id}`;
|
||||||
|
} else {
|
||||||
|
params += `&userId=${name}-${Date.now()}`;
|
||||||
|
}
|
||||||
|
const url = `wss://${HOST}/api/v2/rooms/${ROOM}/play?${params}`;
|
||||||
|
log(name, 'Connecting:', url);
|
||||||
|
|
||||||
|
const ws = new WebSocket(url, ['ecast-v0'], {
|
||||||
|
headers: { 'Origin': 'https://jackbox.tv' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const allMsgs = [];
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
const m = JSON.parse(raw.toString());
|
||||||
|
allMsgs.push(m);
|
||||||
|
|
||||||
|
if (m.opcode === 'client/welcome') {
|
||||||
|
const r = m.result;
|
||||||
|
const hereList = r.here ? Object.entries(r.here).map(([k, v]) => {
|
||||||
|
const role = Object.keys(v.roles)[0];
|
||||||
|
const detail = v.roles.player ? `(${v.roles.player.name})` : '';
|
||||||
|
return `${k}:${role}${detail}`;
|
||||||
|
}).join(', ') : 'null';
|
||||||
|
log(name, `WELCOME id=${r.id} reconnect=${r.reconnect} secret=${r.secret} here=[${hereList}]`);
|
||||||
|
resolve({ ws, id: r.id, secret: r.secret, msgs: allMsgs, name });
|
||||||
|
} else if (m.opcode === 'client/connected') {
|
||||||
|
const r = m.result;
|
||||||
|
log(name, `*** CLIENT/CONNECTED id=${r.id} userId=${r.userId} name=${r.name} role=${JSON.stringify(r.roles || r.role)}`);
|
||||||
|
} else if (m.opcode === 'client/disconnected') {
|
||||||
|
const r = m.result;
|
||||||
|
log(name, `*** CLIENT/DISCONNECTED id=${r.id} role=${JSON.stringify(r.roles || r.role)}`);
|
||||||
|
} else if (m.opcode === 'client/kicked') {
|
||||||
|
log(name, `*** CLIENT/KICKED:`, JSON.stringify(m.result));
|
||||||
|
} else if (m.opcode === 'error') {
|
||||||
|
log(name, `ERROR code=${m.result.code}: ${m.result.msg}`);
|
||||||
|
reject(new Error(m.result.msg));
|
||||||
|
} else if (m.opcode === 'object') {
|
||||||
|
const r = m.result;
|
||||||
|
if (r.key === 'room') {
|
||||||
|
log(name, `ROOM state=${r.val?.state} lobbyState=${r.val?.lobbyState} gameCanStart=${r.val?.gameCanStart} gameFinished=${r.val?.gameFinished} v${r.version}`);
|
||||||
|
} else if (r.key === 'textDescriptions') {
|
||||||
|
const latest = r.val?.latestDescriptions?.[0];
|
||||||
|
if (latest) log(name, `TEXT: "${latest.text}" (${latest.category})`);
|
||||||
|
} else if (r.key?.startsWith('player:')) {
|
||||||
|
log(name, `PLAYER ${r.key} state=${r.val?.state || 'init'} v${r.version}`);
|
||||||
|
} else {
|
||||||
|
log(name, `ENTITY ${r.key} v${r.version}`);
|
||||||
|
}
|
||||||
|
} else if (m.opcode === 'ok') {
|
||||||
|
log(name, `OK seq response`);
|
||||||
|
} else if (m.opcode === 'room/get-audience') {
|
||||||
|
log(name, `AUDIENCE connections=${m.result?.connections}`);
|
||||||
|
} else {
|
||||||
|
log(name, `OTHER op=${m.opcode}`, JSON.stringify(m.result).slice(0, 200));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', (code, reason) => {
|
||||||
|
log(name, `CLOSED code=${code} reason=${reason.toString()}`);
|
||||||
|
});
|
||||||
|
ws.on('error', (e) => {
|
||||||
|
log(name, `WS_ERROR: ${e.message}`);
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
log('TEST', `=== Phase 1: Connect P1 to ${ROOM} ===`);
|
||||||
|
const p1 = await connect('P1');
|
||||||
|
log('TEST', `P1 connected as id=${p1.id}, secret=${p1.secret}`);
|
||||||
|
|
||||||
|
log('TEST', `=== Phase 2: Check connections ===`);
|
||||||
|
const connBefore = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
|
||||||
|
log('TEST', `Connections: ${JSON.stringify(connBefore)}`);
|
||||||
|
|
||||||
|
await wait(2000);
|
||||||
|
|
||||||
|
log('TEST', `=== Phase 3: Connect P2 (watch P1 for client/connected) ===`);
|
||||||
|
const p2 = await connect('P2');
|
||||||
|
log('TEST', `P2 connected as id=${p2.id}, secret=${p2.secret}`);
|
||||||
|
|
||||||
|
const connAfterJoin = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
|
||||||
|
log('TEST', `Connections after P2 join: ${JSON.stringify(connAfterJoin)}`);
|
||||||
|
|
||||||
|
await wait(2000);
|
||||||
|
|
||||||
|
log('TEST', `=== Phase 4: P2 gracefully disconnects (watch P1 for client/disconnected) ===`);
|
||||||
|
p2.ws.close(1000, 'test-disconnect');
|
||||||
|
await wait(3000);
|
||||||
|
|
||||||
|
const connAfterLeave = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
|
||||||
|
log('TEST', `Connections after P2 leave: ${JSON.stringify(connAfterLeave)}`);
|
||||||
|
|
||||||
|
log('TEST', `=== Phase 5: Reconnect P2 using secret ===`);
|
||||||
|
const p2r = await connect('P2-RECONNECT', { secret: p2.secret, id: p2.id });
|
||||||
|
log('TEST', `P2 reconnected as id=${p2r.id}, reconnect should be true`);
|
||||||
|
|
||||||
|
const connAfterReconnect = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
|
||||||
|
log('TEST', `Connections after P2 reconnect: ${JSON.stringify(connAfterReconnect)}`);
|
||||||
|
|
||||||
|
await wait(2000);
|
||||||
|
|
||||||
|
log('TEST', `=== Phase 6: Connect P3 (reach 3 players for CanStart) ===`);
|
||||||
|
const p3 = await connect('P3');
|
||||||
|
log('TEST', `P3 connected as id=${p3.id}`);
|
||||||
|
|
||||||
|
await wait(2000);
|
||||||
|
|
||||||
|
log('TEST', `=== Phase 7: Query audience count ===`);
|
||||||
|
p1.ws.send(JSON.stringify({ seq: 100, opcode: 'room/get-audience', params: {} }));
|
||||||
|
await wait(1000);
|
||||||
|
|
||||||
|
log('TEST', `=== Phase 8: All messages received by P1 ===`);
|
||||||
|
const p1Opcodes = p1.msgs.map(m => `pc:${m.pc} ${m.opcode}${m.result?.key ? ':' + m.result.key : ''}`);
|
||||||
|
log('TEST', `P1 received ${p1.msgs.length} messages: ${p1Opcodes.join(', ')}`);
|
||||||
|
|
||||||
|
log('TEST', `=== Cleanup ===`);
|
||||||
|
p1.ws.close(1000);
|
||||||
|
p2r.ws.close(1000);
|
||||||
|
p3.ws.close(1000);
|
||||||
|
await wait(1000);
|
||||||
|
|
||||||
|
const connFinal = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
|
||||||
|
log('TEST', `Final connections: ${JSON.stringify(connFinal)}`);
|
||||||
|
log('TEST', `=== DONE ===`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchJSON(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get(url, res => {
|
||||||
|
let d = '';
|
||||||
|
res.on('data', c => d += c);
|
||||||
|
res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } });
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('FATAL:', e.message); process.exit(1); });
|
||||||
109
scripts/ws-probe.js
Normal file
109
scripts/ws-probe.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const ROOM_CODE = process.argv[2] || 'LSBN';
|
||||||
|
const PLAYER_NAME = process.argv[3] || 'PROBE_WS';
|
||||||
|
const ROLE = process.argv[4] || 'player'; // 'player' or 'audience'
|
||||||
|
const USER_ID = `probe-${Date.now()}`;
|
||||||
|
|
||||||
|
function getRoomInfo(code) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get(`https://ecast.jackboxgames.com/api/v2/rooms/${code}`, res => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', c => data += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
if (json.ok) resolve(json.body);
|
||||||
|
else reject(new Error(json.error || 'Room not found'));
|
||||||
|
} catch (e) { reject(e); }
|
||||||
|
});
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ts() {
|
||||||
|
return new Date().toISOString().slice(11, 23);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`[${ts()}] Fetching room info for ${ROOM_CODE}...`);
|
||||||
|
const room = await getRoomInfo(ROOM_CODE);
|
||||||
|
console.log(`[${ts()}] Room: ${room.appTag}, host: ${room.host}, locked: ${room.locked}`);
|
||||||
|
|
||||||
|
let wsUrl;
|
||||||
|
if (ROLE === 'audience') {
|
||||||
|
wsUrl = `wss://${room.audienceHost}/api/v2/audience/${ROOM_CODE}/play`;
|
||||||
|
} else {
|
||||||
|
wsUrl = `wss://${room.host}/api/v2/rooms/${ROOM_CODE}/play?role=${ROLE}&name=${encodeURIComponent(PLAYER_NAME)}&userId=${USER_ID}&format=json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${ts()}] Connecting: ${wsUrl}`);
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl, ['ecast-v0'], {
|
||||||
|
headers: {
|
||||||
|
'Origin': 'https://jackbox.tv',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let msgCount = 0;
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
console.log(`[${ts()}] CONNECTED`);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
msgCount++;
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
const summary = summarize(msg);
|
||||||
|
console.log(`[${ts()}] RECV #${msgCount} | pc:${msg.pc} | opcode:${msg.opcode} | ${summary}`);
|
||||||
|
if (process.env.VERBOSE === 'true') {
|
||||||
|
console.log(JSON.stringify(msg, null, 2));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[${ts()}] RECV #${msgCount} | raw: ${raw.toString().slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', (code, reason) => {
|
||||||
|
console.log(`[${ts()}] CLOSED code=${code} reason=${reason}`);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
console.error(`[${ts()}] ERROR: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log(`\n[${ts()}] Closing (${msgCount} messages received)`);
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(msg) {
|
||||||
|
if (msg.opcode === 'client/welcome') {
|
||||||
|
const r = msg.result || {};
|
||||||
|
const hereIds = r.here ? Object.keys(r.here) : [];
|
||||||
|
const entityKeys = r.entities ? Object.keys(r.entities) : [];
|
||||||
|
return `id=${r.id} name=${r.name} reconnect=${r.reconnect} here=[${hereIds}] entities=[${entityKeys}]`;
|
||||||
|
}
|
||||||
|
if (msg.opcode === 'object') {
|
||||||
|
const r = msg.result || {};
|
||||||
|
const valKeys = r.val ? Object.keys(r.val).slice(0, 5).join(',') : 'null';
|
||||||
|
return `key=${r.key} v${r.version} from=${r.from} val=[${valKeys}...]`;
|
||||||
|
}
|
||||||
|
if (msg.opcode === 'client/connected') {
|
||||||
|
const r = msg.result || {};
|
||||||
|
return `id=${r.id} userId=${r.userId} name=${r.name} role=${r.role}`;
|
||||||
|
}
|
||||||
|
if (msg.opcode === 'client/disconnected') {
|
||||||
|
const r = msg.result || {};
|
||||||
|
return `id=${r.id} role=${r.role}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(msg.result || msg).slice(0, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(e); process.exit(1); });
|
||||||
576
tests/api/ecast-shard-client.test.js
Normal file
576
tests/api/ecast-shard-client.test.js
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
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([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles null/undefined val', () => {
|
||||||
|
expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(null)).toEqual([]);
|
||||||
|
expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
test('initializes with correct defaults', () => {
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
|
||||||
|
});
|
||||||
|
expect(client.sessionId).toBe(1);
|
||||||
|
expect(client.gameId).toBe(5);
|
||||||
|
expect(client.roomCode).toBe('TEST');
|
||||||
|
expect(client.maxPlayers).toBe(8);
|
||||||
|
expect(client.playerCount).toBe(0);
|
||||||
|
expect(client.playerNames).toEqual([]);
|
||||||
|
expect(client.gameStarted).toBe(false);
|
||||||
|
expect(client.gameFinished).toBe(false);
|
||||||
|
expect(client.appTag).toBeNull();
|
||||||
|
expect(client.ws).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleWelcome', () => {
|
||||||
|
test('parses welcome message and sets internal state', () => {
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.handleWelcome({
|
||||||
|
id: 7,
|
||||||
|
secret: 'abc-123',
|
||||||
|
reconnect: false,
|
||||||
|
entities: {
|
||||||
|
room: ['object', { key: 'room', val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false }, version: 0, from: 1 }, { locked: false }]
|
||||||
|
},
|
||||||
|
here: {
|
||||||
|
'1': { id: 1, roles: { host: {} } },
|
||||||
|
'2': { id: 2, roles: { player: { name: 'Alice' } } },
|
||||||
|
'3': { id: 3, roles: { player: { name: 'Bob' } } },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.shardId).toBe(7);
|
||||||
|
expect(client.secret).toBe('abc-123');
|
||||||
|
expect(client.playerCount).toBe(2);
|
||||||
|
expect(client.playerNames).toEqual(['Alice', 'Bob']);
|
||||||
|
expect(client.gameState).toBe('Lobby');
|
||||||
|
expect(client.lobbyState).toBe('CanStart');
|
||||||
|
expect(client.gameStarted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses bc:room entity when room key is absent', () => {
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.handleWelcome({
|
||||||
|
id: 5,
|
||||||
|
secret: 'xyz-789',
|
||||||
|
reconnect: false,
|
||||||
|
entities: {
|
||||||
|
'bc:room': ['object', { key: 'bc:room', val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false }, version: 0, from: 1 }, { locked: false }],
|
||||||
|
audience: ['crdt/pn-counter', [], { locked: false }],
|
||||||
|
},
|
||||||
|
here: {
|
||||||
|
'1': { id: 1, roles: { host: {} } },
|
||||||
|
'3': { id: 3, roles: { player: { name: 'HÂM' } } },
|
||||||
|
'4': { id: 4, roles: { player: { name: 'FGHFGHY' } } },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.playerCount).toBe(2);
|
||||||
|
expect(client.playerNames).toEqual(['HÂM', 'FGHFGHY']);
|
||||||
|
expect(client.gameState).toBe('Lobby');
|
||||||
|
expect(client.lobbyState).toBe('CanStart');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleEntityUpdate', () => {
|
||||||
|
test('updates room state on room entity update', () => {
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
|
||||||
|
});
|
||||||
|
client.gameState = 'Lobby';
|
||||||
|
client.lobbyState = 'WaitingForMore';
|
||||||
|
|
||||||
|
client.handleEntityUpdate({
|
||||||
|
key: 'room',
|
||||||
|
val: { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: true, gameFinished: false },
|
||||||
|
version: 5,
|
||||||
|
from: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.gameState).toBe('Gameplay');
|
||||||
|
expect(client.gameStarted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles bc:room key as room update', () => {
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.handleEntityUpdate({
|
||||||
|
key: 'bc:room',
|
||||||
|
val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false },
|
||||||
|
version: 1,
|
||||||
|
from: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.lobbyState).toBe('CanStart');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('event broadcasting', () => {
|
||||||
|
let events;
|
||||||
|
let client;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
events = [];
|
||||||
|
client = new EcastShardClient({
|
||||||
|
sessionId: 1,
|
||||||
|
gameId: 5,
|
||||||
|
roomCode: 'TEST',
|
||||||
|
maxPlayers: 8,
|
||||||
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleWelcome broadcasts room.connected', () => {
|
||||||
|
test('broadcasts room.connected with initial state', () => {
|
||||||
|
client.appTag = 'drawful2international';
|
||||||
|
client.handleWelcome({
|
||||||
|
id: 7,
|
||||||
|
secret: 'abc',
|
||||||
|
reconnect: false,
|
||||||
|
entities: {
|
||||||
|
room: ['object', { key: 'room', val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false }, version: 0, from: 1 }, { locked: false }]
|
||||||
|
},
|
||||||
|
here: {
|
||||||
|
'1': { id: 1, roles: { host: {} } },
|
||||||
|
'2': { id: 2, roles: { player: { name: 'Alice' } } },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].type).toBe('room.connected');
|
||||||
|
expect(events[0].data.playerCount).toBe(1);
|
||||||
|
expect(events[0].data.players).toEqual(['Alice']);
|
||||||
|
expect(events[0].data.lobbyState).toBe('CanStart');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleEntityUpdate broadcasts events', () => {
|
||||||
|
test('broadcasts lobby.updated on lobbyState change', () => {
|
||||||
|
client.lobbyState = 'WaitingForMore';
|
||||||
|
client.gameState = 'Lobby';
|
||||||
|
|
||||||
|
client.handleEntityUpdate({
|
||||||
|
key: 'room',
|
||||||
|
val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false },
|
||||||
|
version: 2, from: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].type).toBe('lobby.updated');
|
||||||
|
expect(events[0].data.lobbyState).toBe('CanStart');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('broadcasts game.started on state transition to Gameplay', () => {
|
||||||
|
client.lobbyState = 'Countdown';
|
||||||
|
client.gameState = 'Lobby';
|
||||||
|
client.gameStarted = false;
|
||||||
|
client.playerCount = 4;
|
||||||
|
client.playerNames = ['A', 'B', 'C', 'D'];
|
||||||
|
|
||||||
|
client.handleEntityUpdate({
|
||||||
|
key: 'room',
|
||||||
|
val: { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: true, gameFinished: false },
|
||||||
|
version: 5, from: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startEvents = events.filter(e => e.type === 'game.started');
|
||||||
|
expect(startEvents).toHaveLength(1);
|
||||||
|
expect(startEvents[0].data.playerCount).toBe(4);
|
||||||
|
expect(startEvents[0].data.players).toEqual(['A', 'B', 'C', 'D']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not broadcast game.started if already started', () => {
|
||||||
|
client.gameStarted = true;
|
||||||
|
client.gameState = 'Gameplay';
|
||||||
|
|
||||||
|
client.handleEntityUpdate({
|
||||||
|
key: 'room',
|
||||||
|
val: { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: false },
|
||||||
|
version: 10, from: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events.filter(e => e.type === 'game.started')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('broadcasts game.ended on gameFinished transition', () => {
|
||||||
|
client.gameStarted = true;
|
||||||
|
client.gameState = 'Gameplay';
|
||||||
|
client.gameFinished = false;
|
||||||
|
client.playerCount = 3;
|
||||||
|
client.playerNames = ['X', 'Y', 'Z'];
|
||||||
|
|
||||||
|
client.handleEntityUpdate({
|
||||||
|
key: 'room',
|
||||||
|
val: { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true },
|
||||||
|
version: 20, from: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endEvents = events.filter(e => e.type === 'game.ended');
|
||||||
|
expect(endEvents).toHaveLength(1);
|
||||||
|
expect(endEvents[0].data.playerCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('broadcasts lobby.player-joined from textDescriptions', () => {
|
||||||
|
client.playerNames = ['Alice'];
|
||||||
|
client.playerCount = 1;
|
||||||
|
|
||||||
|
client.handleEntityUpdate({
|
||||||
|
key: 'textDescriptions',
|
||||||
|
val: {
|
||||||
|
latestDescriptions: [
|
||||||
|
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Bob joined.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
version: 3, from: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].type).toBe('lobby.player-joined');
|
||||||
|
expect(events[0].data.playerName).toBe('Bob');
|
||||||
|
expect(events[0].data.playerCount).toBe(2);
|
||||||
|
expect(events[0].data.players).toEqual(['Alice', 'Bob']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not broadcast duplicate player join', () => {
|
||||||
|
client.playerNames = ['Alice', 'Bob'];
|
||||||
|
client.playerCount = 2;
|
||||||
|
|
||||||
|
client.handleEntityUpdate({
|
||||||
|
key: 'textDescriptions',
|
||||||
|
val: {
|
||||||
|
latestDescriptions: [
|
||||||
|
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Bob joined.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
version: 4, from: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleClientConnected', () => {
|
||||||
|
test('broadcasts lobby.player-joined for new player connection', () => {
|
||||||
|
client.playerNames = ['Alice'];
|
||||||
|
client.playerCount = 1;
|
||||||
|
|
||||||
|
client.handleClientConnected({
|
||||||
|
id: 3,
|
||||||
|
roles: { player: { name: 'Charlie' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].type).toBe('lobby.player-joined');
|
||||||
|
expect(events[0].data.playerName).toBe('Charlie');
|
||||||
|
expect(events[0].data.playerCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores non-player connections', () => {
|
||||||
|
client.handleClientConnected({
|
||||||
|
id: 5,
|
||||||
|
roles: { shard: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores duplicate player connection', () => {
|
||||||
|
client.playerNames = ['Alice'];
|
||||||
|
client.playerCount = 1;
|
||||||
|
|
||||||
|
client.handleClientConnected({
|
||||||
|
id: 2,
|
||||||
|
roles: { player: { name: 'Alice' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildReconnectUrl', () => {
|
||||||
|
test('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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSnapshot', () => {
|
||||||
|
test('returns correct shape with current state', () => {
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1, gameId: 5, roomCode: 'LSBN', maxPlayers: 8, onEvent: () => {}
|
||||||
|
});
|
||||||
|
client.appTag = 'drawful2international';
|
||||||
|
client.playerCount = 3;
|
||||||
|
client.playerNames = ['Alice', 'Bob', 'Charlie'];
|
||||||
|
client.lobbyState = 'CanStart';
|
||||||
|
client.gameState = 'Lobby';
|
||||||
|
client.gameStarted = false;
|
||||||
|
client.gameFinished = false;
|
||||||
|
|
||||||
|
const snapshot = client.getSnapshot();
|
||||||
|
|
||||||
|
expect(snapshot).toEqual({
|
||||||
|
sessionId: 1,
|
||||||
|
gameId: 5,
|
||||||
|
roomCode: 'LSBN',
|
||||||
|
appTag: 'drawful2international',
|
||||||
|
maxPlayers: 8,
|
||||||
|
playerCount: 3,
|
||||||
|
players: ['Alice', 'Bob', 'Charlie'],
|
||||||
|
lobbyState: 'CanStart',
|
||||||
|
gameState: 'Lobby',
|
||||||
|
gameStarted: false,
|
||||||
|
gameFinished: false,
|
||||||
|
monitoring: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns a defensive copy of playerNames', () => {
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
|
||||||
|
});
|
||||||
|
client.playerNames = ['Alice'];
|
||||||
|
|
||||||
|
const snapshot = client.getSnapshot();
|
||||||
|
snapshot.players.push('Mutated');
|
||||||
|
|
||||||
|
expect(client.playerNames).toEqual(['Alice']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('startStatusBroadcast / stopStatusBroadcast', () => {
|
||||||
|
beforeEach(() => jest.useFakeTimers());
|
||||||
|
afterEach(() => jest.useRealTimers());
|
||||||
|
|
||||||
|
function stubRefresh(client) {
|
||||||
|
client._refreshPlayerCount = () => Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('broadcasts game.status every 20 seconds', async () => {
|
||||||
|
const events = [];
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
|
||||||
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
|
});
|
||||||
|
stubRefresh(client);
|
||||||
|
client.playerCount = 2;
|
||||||
|
client.playerNames = ['A', 'B'];
|
||||||
|
client.gameState = 'Lobby';
|
||||||
|
|
||||||
|
client.startStatusBroadcast();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(20000);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].type).toBe('game.status');
|
||||||
|
expect(events[0].data.monitoring).toBe(true);
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(20000);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
|
||||||
|
client.stopStatusBroadcast();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(40000);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disconnect stops the status broadcast', async () => {
|
||||||
|
const events = [];
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
|
||||||
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
|
});
|
||||||
|
stubRefresh(client);
|
||||||
|
|
||||||
|
client.startStatusBroadcast();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(20000);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
|
||||||
|
client.disconnect();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(40000);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleWelcome starts status broadcast', async () => {
|
||||||
|
const events = [];
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
|
||||||
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
|
});
|
||||||
|
stubRefresh(client);
|
||||||
|
|
||||||
|
client.handleWelcome({
|
||||||
|
id: 7,
|
||||||
|
secret: 'abc',
|
||||||
|
reconnect: false,
|
||||||
|
entities: {},
|
||||||
|
here: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(20000);
|
||||||
|
await Promise.resolve();
|
||||||
|
const statusEvents = events.filter(e => e.type === 'game.status');
|
||||||
|
expect(statusEvents).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('module exports', () => {
|
||||||
|
const { startMonitor, stopMonitor, cleanupAllShards, getMonitorSnapshot } = require('../../backend/utils/ecast-shard-client');
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getMonitorSnapshot is exported', () => {
|
||||||
|
expect(typeof getMonitorSnapshot).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getMonitorSnapshot returns null when no shard active', () => {
|
||||||
|
const snapshot = getMonitorSnapshot(999, 999);
|
||||||
|
expect(snapshot).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleError with code 2027', () => {
|
||||||
|
test('marks game as finished and emits events on room-closed error', () => {
|
||||||
|
const events = [];
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1,
|
||||||
|
gameId: 5,
|
||||||
|
roomCode: 'TEST',
|
||||||
|
maxPlayers: 8,
|
||||||
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
|
});
|
||||||
|
client.playerCount = 4;
|
||||||
|
client.playerNames = ['A', 'B', 'C', 'D'];
|
||||||
|
|
||||||
|
client.handleError({ code: 2027, msg: 'the room has already been closed' });
|
||||||
|
|
||||||
|
expect(client.gameFinished).toBe(true);
|
||||||
|
expect(events.some(e => e.type === 'game.ended')).toBe(true);
|
||||||
|
expect(events.some(e => e.type === 'room.disconnected' && e.data.reason === 'room_closed')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
11
tests/api/jackbox-api.test.js
Normal file
11
tests/api/jackbox-api.test.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
const { getRoomInfo, checkRoomStatus } = require('../../backend/utils/jackbox-api');
|
||||||
|
|
||||||
|
describe('jackbox-api exports', () => {
|
||||||
|
test('getRoomInfo is exported as a function', () => {
|
||||||
|
expect(typeof getRoomInfo).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkRoomStatus is still exported', () => {
|
||||||
|
expect(typeof checkRoomStatus).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
227
tests/api/named-admins.test.js
Normal file
227
tests/api/named-admins.test.js
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
describe('load-admins', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'admins-test-'));
|
||||||
|
delete process.env.ADMIN_CONFIG_PATH;
|
||||||
|
delete process.env.ADMIN_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeConfig(admins) {
|
||||||
|
const filePath = path.join(tmpDir, 'admins.json');
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(admins));
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('loads admins from ADMIN_CONFIG_PATH', () => {
|
||||||
|
const configPath = writeConfig([
|
||||||
|
{ name: 'Alice', key: 'key-a' },
|
||||||
|
{ name: 'Bob', key: 'key-b' }
|
||||||
|
]);
|
||||||
|
process.env.ADMIN_CONFIG_PATH = configPath;
|
||||||
|
|
||||||
|
const { findAdminByKey } = require('../../backend/config/load-admins');
|
||||||
|
expect(findAdminByKey('key-a')).toEqual({ name: 'Alice' });
|
||||||
|
expect(findAdminByKey('key-b')).toEqual({ name: 'Bob' });
|
||||||
|
expect(findAdminByKey('wrong')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to ADMIN_KEY when no config file', () => {
|
||||||
|
process.env.ADMIN_CONFIG_PATH = path.join(tmpDir, 'nonexistent.json');
|
||||||
|
process.env.ADMIN_KEY = 'legacy-key';
|
||||||
|
|
||||||
|
const { findAdminByKey } = require('../../backend/config/load-admins');
|
||||||
|
expect(findAdminByKey('legacy-key')).toEqual({ name: 'Admin' });
|
||||||
|
expect(findAdminByKey('wrong')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws when neither config file nor ADMIN_KEY exists', () => {
|
||||||
|
process.env.ADMIN_CONFIG_PATH = path.join(tmpDir, 'nonexistent.json');
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
require('../../backend/config/load-admins');
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects duplicate admin names', () => {
|
||||||
|
const configPath = writeConfig([
|
||||||
|
{ name: 'Alice', key: 'key-a' },
|
||||||
|
{ name: 'Alice', key: 'key-b' }
|
||||||
|
]);
|
||||||
|
process.env.ADMIN_CONFIG_PATH = configPath;
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
require('../../backend/config/load-admins');
|
||||||
|
}).toThrow(/duplicate/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects duplicate keys', () => {
|
||||||
|
const configPath = writeConfig([
|
||||||
|
{ name: 'Alice', key: 'same-key' },
|
||||||
|
{ name: 'Bob', key: 'same-key' }
|
||||||
|
]);
|
||||||
|
process.env.ADMIN_CONFIG_PATH = configPath;
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
require('../../backend/config/load-admins');
|
||||||
|
}).toThrow(/duplicate/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
describe('POST /api/auth/login — named admins', () => {
|
||||||
|
let app;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.ADMIN_KEY = 'test-admin-key';
|
||||||
|
process.env.ADMIN_CONFIG_PATH = '/tmp/nonexistent-admins.json';
|
||||||
|
jest.resetModules();
|
||||||
|
({ app } = require('../../backend/server'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login returns admin name in response', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ key: 'test-admin-key' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.name).toBeDefined();
|
||||||
|
expect(res.body.token).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verify returns admin name in user object', async () => {
|
||||||
|
const loginRes = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ key: 'test-admin-key' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/verify')
|
||||||
|
.set('Authorization', `Bearer ${loginRes.body.token}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.user.name).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid key still returns 401', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ key: 'wrong-key' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
describe('WebSocket presence', () => {
|
||||||
|
let server, wsUrl;
|
||||||
|
|
||||||
|
beforeAll((done) => {
|
||||||
|
process.env.ADMIN_KEY = 'test-admin-key';
|
||||||
|
process.env.ADMIN_CONFIG_PATH = '/tmp/nonexistent-admins.json';
|
||||||
|
jest.resetModules();
|
||||||
|
const { app } = require('../../backend/server');
|
||||||
|
const { WebSocketManager, setWebSocketManager } = require('../../backend/utils/websocket-manager');
|
||||||
|
|
||||||
|
server = http.createServer(app);
|
||||||
|
const wsManager = new WebSocketManager(server);
|
||||||
|
setWebSocketManager(wsManager);
|
||||||
|
|
||||||
|
server.listen(0, () => {
|
||||||
|
const port = server.address().port;
|
||||||
|
wsUrl = `ws://localhost:${port}/api/sessions/live`;
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll((done) => {
|
||||||
|
server.close(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeToken(name) {
|
||||||
|
return jwt.sign({ role: 'admin', name }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectAndAuth(name) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
ws.on('open', () => {
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: makeToken(name) }));
|
||||||
|
});
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
if (msg.type === 'auth_success') {
|
||||||
|
resolve(ws);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForMessage(ws, type) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const handler = (data) => {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
if (msg.type === type) {
|
||||||
|
ws.off('message', handler);
|
||||||
|
resolve(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.on('message', handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('page_focus triggers presence_update with admin name and page', async () => {
|
||||||
|
const ws1 = await connectAndAuth('Alice');
|
||||||
|
const ws2 = await connectAndAuth('Bob');
|
||||||
|
|
||||||
|
const presencePromise = waitForMessage(ws2, 'presence_update');
|
||||||
|
|
||||||
|
ws1.send(JSON.stringify({ type: 'page_focus', page: '/history' }));
|
||||||
|
|
||||||
|
const msg = await presencePromise;
|
||||||
|
expect(msg.admins).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'Alice', page: '/history' })
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
ws1.close();
|
||||||
|
ws2.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disconnect removes admin from presence', async () => {
|
||||||
|
const ws1 = await connectAndAuth('Alice');
|
||||||
|
const ws2 = await connectAndAuth('Bob');
|
||||||
|
|
||||||
|
ws1.send(JSON.stringify({ type: 'page_focus', page: '/picker' }));
|
||||||
|
ws2.send(JSON.stringify({ type: 'page_focus', page: '/picker' }));
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
|
const presencePromise = waitForMessage(ws2, 'presence_update');
|
||||||
|
ws1.close();
|
||||||
|
|
||||||
|
const msg = await presencePromise;
|
||||||
|
const names = msg.admins.map(a => a.name);
|
||||||
|
expect(names).not.toContain('Alice');
|
||||||
|
|
||||||
|
ws2.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
62
tests/api/regression-games.test.js
Normal file
62
tests/api/regression-games.test.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const { app } = require('../../backend/server');
|
||||||
|
const { cleanDb, seedGame } = require('../helpers/test-utils');
|
||||||
|
const db = require('../../backend/database');
|
||||||
|
|
||||||
|
describe('GET /api/games (regression)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns games with vote fields', async () => {
|
||||||
|
seedGame({
|
||||||
|
title: 'Quiplash 3',
|
||||||
|
upvotes: 10,
|
||||||
|
downvotes: 3,
|
||||||
|
popularity_score: 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/games');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveLength(1);
|
||||||
|
expect(res.body[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'Quiplash 3',
|
||||||
|
upvotes: 10,
|
||||||
|
downvotes: 3,
|
||||||
|
popularity_score: 7,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/games/:id returns vote fields', async () => {
|
||||||
|
const game = seedGame({
|
||||||
|
title: 'Drawful 2',
|
||||||
|
upvotes: 5,
|
||||||
|
downvotes: 2,
|
||||||
|
popularity_score: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/games/${game.id}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.upvotes).toBe(5);
|
||||||
|
expect(res.body.downvotes).toBe(2);
|
||||||
|
expect(res.body.popularity_score).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vote aggregates update correctly after recording votes', async () => {
|
||||||
|
const game = seedGame({ title: 'Fibbage 4' });
|
||||||
|
|
||||||
|
db.prepare('UPDATE games SET upvotes = upvotes + 1, popularity_score = popularity_score + 1 WHERE id = ?').run(game.id);
|
||||||
|
db.prepare('UPDATE games SET upvotes = upvotes + 1, popularity_score = popularity_score + 1 WHERE id = ?').run(game.id);
|
||||||
|
db.prepare('UPDATE games SET downvotes = downvotes + 1, popularity_score = popularity_score - 1 WHERE id = ?').run(game.id);
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/games/${game.id}`);
|
||||||
|
|
||||||
|
expect(res.body.upvotes).toBe(2);
|
||||||
|
expect(res.body.downvotes).toBe(1);
|
||||||
|
expect(res.body.popularity_score).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
73
tests/api/regression-sessions.test.js
Normal file
73
tests/api/regression-sessions.test.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const { app } = require('../../backend/server');
|
||||||
|
const { cleanDb, seedGame, seedSession, seedSessionGame } = require('../helpers/test-utils');
|
||||||
|
|
||||||
|
describe('GET /api/sessions (regression)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/sessions/:id returns session object with preview for unauthenticated', async () => {
|
||||||
|
const session = seedSession({ is_active: 1, notes: 'Test session' });
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/sessions/${session.id}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: session.id,
|
||||||
|
is_active: 1,
|
||||||
|
has_notes: true,
|
||||||
|
notes_preview: 'Test session',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.body.notes).toBeUndefined();
|
||||||
|
expect(res.body).toHaveProperty('games_played');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/sessions/:id returns 404 for nonexistent session', async () => {
|
||||||
|
const res = await request(app).get('/api/sessions/99999');
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toBe('Session not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/sessions/:id/games returns games with expected shape', async () => {
|
||||||
|
const game = seedGame({
|
||||||
|
title: 'Quiplash 3',
|
||||||
|
pack_name: 'Party Pack 7',
|
||||||
|
min_players: 3,
|
||||||
|
max_players: 8,
|
||||||
|
});
|
||||||
|
const session = seedSession({ is_active: 1 });
|
||||||
|
seedSessionGame(session.id, game.id, { status: 'playing' });
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/sessions/${session.id}/games`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveLength(1);
|
||||||
|
expect(res.body[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
game_id: game.id,
|
||||||
|
session_id: session.id,
|
||||||
|
pack_name: 'Party Pack 7',
|
||||||
|
title: 'Quiplash 3',
|
||||||
|
min_players: 3,
|
||||||
|
max_players: 8,
|
||||||
|
status: 'playing',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.body[0]).toHaveProperty('upvotes');
|
||||||
|
expect(res.body[0]).toHaveProperty('downvotes');
|
||||||
|
expect(res.body[0]).toHaveProperty('popularity_score');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/sessions/:id/games returns empty array for session with no games', async () => {
|
||||||
|
const session = seedSession({ is_active: 1 });
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/sessions/${session.id}/games`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
205
tests/api/regression-votes-live.test.js
Normal file
205
tests/api/regression-votes-live.test.js
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const { app } = require('../../backend/server');
|
||||||
|
const { getAuthHeader, cleanDb, seedGame, seedSession, seedSessionGame, db } = require('../helpers/test-utils');
|
||||||
|
|
||||||
|
describe('POST /api/votes/live (regression)', () => {
|
||||||
|
let game, session, sessionGame;
|
||||||
|
const baseTime = '2026-03-15T20:00:00.000Z';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
|
||||||
|
session = seedSession({ is_active: 1 });
|
||||||
|
sessionGame = seedSessionGame(session.id, game.id, {
|
||||||
|
status: 'playing',
|
||||||
|
played_at: baseTime,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 200 with correct response shape for upvote', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.session).toEqual(
|
||||||
|
expect.objectContaining({ id: session.id })
|
||||||
|
);
|
||||||
|
expect(res.body.game).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: game.id,
|
||||||
|
title: 'Quiplash 3',
|
||||||
|
upvotes: 1,
|
||||||
|
downvotes: 0,
|
||||||
|
popularity_score: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.body.vote).toEqual({
|
||||||
|
username: 'viewer1',
|
||||||
|
type: 'up',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const voteRow = db.prepare('SELECT * FROM live_votes WHERE username = ?').get('viewer1');
|
||||||
|
expect(voteRow).toBeDefined();
|
||||||
|
expect(voteRow.session_id).toBe(session.id);
|
||||||
|
expect(voteRow.game_id).toBe(game.id);
|
||||||
|
expect(voteRow.vote_type).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('increments downvotes and decrements popularity_score for downvote', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'down',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.game.downvotes).toBe(1);
|
||||||
|
expect(res.body.game.popularity_score).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 400 for missing username', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ vote: 'up', timestamp: '2026-03-15T20:05:00.000Z' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/Missing required fields/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 400 for missing vote', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ username: 'viewer1', timestamp: '2026-03-15T20:05:00.000Z' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 400 for missing timestamp', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ username: 'viewer1', vote: 'up' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 400 for invalid vote value', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'maybe',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/vote must be either/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 400 for invalid timestamp format', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: 'not-a-date',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/Invalid timestamp/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 404 when no active session', async () => {
|
||||||
|
cleanDb();
|
||||||
|
seedGame({ title: 'Unused' });
|
||||||
|
seedSession({ is_active: 0 });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toMatch(/No active session/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 404 when vote timestamp does not match any game', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2020-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toMatch(/does not match any game/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 409 for duplicate vote within 1 second', async () => {
|
||||||
|
await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'down',
|
||||||
|
timestamp: '2026-03-15T20:05:00.500Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.error).toMatch(/Duplicate vote/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 401 without auth token', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
153
tests/api/regression-websocket.test.js
Normal file
153
tests/api/regression-websocket.test.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
const WebSocket = require('ws');
|
||||||
|
const request = require('supertest');
|
||||||
|
const { app, server } = require('../../backend/server');
|
||||||
|
const { getAuthToken, getAuthHeader, cleanDb, seedGame, seedSession } = require('../helpers/test-utils');
|
||||||
|
|
||||||
|
function connectWs() {
|
||||||
|
return new WebSocket(`ws://localhost:${server.address().port}/api/sessions/live`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForMessage(ws, type, timeoutMs = 3000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => reject(new Error(`Timeout waiting for ${type}`)), timeoutMs);
|
||||||
|
ws.on('message', function handler(data) {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
if (msg.type === type) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
ws.removeListener('message', handler);
|
||||||
|
resolve(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticateAndSubscribe(ws, sessionId) {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
|
||||||
|
await waitForMessage(ws, 'auth_success');
|
||||||
|
ws.send(JSON.stringify({ type: 'subscribe', sessionId }));
|
||||||
|
await waitForMessage(ws, 'subscribed');
|
||||||
|
resolve();
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll((done) => {
|
||||||
|
server.listen(0, () => done());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll((done) => {
|
||||||
|
server.close(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WebSocket events (regression)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auth flow: auth -> auth_success', (done) => {
|
||||||
|
const ws = connectWs();
|
||||||
|
ws.on('open', () => {
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
|
||||||
|
});
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
if (msg.type === 'auth_success') {
|
||||||
|
ws.close();
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscribe/unsubscribe flow', async () => {
|
||||||
|
const session = seedSession({ is_active: 1 });
|
||||||
|
const ws = connectWs();
|
||||||
|
await new Promise((resolve) => ws.on('open', resolve));
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
|
||||||
|
await waitForMessage(ws, 'auth_success');
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'subscribe', sessionId: session.id }));
|
||||||
|
const subMsg = await waitForMessage(ws, 'subscribed');
|
||||||
|
expect(subMsg.sessionId).toBe(session.id);
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'unsubscribe', sessionId: session.id }));
|
||||||
|
const unsubMsg = await waitForMessage(ws, 'unsubscribed');
|
||||||
|
expect(unsubMsg.sessionId).toBe(session.id);
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session.started broadcasts to all authenticated clients', async () => {
|
||||||
|
const ws = connectWs();
|
||||||
|
await new Promise((resolve) => ws.on('open', resolve));
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
|
||||||
|
await waitForMessage(ws, 'auth_success');
|
||||||
|
|
||||||
|
const eventPromise = waitForMessage(ws, 'session.started');
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/sessions')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ notes: 'Test session' });
|
||||||
|
|
||||||
|
const event = await eventPromise;
|
||||||
|
expect(event.data.session).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
is_active: 1,
|
||||||
|
notes: 'Test session',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session.ended broadcasts to session subscribers', async () => {
|
||||||
|
const session = seedSession({ is_active: 1 });
|
||||||
|
const ws = connectWs();
|
||||||
|
await new Promise((resolve) => ws.on('open', resolve));
|
||||||
|
|
||||||
|
await authenticateAndSubscribe(ws, session.id);
|
||||||
|
|
||||||
|
const eventPromise = waitForMessage(ws, 'session.ended');
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/sessions/${session.id}/close`)
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
const event = await eventPromise;
|
||||||
|
expect(event.data.session.id).toBe(session.id);
|
||||||
|
expect(event.data.session.is_active).toBe(0);
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('game.added broadcasts to session subscribers', async () => {
|
||||||
|
const game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
|
||||||
|
const session = seedSession({ is_active: 1 });
|
||||||
|
const ws = connectWs();
|
||||||
|
await new Promise((resolve) => ws.on('open', resolve));
|
||||||
|
|
||||||
|
await authenticateAndSubscribe(ws, session.id);
|
||||||
|
|
||||||
|
const eventPromise = waitForMessage(ws, 'game.added');
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/sessions/${session.id}/games`)
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ game_id: game.id });
|
||||||
|
|
||||||
|
const event = await eventPromise;
|
||||||
|
expect(event.data.game.title).toBe('Quiplash 3');
|
||||||
|
expect(event.data.session.id).toBe(session.id);
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
405
tests/api/session-archive.test.js
Normal file
405
tests/api/session-archive.test.js
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const { app } = require('../../backend/server');
|
||||||
|
const { cleanDb, getAuthHeader, seedSession } = require('../helpers/test-utils');
|
||||||
|
|
||||||
|
describe('GET /api/sessions — filter and limit', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default filter excludes archived sessions', async () => {
|
||||||
|
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');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveLength(1);
|
||||||
|
expect(res.body[0].archived).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filter=archived returns only archived sessions', async () => {
|
||||||
|
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=archived');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveLength(1);
|
||||||
|
expect(res.body[0].archived).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filter=all returns all sessions', async () => {
|
||||||
|
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=all');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('limit restricts number of sessions returned', 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');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('limit=all returns all sessions', 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=all');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('X-Total-Count header reflects total matching sessions before limit', 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');
|
||||||
|
expect(res.headers['x-total-count']).toBe('10');
|
||||||
|
expect(res.body).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('response includes archived field on each session', async () => {
|
||||||
|
seedSession({ is_active: 0, notes: null });
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/sessions?filter=all');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body[0]).toHaveProperty('archived', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default limit is all when no limit param provided', async () => {
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
seedSession({ is_active: 0, notes: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/sessions?filter=all');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/sessions/:id/archive', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('archives a closed session', async () => {
|
||||||
|
const session = seedSession({ is_active: 0, notes: null });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/sessions/${session.id}/archive`)
|
||||||
|
.set('Authorization', getAuthHeader());
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
|
||||||
|
const check = await request(app).get(`/api/sessions/${session.id}`);
|
||||||
|
expect(check.body.archived).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 400 for active session', async () => {
|
||||||
|
const session = seedSession({ is_active: 1, notes: null });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/sessions/${session.id}/archive`)
|
||||||
|
.set('Authorization', getAuthHeader());
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 404 for non-existent session', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sessions/9999/archive')
|
||||||
|
.set('Authorization', getAuthHeader());
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 401 without auth', async () => {
|
||||||
|
const session = seedSession({ is_active: 0, notes: null });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/sessions/${session.id}/archive`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/sessions/:id/unarchive', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unarchives an archived session', async () => {
|
||||||
|
const session = seedSession({ is_active: 0, notes: null });
|
||||||
|
require('../helpers/test-utils').db.prepare(
|
||||||
|
'UPDATE sessions SET archived = 1 WHERE id = ?'
|
||||||
|
).run(session.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/sessions/${session.id}/unarchive`)
|
||||||
|
.set('Authorization', getAuthHeader());
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
|
||||||
|
const check = await request(app).get(`/api/sessions/${session.id}`);
|
||||||
|
expect(check.body.archived).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 404 for non-existent session', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sessions/9999/unarchive')
|
||||||
|
.set('Authorization', getAuthHeader());
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/sessions/bulk', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk archive multiple sessions', async () => {
|
||||||
|
const s1 = seedSession({ is_active: 0, notes: null });
|
||||||
|
const s2 = seedSession({ is_active: 0, notes: null });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sessions/bulk')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ action: 'archive', ids: [s1.id, s2.id] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.affected).toBe(2);
|
||||||
|
|
||||||
|
const list = await request(app).get('/api/sessions?filter=archived');
|
||||||
|
expect(list.body).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk unarchive multiple sessions', async () => {
|
||||||
|
const s1 = seedSession({ is_active: 0, notes: null });
|
||||||
|
const s2 = seedSession({ is_active: 0, notes: null });
|
||||||
|
const db = require('../helpers/test-utils').db;
|
||||||
|
db.prepare('UPDATE sessions SET archived = 1 WHERE id IN (?, ?)').run(s1.id, s2.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sessions/bulk')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ action: 'unarchive', ids: [s1.id, s2.id] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.affected).toBe(2);
|
||||||
|
|
||||||
|
const list = await request(app).get('/api/sessions?filter=all');
|
||||||
|
expect(list.body.every(s => s.archived === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk delete multiple sessions', async () => {
|
||||||
|
const s1 = seedSession({ is_active: 0, notes: null });
|
||||||
|
const s2 = seedSession({ is_active: 0, notes: null });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sessions/bulk')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ action: 'delete', ids: [s1.id, s2.id] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.affected).toBe(2);
|
||||||
|
|
||||||
|
const list = await request(app).get('/api/sessions?filter=all');
|
||||||
|
expect(list.body).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects archive of active sessions', async () => {
|
||||||
|
const active = seedSession({ is_active: 1, notes: null });
|
||||||
|
const closed = seedSession({ is_active: 0, notes: null });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sessions/bulk')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ action: 'archive', ids: [active.id, closed.id] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.activeIds).toContain(active.id);
|
||||||
|
|
||||||
|
const list = await request(app).get('/api/sessions?filter=all');
|
||||||
|
expect(list.body).toHaveLength(2);
|
||||||
|
expect(list.body.every(s => s.archived === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects delete of active sessions', async () => {
|
||||||
|
const active = seedSession({ is_active: 1, notes: null });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sessions/bulk')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ action: 'delete', ids: [active.id] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 400 for empty ids array', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sessions/bulk')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ action: 'archive', ids: [] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 400 for invalid action', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sessions/bulk')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ action: 'nuke', ids: [1] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 400 for non-array ids', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sessions/bulk')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ action: 'archive', ids: 'not-array' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 404 if any session ID does not exist', async () => {
|
||||||
|
const s1 = seedSession({ is_active: 0, notes: null });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sessions/bulk')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ action: 'archive', ids: [s1.id, 9999] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 401 without auth', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sessions/bulk')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ action: 'archive', ids: [1] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
264
tests/api/session-notes.test.js
Normal file
264
tests/api/session-notes.test.js
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const { app } = require('../../backend/server');
|
||||||
|
const { cleanDb, getAuthHeader, seedSession } = require('../helpers/test-utils');
|
||||||
|
const { computeNotesPreview } = require('../../backend/utils/notes-preview');
|
||||||
|
|
||||||
|
describe('computeNotesPreview', () => {
|
||||||
|
test('returns has_notes false and null preview for null input', () => {
|
||||||
|
const result = computeNotesPreview(null);
|
||||||
|
expect(result).toEqual({ has_notes: false, notes_preview: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns has_notes false and null preview for empty string', () => {
|
||||||
|
const result = computeNotesPreview('');
|
||||||
|
expect(result).toEqual({ has_notes: false, notes_preview: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns first paragraph as preview', () => {
|
||||||
|
const notes = 'First paragraph here.\n\nSecond paragraph here.';
|
||||||
|
const result = computeNotesPreview(notes);
|
||||||
|
expect(result.has_notes).toBe(true);
|
||||||
|
expect(result.notes_preview).toBe('First paragraph here.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips markdown bold formatting', () => {
|
||||||
|
const notes = '**Bold text** and more';
|
||||||
|
const result = computeNotesPreview(notes);
|
||||||
|
expect(result.notes_preview).toBe('Bold text and more');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips markdown italic formatting', () => {
|
||||||
|
const notes = '*Italic text* and _also italic_';
|
||||||
|
const result = computeNotesPreview(notes);
|
||||||
|
expect(result.notes_preview).toBe('Italic text and also italic');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips markdown links', () => {
|
||||||
|
const notes = 'Check [this link](http://example.com) out';
|
||||||
|
const result = computeNotesPreview(notes);
|
||||||
|
expect(result.notes_preview).toBe('Check this link out');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips markdown headers', () => {
|
||||||
|
const notes = '## Header text';
|
||||||
|
const result = computeNotesPreview(notes);
|
||||||
|
expect(result.notes_preview).toBe('Header text');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips markdown list markers', () => {
|
||||||
|
const notes = '- Item one\n- Item two';
|
||||||
|
const result = computeNotesPreview(notes);
|
||||||
|
expect(result.notes_preview).toBe('Item one Item two');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('truncates to 150 characters with ellipsis', () => {
|
||||||
|
const notes = 'A'.repeat(200);
|
||||||
|
const result = computeNotesPreview(notes);
|
||||||
|
expect(result.notes_preview).toHaveLength(153); // 150 + '...'
|
||||||
|
expect(result.notes_preview.endsWith('...')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not truncate text at or under 150 characters', () => {
|
||||||
|
const notes = 'A'.repeat(150);
|
||||||
|
const result = computeNotesPreview(notes);
|
||||||
|
expect(result.notes_preview).toHaveLength(150);
|
||||||
|
expect(result.notes_preview).not.toContain('...');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sessions list', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes has_notes and notes_preview in list response', async () => {
|
||||||
|
seedSession({ notes: '**Bold** first paragraph\n\nSecond paragraph' });
|
||||||
|
seedSession({ notes: null });
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/sessions');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveLength(2);
|
||||||
|
|
||||||
|
const withNotes = res.body.find(s => s.has_notes === true);
|
||||||
|
const withoutNotes = res.body.find(s => s.has_notes === false);
|
||||||
|
|
||||||
|
expect(withNotes.notes_preview).toBe('Bold first paragraph');
|
||||||
|
expect(withNotes).not.toHaveProperty('notes');
|
||||||
|
|
||||||
|
expect(withoutNotes.notes_preview).toBeNull();
|
||||||
|
expect(withoutNotes).not.toHaveProperty('notes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list response preserves existing fields', async () => {
|
||||||
|
seedSession({ is_active: 1, notes: 'Test' });
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/sessions');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body[0]).toHaveProperty('id');
|
||||||
|
expect(res.body[0]).toHaveProperty('created_at');
|
||||||
|
expect(res.body[0]).toHaveProperty('closed_at');
|
||||||
|
expect(res.body[0]).toHaveProperty('is_active');
|
||||||
|
expect(res.body[0]).toHaveProperty('games_played');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sessions/:id notes visibility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns full notes when authenticated', async () => {
|
||||||
|
const session = seedSession({ notes: '**Full notes** here\n\nSecond paragraph' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/sessions/${session.id}`)
|
||||||
|
.set('Authorization', getAuthHeader());
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.notes).toBe('**Full notes** here\n\nSecond paragraph');
|
||||||
|
expect(res.body.has_notes).toBe(true);
|
||||||
|
expect(res.body.notes_preview).toBe('Full notes here');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns only preview when unauthenticated', async () => {
|
||||||
|
const session = seedSession({ notes: '**Full notes** here\n\nSecond paragraph' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/sessions/${session.id}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.notes).toBeUndefined();
|
||||||
|
expect(res.body.has_notes).toBe(true);
|
||||||
|
expect(res.body.notes_preview).toBe('Full notes here');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns has_notes false when no notes', async () => {
|
||||||
|
const session = seedSession({ notes: null });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/sessions/${session.id}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.has_notes).toBe(false);
|
||||||
|
expect(res.body.notes_preview).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/sessions/:id/notes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates notes when authenticated', async () => {
|
||||||
|
const session = seedSession({ notes: 'Old notes' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/sessions/${session.id}/notes`)
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ notes: 'New notes here' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.notes).toBe('New notes here');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overwrites notes completely (no merge)', async () => {
|
||||||
|
const session = seedSession({ notes: 'Original notes' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/sessions/${session.id}/notes`)
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ notes: 'Replacement' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.notes).toBe('Replacement');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 404 for nonexistent session', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/sessions/99999/notes')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ notes: 'test' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 401 without auth header', async () => {
|
||||||
|
const session = seedSession({});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/sessions/${session.id}/notes`)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ notes: 'test' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 403 with invalid token', async () => {
|
||||||
|
const session = seedSession({});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/sessions/${session.id}/notes`)
|
||||||
|
.set('Authorization', 'Bearer invalid-token')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ notes: 'test' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/sessions/:id/notes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clears notes when authenticated', async () => {
|
||||||
|
const session = seedSession({ notes: 'Some notes' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/sessions/${session.id}/notes`)
|
||||||
|
.set('Authorization', getAuthHeader());
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify notes are actually cleared
|
||||||
|
const check = await request(app)
|
||||||
|
.get(`/api/sessions/${session.id}`)
|
||||||
|
.set('Authorization', getAuthHeader());
|
||||||
|
expect(check.body.notes).toBeNull();
|
||||||
|
expect(check.body.has_notes).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 404 for nonexistent session', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/sessions/99999/notes')
|
||||||
|
.set('Authorization', getAuthHeader());
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 401 without auth header', async () => {
|
||||||
|
const session = seedSession({ notes: 'test' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/sessions/${session.id}/notes`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 403 with invalid token', async () => {
|
||||||
|
const session = seedSession({ notes: 'test' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/sessions/${session.id}/notes`)
|
||||||
|
.set('Authorization', 'Bearer invalid-token');
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
94
tests/api/sessions-votes.test.js
Normal file
94
tests/api/sessions-votes.test.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const { app } = require('../../backend/server');
|
||||||
|
const { cleanDb, seedGame, seedSession, seedSessionGame, seedVote } = require('../helpers/test-utils');
|
||||||
|
|
||||||
|
describe('GET /api/sessions/:id/votes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns per-game vote breakdown for a session', async () => {
|
||||||
|
const game1 = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
|
||||||
|
const game2 = seedGame({ title: 'Drawful 2', pack_name: 'Party Pack 3' });
|
||||||
|
const session = seedSession({ is_active: 1 });
|
||||||
|
seedSessionGame(session.id, game1.id);
|
||||||
|
seedSessionGame(session.id, game2.id);
|
||||||
|
|
||||||
|
seedVote(session.id, game1.id, 'user1', 'up');
|
||||||
|
seedVote(session.id, game1.id, 'user2', 'up');
|
||||||
|
seedVote(session.id, game1.id, 'user3', 'down');
|
||||||
|
seedVote(session.id, game2.id, 'user1', 'down');
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/sessions/${session.id}/votes`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.session_id).toBe(session.id);
|
||||||
|
expect(res.body.votes).toHaveLength(2);
|
||||||
|
|
||||||
|
const q3 = res.body.votes.find((v) => v.game_id === game1.id);
|
||||||
|
expect(q3.title).toBe('Quiplash 3');
|
||||||
|
expect(q3.pack_name).toBe('Party Pack 7');
|
||||||
|
expect(q3.upvotes).toBe(2);
|
||||||
|
expect(q3.downvotes).toBe(1);
|
||||||
|
expect(q3.net_score).toBe(1);
|
||||||
|
expect(q3.total_votes).toBe(3);
|
||||||
|
|
||||||
|
const d2 = res.body.votes.find((v) => v.game_id === game2.id);
|
||||||
|
expect(d2.upvotes).toBe(0);
|
||||||
|
expect(d2.downvotes).toBe(1);
|
||||||
|
expect(d2.net_score).toBe(-1);
|
||||||
|
expect(d2.total_votes).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty votes array when session has no votes', async () => {
|
||||||
|
const session = seedSession({ is_active: 1 });
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/sessions/${session.id}/votes`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.session_id).toBe(session.id);
|
||||||
|
expect(res.body.votes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 404 for nonexistent session', async () => {
|
||||||
|
const res = await request(app).get('/api/sessions/99999/votes');
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toBe('Session not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only includes votes from the requested session', async () => {
|
||||||
|
const game = seedGame({ title: 'Quiplash 3' });
|
||||||
|
const session1 = seedSession({ is_active: 0 });
|
||||||
|
const session2 = seedSession({ is_active: 1 });
|
||||||
|
seedSessionGame(session1.id, game.id);
|
||||||
|
seedSessionGame(session2.id, game.id);
|
||||||
|
|
||||||
|
seedVote(session1.id, game.id, 'user1', 'up');
|
||||||
|
seedVote(session1.id, game.id, 'user2', 'up');
|
||||||
|
seedVote(session2.id, game.id, 'user3', 'down');
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/sessions/${session1.id}/votes`);
|
||||||
|
|
||||||
|
expect(res.body.votes).toHaveLength(1);
|
||||||
|
expect(res.body.votes[0].upvotes).toBe(2);
|
||||||
|
expect(res.body.votes[0].downvotes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('results are ordered by net_score descending', async () => {
|
||||||
|
const game1 = seedGame({ title: 'Good Game' });
|
||||||
|
const game2 = seedGame({ title: 'Bad Game' });
|
||||||
|
const session = seedSession({ is_active: 1 });
|
||||||
|
seedSessionGame(session.id, game1.id);
|
||||||
|
seedSessionGame(session.id, game2.id);
|
||||||
|
|
||||||
|
seedVote(session.id, game2.id, 'user1', 'down');
|
||||||
|
seedVote(session.id, game2.id, 'user2', 'down');
|
||||||
|
seedVote(session.id, game1.id, 'user1', 'up');
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/sessions/${session.id}/votes`);
|
||||||
|
|
||||||
|
expect(res.body.votes[0].title).toBe('Good Game');
|
||||||
|
expect(res.body.votes[1].title).toBe('Bad Game');
|
||||||
|
});
|
||||||
|
});
|
||||||
166
tests/api/votes-get.test.js
Normal file
166
tests/api/votes-get.test.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const { app } = require('../../backend/server');
|
||||||
|
const { cleanDb, seedGame, seedSession, seedSessionGame, seedVote } = require('../helpers/test-utils');
|
||||||
|
|
||||||
|
describe('GET /api/votes', () => {
|
||||||
|
let game1, game2, session;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
game1 = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
|
||||||
|
game2 = seedGame({ title: 'Drawful 2', pack_name: 'Party Pack 3' });
|
||||||
|
session = seedSession({ is_active: 1 });
|
||||||
|
seedSessionGame(session.id, game1.id);
|
||||||
|
seedSessionGame(session.id, game2.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns all votes with pagination metadata', async () => {
|
||||||
|
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||||
|
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/votes');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.votes).toHaveLength(2);
|
||||||
|
expect(res.body.pagination).toEqual({
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
total: 2,
|
||||||
|
total_pages: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns vote_type as "up"/"down" not raw integers', async () => {
|
||||||
|
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||||
|
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/votes');
|
||||||
|
|
||||||
|
const types = res.body.votes.map((v) => v.vote_type);
|
||||||
|
expect(types).toContain('up');
|
||||||
|
expect(types).toContain('down');
|
||||||
|
expect(types).not.toContain(1);
|
||||||
|
expect(types).not.toContain(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes game_title and pack_name via join', async () => {
|
||||||
|
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/votes');
|
||||||
|
|
||||||
|
expect(res.body.votes[0].game_title).toBe('Quiplash 3');
|
||||||
|
expect(res.body.votes[0].pack_name).toBe('Party Pack 7');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters by session_id', async () => {
|
||||||
|
const session2 = seedSession({ is_active: 0 });
|
||||||
|
seedSessionGame(session2.id, game1.id);
|
||||||
|
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||||
|
seedVote(session2.id, game1.id, 'user2', 'up', '2026-03-15T21:01:00.000Z');
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/votes?session_id=${session.id}`);
|
||||||
|
|
||||||
|
expect(res.body.votes).toHaveLength(1);
|
||||||
|
expect(res.body.votes[0].session_id).toBe(session.id);
|
||||||
|
expect(res.body.pagination.total).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters by game_id', async () => {
|
||||||
|
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||||
|
seedVote(session.id, game2.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/votes?game_id=${game1.id}`);
|
||||||
|
|
||||||
|
expect(res.body.votes).toHaveLength(1);
|
||||||
|
expect(res.body.votes[0].game_id).toBe(game1.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters by username', async () => {
|
||||||
|
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||||
|
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/votes?username=user1');
|
||||||
|
|
||||||
|
expect(res.body.votes).toHaveLength(1);
|
||||||
|
expect(res.body.votes[0].username).toBe('user1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters by vote_type', async () => {
|
||||||
|
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||||
|
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/votes?vote_type=up');
|
||||||
|
|
||||||
|
expect(res.body.votes).toHaveLength(1);
|
||||||
|
expect(res.body.votes[0].vote_type).toBe('up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('combines multiple filters', async () => {
|
||||||
|
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||||
|
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:02:00.000Z');
|
||||||
|
seedVote(session.id, game2.id, 'user1', 'up', '2026-03-15T20:03:00.000Z');
|
||||||
|
|
||||||
|
const res = await request(app).get(
|
||||||
|
`/api/votes?game_id=${game1.id}&username=user1`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.body.votes).toHaveLength(1);
|
||||||
|
expect(res.body.votes[0].username).toBe('user1');
|
||||||
|
expect(res.body.votes[0].game_id).toBe(game1.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('respects page and limit', async () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
seedVote(session.id, game1.id, `user${i}`, 'up', `2026-03-15T20:0${i}:00.000Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/votes?page=2&limit=2');
|
||||||
|
|
||||||
|
expect(res.body.votes).toHaveLength(2);
|
||||||
|
expect(res.body.pagination).toEqual({
|
||||||
|
page: 2,
|
||||||
|
limit: 2,
|
||||||
|
total: 5,
|
||||||
|
total_pages: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caps limit at 100', async () => {
|
||||||
|
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/votes?limit=500');
|
||||||
|
|
||||||
|
expect(res.body.pagination.limit).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 200 with empty array when no votes match', async () => {
|
||||||
|
const res = await request(app).get('/api/votes?username=nonexistent');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.votes).toEqual([]);
|
||||||
|
expect(res.body.pagination.total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 400 for invalid session_id', async () => {
|
||||||
|
const res = await request(app).get('/api/votes?session_id=abc');
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 400 for invalid vote_type', async () => {
|
||||||
|
const res = await request(app).get('/api/votes?vote_type=maybe');
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('orders by timestamp descending', async () => {
|
||||||
|
seedVote(session.id, game1.id, 'user1', 'up', '2026-03-15T20:01:00.000Z');
|
||||||
|
seedVote(session.id, game1.id, 'user2', 'down', '2026-03-15T20:05:00.000Z');
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/votes');
|
||||||
|
|
||||||
|
const timestamps = res.body.votes.map((v) => v.timestamp);
|
||||||
|
expect(timestamps[0]).toBe('2026-03-15T20:05:00.000Z');
|
||||||
|
expect(timestamps[1]).toBe('2026-03-15T20:01:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
165
tests/api/votes-live-e2e.test.js
Normal file
165
tests/api/votes-live-e2e.test.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const { app } = require('../../backend/server');
|
||||||
|
const { getAuthHeader, cleanDb, seedGame, seedSession, seedSessionGame, db } = require('../helpers/test-utils');
|
||||||
|
|
||||||
|
describe('POST /api/votes/live -> read-back (end-to-end)', () => {
|
||||||
|
let game1, game2, session;
|
||||||
|
const baseTime = '2026-03-15T20:00:00.000Z';
|
||||||
|
const game2Time = '2026-03-15T20:30:00.000Z';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
game1 = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
|
||||||
|
game2 = seedGame({ title: 'Drawful 2', pack_name: 'Party Pack 3' });
|
||||||
|
session = seedSession({ is_active: 1 });
|
||||||
|
seedSessionGame(session.id, game1.id, { status: 'played', played_at: baseTime });
|
||||||
|
seedSessionGame(session.id, game2.id, { status: 'playing', played_at: game2Time });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vote via POST populates live_votes table (direct DB check)', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:35:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT * FROM live_votes WHERE session_id = ? AND game_id = ? AND username = ?'
|
||||||
|
).get(session.id, game2.id, 'viewer1');
|
||||||
|
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row.session_id).toBe(session.id);
|
||||||
|
expect(row.game_id).toBe(game2.id);
|
||||||
|
expect(row.username).toBe('viewer1');
|
||||||
|
expect(row.vote_type).toBe(1);
|
||||||
|
expect(row.timestamp).toBe('2026-03-15T20:35:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vote via POST is visible in GET /api/sessions/:id/votes', async () => {
|
||||||
|
await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:35:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer2',
|
||||||
|
vote: 'down',
|
||||||
|
timestamp: '2026-03-15T20:36:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/sessions/${session.id}/votes`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.session_id).toBe(session.id);
|
||||||
|
expect(res.body.votes).toHaveLength(1);
|
||||||
|
|
||||||
|
const gameVotes = res.body.votes[0];
|
||||||
|
expect(gameVotes.game_id).toBe(game2.id);
|
||||||
|
expect(gameVotes.title).toBe('Drawful 2');
|
||||||
|
expect(gameVotes.upvotes).toBe(1);
|
||||||
|
expect(gameVotes.downvotes).toBe(1);
|
||||||
|
expect(gameVotes.net_score).toBe(0);
|
||||||
|
expect(gameVotes.total_votes).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vote via POST is visible in GET /api/votes', async () => {
|
||||||
|
await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:35:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/votes?session_id=${session.id}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.votes).toHaveLength(1);
|
||||||
|
expect(res.body.votes[0].username).toBe('viewer1');
|
||||||
|
expect(res.body.votes[0].vote_type).toBe('up');
|
||||||
|
expect(res.body.votes[0].game_id).toBe(game2.id);
|
||||||
|
expect(res.body.votes[0].game_title).toBe('Drawful 2');
|
||||||
|
expect(res.body.votes[0].session_id).toBe(session.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('votes for different games in same session are tracked separately', async () => {
|
||||||
|
await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:10:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer2',
|
||||||
|
vote: 'down',
|
||||||
|
timestamp: '2026-03-15T20:35:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/sessions/${session.id}/votes`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.votes).toHaveLength(2);
|
||||||
|
|
||||||
|
const q3 = res.body.votes.find((v) => v.game_id === game1.id);
|
||||||
|
expect(q3.upvotes).toBe(1);
|
||||||
|
expect(q3.downvotes).toBe(0);
|
||||||
|
|
||||||
|
const d2 = res.body.votes.find((v) => v.game_id === game2.id);
|
||||||
|
expect(d2.upvotes).toBe(0);
|
||||||
|
expect(d2.downvotes).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live_votes row count matches number of accepted votes', async () => {
|
||||||
|
const timestamps = [
|
||||||
|
'2026-03-15T20:35:00.000Z',
|
||||||
|
'2026-03-15T20:36:05.000Z',
|
||||||
|
'2026-03-15T20:37:10.000Z',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < timestamps.length; i++) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: `viewer${i}`,
|
||||||
|
vote: i % 2 === 0 ? 'up' : 'down',
|
||||||
|
timestamp: timestamps[i],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = db.prepare(
|
||||||
|
'SELECT COUNT(*) as cnt FROM live_votes WHERE session_id = ?'
|
||||||
|
).get(session.id);
|
||||||
|
expect(count.cnt).toBe(3);
|
||||||
|
|
||||||
|
const sessionVotes = await request(app).get(`/api/sessions/${session.id}/votes`);
|
||||||
|
expect(sessionVotes.body.votes[0].total_votes).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
166
tests/api/votes-live-websocket.test.js
Normal file
166
tests/api/votes-live-websocket.test.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
const WebSocket = require('ws');
|
||||||
|
const request = require('supertest');
|
||||||
|
const { app, server } = require('../../backend/server');
|
||||||
|
const { getAuthToken, getAuthHeader, cleanDb, seedGame, seedSession, seedSessionGame } = require('../helpers/test-utils');
|
||||||
|
|
||||||
|
function connectWs() {
|
||||||
|
return new WebSocket(`ws://localhost:${server.address().port}/api/sessions/live`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForMessage(ws, type, timeoutMs = 3000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => reject(new Error(`Timeout waiting for ${type}`)), timeoutMs);
|
||||||
|
ws.on('message', function handler(data) {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
if (msg.type === type) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
ws.removeListener('message', handler);
|
||||||
|
resolve(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll((done) => {
|
||||||
|
server.listen(0, () => done());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll((done) => {
|
||||||
|
server.close(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('vote.received WebSocket event', () => {
|
||||||
|
const baseTime = '2026-03-15T20:00:00.000Z';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('broadcasts vote.received to session subscribers on live vote', async () => {
|
||||||
|
const game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
|
||||||
|
const session = seedSession({ is_active: 1 });
|
||||||
|
seedSessionGame(session.id, game.id, { status: 'playing', played_at: baseTime });
|
||||||
|
|
||||||
|
const ws = connectWs();
|
||||||
|
await new Promise((resolve) => ws.on('open', resolve));
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
|
||||||
|
await waitForMessage(ws, 'auth_success');
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'subscribe', sessionId: session.id }));
|
||||||
|
await waitForMessage(ws, 'subscribed');
|
||||||
|
|
||||||
|
const eventPromise = waitForMessage(ws, 'vote.received');
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = await eventPromise;
|
||||||
|
|
||||||
|
expect(event.data.sessionId).toBe(session.id);
|
||||||
|
expect(event.data.game).toEqual({
|
||||||
|
id: game.id,
|
||||||
|
title: 'Quiplash 3',
|
||||||
|
pack_name: 'Party Pack 7',
|
||||||
|
});
|
||||||
|
expect(event.data.vote).toEqual({
|
||||||
|
username: 'viewer1',
|
||||||
|
type: 'up',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
});
|
||||||
|
expect(event.data.totals).toEqual({
|
||||||
|
upvotes: 1,
|
||||||
|
downvotes: 0,
|
||||||
|
popularity_score: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not broadcast on duplicate vote (409)', async () => {
|
||||||
|
const game = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7' });
|
||||||
|
const session = seedSession({ is_active: 1 });
|
||||||
|
seedSessionGame(session.id, game.id, { status: 'playing', played_at: baseTime });
|
||||||
|
|
||||||
|
const ws = connectWs();
|
||||||
|
await new Promise((resolve) => ws.on('open', resolve));
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
|
||||||
|
await waitForMessage(ws, 'auth_success');
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'subscribe', sessionId: session.id }));
|
||||||
|
await waitForMessage(ws, 'subscribed');
|
||||||
|
|
||||||
|
// First vote succeeds - set up listener before POST to catch the event
|
||||||
|
const firstEventPromise = waitForMessage(ws, 'vote.received');
|
||||||
|
await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
});
|
||||||
|
await firstEventPromise;
|
||||||
|
|
||||||
|
// Duplicate vote (within 1 second)
|
||||||
|
const dupRes = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'down',
|
||||||
|
timestamp: '2026-03-15T20:05:00.500Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dupRes.status).toBe(409);
|
||||||
|
|
||||||
|
// Verify no vote.received event comes (wait briefly)
|
||||||
|
const noEvent = await Promise.race([
|
||||||
|
waitForMessage(ws, 'vote.received', 500).then(() => 'received').catch(() => 'timeout'),
|
||||||
|
new Promise((resolve) => setTimeout(() => resolve('timeout'), 500)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(noEvent).toBe('timeout');
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not broadcast when no active session (404)', async () => {
|
||||||
|
const ws = connectWs();
|
||||||
|
await new Promise((resolve) => ws.on('open', resolve));
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
|
||||||
|
await waitForMessage(ws, 'auth_success');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer1',
|
||||||
|
vote: 'up',
|
||||||
|
timestamp: '2026-03-15T20:05:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
|
||||||
|
const noEvent = await Promise.race([
|
||||||
|
waitForMessage(ws, 'vote.received', 500).then(() => 'received').catch(() => 'timeout'),
|
||||||
|
new Promise((resolve) => setTimeout(() => resolve('timeout'), 500)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(noEvent).toBe('timeout');
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
81
tests/helpers/test-utils.js
Normal file
81
tests/helpers/test-utils.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const db = require('../../backend/database');
|
||||||
|
|
||||||
|
function getAuthToken() {
|
||||||
|
return jwt.sign({ role: 'admin', name: 'TestAdmin' }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthHeader() {
|
||||||
|
return `Bearer ${getAuthToken()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanDb() {
|
||||||
|
db.exec('DELETE FROM live_votes');
|
||||||
|
db.exec('DELETE FROM chat_logs');
|
||||||
|
db.exec('DELETE FROM session_games');
|
||||||
|
db.exec('DELETE FROM sessions');
|
||||||
|
db.exec('DELETE FROM webhook_logs');
|
||||||
|
db.exec('DELETE FROM webhooks');
|
||||||
|
db.exec('DELETE FROM games');
|
||||||
|
db.exec('DELETE FROM packs');
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedGame(overrides = {}) {
|
||||||
|
const defaults = {
|
||||||
|
pack_name: 'Party Pack 7',
|
||||||
|
title: 'Quiplash 3',
|
||||||
|
min_players: 3,
|
||||||
|
max_players: 8,
|
||||||
|
length_minutes: 15,
|
||||||
|
has_audience: 1,
|
||||||
|
family_friendly: 1,
|
||||||
|
game_type: 'Writing',
|
||||||
|
enabled: 1,
|
||||||
|
upvotes: 0,
|
||||||
|
downvotes: 0,
|
||||||
|
popularity_score: 0,
|
||||||
|
};
|
||||||
|
const g = { ...defaults, ...overrides };
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO games (pack_name, title, min_players, max_players, length_minutes, has_audience, family_friendly, game_type, enabled, upvotes, downvotes, popularity_score)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(g.pack_name, g.title, g.min_players, g.max_players, g.length_minutes, g.has_audience, g.family_friendly, g.game_type, g.enabled, g.upvotes, g.downvotes, g.popularity_score);
|
||||||
|
return db.prepare('SELECT * FROM games WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedSession(overrides = {}) {
|
||||||
|
const defaults = { is_active: 1, notes: null };
|
||||||
|
const s = { ...defaults, ...overrides };
|
||||||
|
const result = db.prepare('INSERT INTO sessions (is_active, notes) VALUES (?, ?)').run(s.is_active, s.notes);
|
||||||
|
return db.prepare('SELECT * FROM sessions WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedSessionGame(sessionId, gameId, overrides = {}) {
|
||||||
|
const defaults = { status: 'playing', played_at: new Date().toISOString() };
|
||||||
|
const sg = { ...defaults, ...overrides };
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO session_games (session_id, game_id, status, played_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(sessionId, gameId, sg.status, sg.played_at);
|
||||||
|
return db.prepare('SELECT * FROM session_games WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedVote(sessionId, gameId, username, voteType, timestamp) {
|
||||||
|
const vt = voteType === 'up' ? 1 : -1;
|
||||||
|
const ts = timestamp || new Date().toISOString();
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO live_votes (session_id, game_id, username, vote_type, timestamp)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(sessionId, gameId, username, vt, ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAuthToken,
|
||||||
|
getAuthHeader,
|
||||||
|
cleanDb,
|
||||||
|
seedGame,
|
||||||
|
seedSession,
|
||||||
|
seedSessionGame,
|
||||||
|
seedVote,
|
||||||
|
db,
|
||||||
|
};
|
||||||
4
tests/jest.setup.js
Normal file
4
tests/jest.setup.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
process.env.DB_PATH = ':memory:';
|
||||||
|
process.env.JWT_SECRET = 'test-jwt-secret-do-not-use-in-prod';
|
||||||
|
process.env.ADMIN_KEY = 'test-admin-key';
|
||||||
|
process.env.PORT = '0';
|
||||||
Reference in New Issue
Block a user