Compare commits
33 Commits
512b36da51
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195448644a
|
||
|
|
c5ffe23404
|
||
|
|
2964cee291
|
||
|
|
91b7de3bb7
|
||
|
|
ea23b66cbf
|
||
|
|
ea6e8db90b
|
||
|
|
b2bb2989e9
|
||
|
|
52e9a7af42
|
||
|
|
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
|
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.
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -21,6 +21,7 @@ frontend/public/manifest.json
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
logs/
|
||||||
|
|
||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -39,8 +40,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.
|
||||||
264
README.md
264
README.md
@@ -1,6 +1,9 @@
|
|||||||
|
> [!IMPORTANT]
|
||||||
|
> This project was developed entirely with AI coding assistance (Claude Opus 4.6 via Cursor IDE) and has not undergone rigorous review. It is provided as-is and may require adjustments for other environments.
|
||||||
|
|
||||||
# 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,26 +16,37 @@ 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
|
||||||
@@ -50,16 +64,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
|
||||||
|
|
||||||
@@ -78,13 +94,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
|
||||||
@@ -96,7 +111,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.
|
||||||
|
|
||||||
@@ -148,10 +163,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:
|
||||||
@@ -176,41 +213,91 @@ 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
|
||||||
|
|
||||||
```
|
```
|
||||||
/
|
/
|
||||||
├── 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/
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── pages/ # React page components
|
│ │ ├── pages/ # React page components
|
||||||
│ │ │ ├── Home.jsx
|
│ │ │ ├── Home.jsx
|
||||||
│ │ │ ├── Login.jsx
|
│ │ │ ├── Login.jsx
|
||||||
│ │ │ ├── Picker.jsx
|
│ │ │ ├── Picker.jsx
|
||||||
│ │ │ ├── Manager.jsx
|
│ │ │ ├── Manager.jsx
|
||||||
│ │ │ └── History.jsx
|
│ │ │ ├── History.jsx
|
||||||
│ │ ├── context/ # React context providers
|
│ │ │ └── SessionDetail.jsx
|
||||||
│ │ │ └── AuthContext.jsx
|
│ │ ├── components/ # Reusable UI components
|
||||||
│ │ ├── api/ # API client
|
│ │ │ ├── PresenceBar.jsx
|
||||||
|
│ │ │ ├── Toast.jsx
|
||||||
|
│ │ │ ├── ThemeToggle.jsx
|
||||||
|
│ │ │ ├── Logo.jsx
|
||||||
|
│ │ │ ├── RoomCodeModal.jsx
|
||||||
|
│ │ │ ├── GamePoolModal.jsx
|
||||||
|
│ │ │ ├── PopularityBadge.jsx
|
||||||
|
│ │ │ ├── InstallPrompt.jsx
|
||||||
|
│ │ │ └── SafariInstallPrompt.jsx
|
||||||
|
│ │ ├── context/ # React context providers
|
||||||
|
│ │ │ ├── AuthContext.jsx
|
||||||
|
│ │ │ └── ThemeContext.jsx
|
||||||
|
│ │ ├── hooks/ # Custom React hooks
|
||||||
|
│ │ │ └── usePresence.js
|
||||||
|
│ │ ├── config/ # Frontend configuration
|
||||||
|
│ │ │ └── branding.js
|
||||||
|
│ │ ├── api/ # API client
|
||||||
│ │ │ └── axios.js
|
│ │ │ └── axios.js
|
||||||
│ │ ├── App.jsx
|
│ │ ├── App.jsx
|
||||||
│ │ ├── main.jsx
|
│ │ ├── main.jsx
|
||||||
@@ -219,44 +306,74 @@ 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
|
||||||
- `GET /api/sessions/:id/votes` - Get per-game vote breakdown for a 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
|
||||||
@@ -274,6 +391,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
|
||||||
@@ -294,9 +414,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
|
||||||
|
|
||||||
@@ -322,6 +443,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:
|
||||||
@@ -332,12 +457,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
|
||||||
@@ -374,10 +499,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.
|
||||||
|
|
||||||
@@ -386,13 +511,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
|
||||||
@@ -422,8 +551,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
|
||||||
@@ -446,36 +577,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
|
||||||
@@ -489,4 +612,3 @@ MIT
|
|||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Feel free to submit issues and pull requests!
|
Feel free to submit issues and pull requests!
|
||||||
|
|
||||||
|
|||||||
13
backend/.dockerignore
Normal file
13
backend/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
data/
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
config/admins.json
|
||||||
|
|
||||||
29
backend/bootstrap.js
vendored
29
backend/bootstrap.js
vendored
@@ -54,6 +54,33 @@ function bootstrapGames() {
|
|||||||
console.log(`Successfully imported ${records.length} games from CSV`);
|
console.log(`Successfully imported ${records.length} games from CSV`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bootstrapTickers() {
|
||||||
|
const tickersPath = path.join(__dirname, 'config', 'tickers.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(tickersPath)) {
|
||||||
|
console.log('tickers.json not found. Skipping ticker bootstrap.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickers = JSON.parse(fs.readFileSync(tickersPath, 'utf-8'));
|
||||||
|
|
||||||
|
const update = db.prepare('UPDATE games SET ticker = ? WHERE title = ? AND (ticker IS NULL OR ticker != ?)');
|
||||||
|
|
||||||
|
const updateMany = db.transaction((entries) => {
|
||||||
|
let updated = 0;
|
||||||
|
for (const [symbol, title] of entries) {
|
||||||
|
const result = update.run(symbol, title, symbol);
|
||||||
|
updated += result.changes;
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = updateMany(Object.entries(tickers));
|
||||||
|
if (updated > 0) {
|
||||||
|
console.log(`Updated ticker symbols for ${updated} games`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseLengthMinutes(lengthStr) {
|
function parseLengthMinutes(lengthStr) {
|
||||||
if (!lengthStr || lengthStr === '????' || lengthStr === '?') {
|
if (!lengthStr || lengthStr === '????' || lengthStr === '?') {
|
||||||
return null;
|
return null;
|
||||||
@@ -69,5 +96,5 @@ function parseBoolean(value) {
|
|||||||
return value.toLowerCase() === 'yes' ? 1 : 0;
|
return value.toLowerCase() === 'yes' ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { bootstrapGames };
|
module.exports = { bootstrapGames, bootstrapTickers };
|
||||||
|
|
||||||
|
|||||||
5
backend/config/admins.example.json
Normal file
5
backend/config/admins.example.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[
|
||||||
|
{ "name": "Alice", "role": "admin", "key": "change-me-alice-key" },
|
||||||
|
{ "name": "Bob", "role": "bot", "key": "change-me-bob-key" },
|
||||||
|
{ "name": "Charlie", "role": "utility", "key": "change-me-charlie-key" }
|
||||||
|
]
|
||||||
68
backend/config/load-admins.js
Normal file
68
backend/config/load-admins.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG_PATH = path.join(__dirname, 'admins.json');
|
||||||
|
|
||||||
|
function canRead(filePath) {
|
||||||
|
try {
|
||||||
|
fs.accessSync(filePath, fs.constants.R_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAdmins() {
|
||||||
|
const configPath = process.env.ADMIN_CONFIG_PATH || DEFAULT_CONFIG_PATH;
|
||||||
|
|
||||||
|
if (canRead(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 (fs.existsSync(configPath) && !canRead(configPath)) {
|
||||||
|
console.warn(`[Auth] Config file exists at ${configPath} but is not readable, skipping`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, role: match.role || 'admin' } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { findAdminByKey, admins };
|
||||||
60
backend/config/tickers.json
Normal file
60
backend/config/tickers.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"QPL3": "Quiplash 3",
|
||||||
|
"QPL2": "Quiplash 2",
|
||||||
|
"QLXL": "Quiplash XL",
|
||||||
|
"FBXL": "Fibbage XL",
|
||||||
|
"FBG2": "Fibbage 2",
|
||||||
|
"FBG3": "Fibbage 3",
|
||||||
|
"FBG4": "Fibbage 4",
|
||||||
|
"TMP1": "Trivia Murder Party",
|
||||||
|
"TMP2": "Trivia Murder Party 2",
|
||||||
|
"DRWF": "Drawful",
|
||||||
|
"DRWA": "Drawful Animate",
|
||||||
|
"DD": "Dirty Drawful",
|
||||||
|
"DOOM": "Doominate",
|
||||||
|
"JJ": "Job Job",
|
||||||
|
"TKO2": "Tee K.O. 2",
|
||||||
|
"TKOX": "Tee K.O. T-Shirt Knock Out",
|
||||||
|
"CU": "Champ'd Up",
|
||||||
|
"BR": "Blather 'Round",
|
||||||
|
"STR": "Split the Room",
|
||||||
|
"ROOM": "Roomerang",
|
||||||
|
"BRKT": "Bracketeering",
|
||||||
|
"NNSR": "Nonsensory",
|
||||||
|
"QXRT": "Quixort",
|
||||||
|
"JNKT": "Junktopia",
|
||||||
|
"TP": "Talking Points",
|
||||||
|
"PS": "Patently Stupid",
|
||||||
|
"PTB": "Push the Button",
|
||||||
|
"WD": "Weapons Drawn",
|
||||||
|
"HPNT": "Hypnotorious",
|
||||||
|
"DCTN": "Dictionarium",
|
||||||
|
"RM": "Role Models",
|
||||||
|
"JB": "Joke Boat",
|
||||||
|
"GSPN": "Guesspionage",
|
||||||
|
"MVC": "Mad Verse City",
|
||||||
|
"HRSY": "Hear Say",
|
||||||
|
"CH": "Cookie Haus",
|
||||||
|
"SPCT": "Suspectives",
|
||||||
|
"LOT": "Legends of Trivia",
|
||||||
|
"STI": "Survive the Internet",
|
||||||
|
"CVDL": "Civic Doodle",
|
||||||
|
"MSM": "Monster Seeking Monster",
|
||||||
|
"TPM": "The Poll Mine",
|
||||||
|
"TWEP": "The Wheel of Enormous Proportions",
|
||||||
|
"TJ": "Time Jinx",
|
||||||
|
"DRM": "Dodo Re Mi",
|
||||||
|
"FT": "Fixy Text",
|
||||||
|
"SS": "Survey Scramble",
|
||||||
|
"WS": "Word Spud",
|
||||||
|
"LS": "Lie Swatter",
|
||||||
|
"FI": "Fakin' It!",
|
||||||
|
"FANL": "Fakin' It All Night Long",
|
||||||
|
"LMF": "Let Me Finish",
|
||||||
|
"BDTS": "Bidiots",
|
||||||
|
"BC": "Bomb Corp.",
|
||||||
|
"YDK1": "You Don't Know Jack\u00ae 2015",
|
||||||
|
"YDKJ": "You Don't Know Jack\u00ae Full Stream",
|
||||||
|
"ZPDM": "Zeeple Dome",
|
||||||
|
"EW": "Earwax\u2122"
|
||||||
|
}
|
||||||
@@ -125,6 +125,19 @@ function initializeDatabase() {
|
|||||||
// Column already exists, ignore error
|
// Column already exists, ignore error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add ticker column for ticker-symbol voting
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE games ADD COLUMN ticker TEXT`);
|
||||||
|
} catch (err) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_games_ticker ON games(ticker)`);
|
||||||
|
} catch (err) {
|
||||||
|
// Index already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
// Migrate existing popularity_score to upvotes/downvotes if needed
|
// Migrate existing popularity_score to upvotes/downvotes if needed
|
||||||
try {
|
try {
|
||||||
const gamesWithScore = db.prepare(`
|
const gamesWithScore = db.prepare(`
|
||||||
|
|||||||
@@ -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,31 @@ 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.role, name: admin.name, timestamp: Date.now() },
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: '24h' }
|
{ expiresIn: '24h' }
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
token,
|
token,
|
||||||
|
name: admin.name,
|
||||||
|
role: admin.role,
|
||||||
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 +44,3 @@ router.post('/verify', authenticateToken, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ router.get('/', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const filter = req.query.filter || 'default';
|
const filter = req.query.filter || 'default';
|
||||||
const limitParam = req.query.limit || 'all';
|
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 = '';
|
let whereClause = '';
|
||||||
if (filter === 'default') {
|
if (filter === 'default') {
|
||||||
@@ -45,6 +48,11 @@ router.get('/', (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let offsetClause = '';
|
||||||
|
if (offset > 0) {
|
||||||
|
offsetClause = `OFFSET ${offset}`;
|
||||||
|
}
|
||||||
|
|
||||||
const sessions = db.prepare(`
|
const sessions = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
s.id,
|
s.id,
|
||||||
@@ -60,6 +68,7 @@ router.get('/', (req, res) => {
|
|||||||
GROUP BY s.id
|
GROUP BY s.id
|
||||||
ORDER BY s.created_at DESC
|
ORDER BY s.created_at DESC
|
||||||
${limitClause}
|
${limitClause}
|
||||||
|
${offsetClause}
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
const result = sessions.map(({ notes, ...session }) => {
|
const result = sessions.map(({ notes, ...session }) => {
|
||||||
@@ -67,7 +76,23 @@ router.get('/', (req, res) => {
|
|||||||
return { ...session, has_notes, notes_preview };
|
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-Total-Count', String(countRow.total));
|
||||||
|
res.set('X-Absolute-Total', String(absoluteTotal.total));
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ router.get('/', (req, res) => {
|
|||||||
// 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 {
|
||||||
const { username, vote, timestamp } = req.body;
|
const { username, vote, timestamp, ticker } = req.body;
|
||||||
|
|
||||||
// Validate payload
|
// Validate payload
|
||||||
if (!username || !vote || !timestamp) {
|
if (!username || !vote || !timestamp) {
|
||||||
@@ -123,57 +123,72 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all games played in this session with timestamps
|
|
||||||
const sessionGames = db.prepare(`
|
|
||||||
SELECT sg.game_id, sg.played_at, g.title, g.pack_name, g.upvotes, g.downvotes, g.popularity_score
|
|
||||||
FROM session_games sg
|
|
||||||
JOIN games g ON sg.game_id = g.id
|
|
||||||
WHERE sg.session_id = ?
|
|
||||||
ORDER BY sg.played_at ASC
|
|
||||||
`).all(activeSession.id);
|
|
||||||
|
|
||||||
if (sessionGames.length === 0) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'No games have been played in the active session yet'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match vote timestamp to the correct game using interval logic
|
|
||||||
const voteTime = voteTimestamp.getTime();
|
|
||||||
let matchedGame = null;
|
let matchedGame = null;
|
||||||
|
|
||||||
for (let i = 0; i < sessionGames.length; i++) {
|
if (ticker) {
|
||||||
const currentGame = sessionGames[i];
|
// Ticker voting: resolve game globally by ticker symbol
|
||||||
const nextGame = sessionGames[i + 1];
|
const game = db.prepare(`
|
||||||
|
SELECT id AS game_id, title, pack_name, upvotes, downvotes, popularity_score
|
||||||
|
FROM games WHERE ticker = ?
|
||||||
|
`).get(ticker);
|
||||||
|
|
||||||
const currentGameTime = new Date(currentGame.played_at).getTime();
|
if (!game) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: `Unknown ticker '${ticker}'`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (nextGame) {
|
matchedGame = game;
|
||||||
const nextGameTime = new Date(nextGame.played_at).getTime();
|
} else {
|
||||||
if (voteTime >= currentGameTime && voteTime < nextGameTime) {
|
// thisgame++/thisgame-- voting: resolve game by timestamp interval
|
||||||
matchedGame = currentGame;
|
const sessionGames = db.prepare(`
|
||||||
break;
|
SELECT sg.game_id, sg.played_at, g.title, g.pack_name, g.upvotes, g.downvotes, g.popularity_score
|
||||||
}
|
FROM session_games sg
|
||||||
} else {
|
JOIN games g ON sg.game_id = g.id
|
||||||
// Last game in session - vote belongs here if timestamp is after this game started
|
WHERE sg.session_id = ?
|
||||||
if (voteTime >= currentGameTime) {
|
ORDER BY sg.played_at ASC
|
||||||
matchedGame = currentGame;
|
`).all(activeSession.id);
|
||||||
break;
|
|
||||||
|
if (sessionGames.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'No games have been played in the active session yet'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const voteTime = voteTimestamp.getTime();
|
||||||
|
|
||||||
|
for (let i = 0; i < sessionGames.length; i++) {
|
||||||
|
const currentGame = sessionGames[i];
|
||||||
|
const nextGame = sessionGames[i + 1];
|
||||||
|
|
||||||
|
const currentGameTime = new Date(currentGame.played_at).getTime();
|
||||||
|
|
||||||
|
if (nextGame) {
|
||||||
|
const nextGameTime = new Date(nextGame.played_at).getTime();
|
||||||
|
if (voteTime >= currentGameTime && voteTime < nextGameTime) {
|
||||||
|
matchedGame = currentGame;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (voteTime >= currentGameTime) {
|
||||||
|
matchedGame = currentGame;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!matchedGame) {
|
if (!matchedGame) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: 'Vote timestamp does not match any game in the active session',
|
error: 'Vote timestamp does not match any game in the active session',
|
||||||
debug: {
|
debug: {
|
||||||
voteTimestamp: timestamp,
|
voteTimestamp: timestamp,
|
||||||
sessionGames: sessionGames.map(g => ({
|
sessionGames: sessionGames.map(g => ({
|
||||||
title: g.title,
|
title: g.title,
|
||||||
played_at: g.played_at
|
played_at: g.played_at
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate vote (within 1 second window)
|
// Check for duplicate vote (within 1 second window)
|
||||||
@@ -258,6 +273,7 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
id: updatedGame.id,
|
id: updatedGame.id,
|
||||||
title: updatedGame.title,
|
title: updatedGame.title,
|
||||||
pack_name: matchedGame.pack_name,
|
pack_name: matchedGame.pack_name,
|
||||||
|
ticker: ticker || undefined,
|
||||||
},
|
},
|
||||||
vote: {
|
vote: {
|
||||||
username: username,
|
username: username,
|
||||||
@@ -303,7 +319,8 @@ router.post('/live', authenticateToken, (req, res) => {
|
|||||||
vote: {
|
vote: {
|
||||||
username: username,
|
username: username,
|
||||||
type: vote,
|
type: vote,
|
||||||
timestamp: timestamp
|
timestamp: timestamp,
|
||||||
|
ticker: ticker || undefined,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ require('dotenv').config();
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const { bootstrapGames } = require('./bootstrap');
|
const { bootstrapGames, bootstrapTickers } = require('./bootstrap');
|
||||||
const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager');
|
const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager');
|
||||||
const { cleanupAllShards } = require('./utils/ecast-shard-client');
|
const { cleanupAllShards } = require('./utils/ecast-shard-client');
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ setWebSocketManager(wsManager);
|
|||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
bootstrapGames();
|
bootstrapGames();
|
||||||
|
bootstrapTickers();
|
||||||
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`);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class EcastShardClient {
|
|||||||
lobbyState: roomVal.lobbyState ?? null,
|
lobbyState: roomVal.lobbyState ?? null,
|
||||||
gameCanStart: !!roomVal.gameCanStart,
|
gameCanStart: !!roomVal.gameCanStart,
|
||||||
gameIsStarting: !!roomVal.gameIsStarting,
|
gameIsStarting: !!roomVal.gameIsStarting,
|
||||||
gameStarted: roomVal.state === 'Gameplay',
|
gameStarted: roomVal.state != null && roomVal.state !== 'Lobby',
|
||||||
gameFinished: !!roomVal.gameFinished,
|
gameFinished: !!roomVal.gameFinished,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -213,6 +213,12 @@ class EcastShardClient {
|
|||||||
break;
|
break;
|
||||||
case 'client/disconnected':
|
case 'client/disconnected':
|
||||||
break;
|
break;
|
||||||
|
case 'room/lock':
|
||||||
|
this.handleRoomLock();
|
||||||
|
break;
|
||||||
|
case 'room/exit':
|
||||||
|
this.handleRoomExit(message.result);
|
||||||
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
this.handleError(message.result);
|
this.handleError(message.result);
|
||||||
break;
|
break;
|
||||||
@@ -363,6 +369,44 @@ class EcastShardClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleRoomLock() {
|
||||||
|
if (!this.gameStarted) {
|
||||||
|
console.log(`[Shard Monitor] Room ${this.roomCode} locked (game starting)`);
|
||||||
|
this.gameStarted = true;
|
||||||
|
this.gameState = this.gameState || 'Gameplay';
|
||||||
|
this.onEvent('game.started', {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
gameId: this.gameId,
|
||||||
|
roomCode: this.roomCode,
|
||||||
|
playerCount: this.playerCount,
|
||||||
|
players: [...this.playerNames],
|
||||||
|
maxPlayers: this.maxPlayers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRoomExit() {
|
||||||
|
if (this.gameFinished) return;
|
||||||
|
console.log(`[Shard Monitor] Room ${this.roomCode} exited`);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
handleError(result) {
|
handleError(result) {
|
||||||
console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`);
|
console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`);
|
||||||
if (result?.code === 2027) {
|
if (result?.code === 2027) {
|
||||||
|
|||||||
@@ -26,12 +26,18 @@ class WebSocketManager {
|
|||||||
* Handle new WebSocket connection
|
* Handle new WebSocket connection
|
||||||
*/
|
*/
|
||||||
handleConnection(ws, req) {
|
handleConnection(ws, req) {
|
||||||
console.log('[WebSocket] New connection from', req.socket.remoteAddress);
|
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim()
|
||||||
|
|| req.headers['x-real-ip']
|
||||||
|
|| req.socket.remoteAddress;
|
||||||
|
console.log('[WebSocket] New connection from', clientIp);
|
||||||
|
|
||||||
// Initialize client info
|
// Initialize client info
|
||||||
const clientInfo = {
|
const clientInfo = {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
userId: null,
|
userId: null,
|
||||||
|
adminName: null,
|
||||||
|
role: null,
|
||||||
|
currentPage: null,
|
||||||
subscribedSessions: new Set(),
|
subscribedSessions: new Set(),
|
||||||
lastPing: Date.now()
|
lastPing: Date.now()
|
||||||
};
|
};
|
||||||
@@ -97,6 +103,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,14 +132,22 @@ 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;
|
||||||
|
clientInfo.role = decoded.role || 'admin';
|
||||||
|
|
||||||
|
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',
|
||||||
message: 'Authenticated successfully'
|
message: 'Authenticated successfully'
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[WebSocket] Client authenticated:', clientInfo.userId);
|
console.log('[WebSocket] Client authenticated:', clientInfo.adminName);
|
||||||
|
this.broadcastPresence();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[WebSocket] Authentication failed:', err.message);
|
console.error('[WebSocket] Authentication failed:', err.message);
|
||||||
@@ -282,10 +305,36 @@ class WebSocketManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.clients.delete(ws);
|
this.clients.delete(ws);
|
||||||
console.log('[WebSocket] Client disconnected and cleaned up');
|
console.log('[WebSocket] Client disconnected:', clientInfo.adminName || 'unauthenticated');
|
||||||
|
this.broadcastPresence();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
broadcastPresence() {
|
||||||
|
const admins = [];
|
||||||
|
this.clients.forEach((info) => {
|
||||||
|
if (!info.authenticated || !info.adminName) return;
|
||||||
|
const role = info.role || 'admin';
|
||||||
|
if (role === 'bot' || role === 'utility') {
|
||||||
|
admins.push({ name: info.adminName, role, page: null });
|
||||||
|
} else if (info.currentPage) {
|
||||||
|
admins.push({ name: info.adminName, role, 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,z
|
||||||
|
- ./backend/config/admins.json:/app/config/admins.json:ro,z
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# Votes Endpoints
|
# Votes Endpoints
|
||||||
|
|
||||||
Real-time popularity voting. Bots or integrations send votes during live gaming sessions. Votes are matched to the currently-playing game using timestamp intervals.
|
Real-time popularity voting. Bots or integrations send votes during live gaming sessions. Two voting mechanisms are supported:
|
||||||
|
|
||||||
|
- **`thisgame++`/`thisgame--`** — votes for the game currently being played, matched via timestamp intervals.
|
||||||
|
- **Ticker voting** — votes for a specific game by its ticker symbol (e.g. `QPL3` for Quiplash 3), regardless of what is currently being played.
|
||||||
|
|
||||||
## Endpoint Summary
|
## Endpoint Summary
|
||||||
|
|
||||||
@@ -71,7 +74,10 @@ Results are ordered by `timestamp DESC`. The `vote_type` field is returned as `"
|
|||||||
|
|
||||||
## 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. Supports two independent voting mechanisms:
|
||||||
|
|
||||||
|
- **Ticker voting** — include a `ticker` field to vote for a specific game by symbol. The game is resolved globally and does not need to be in the active session.
|
||||||
|
- **`thisgame++`/`thisgame--` voting** — omit `ticker` to vote for the game currently being played, matched via timestamp intervals against `session_games`.
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
@@ -84,6 +90,20 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
|||||||
| username | string | Yes | Identifier for the voter (used for deduplication) |
|
| username | string | Yes | Identifier for the voter (used for deduplication) |
|
||||||
| vote | string | Yes | `"up"` or `"down"` |
|
| vote | string | Yes | `"up"` or `"down"` |
|
||||||
| timestamp | string | Yes | ISO 8601 timestamp when the vote occurred |
|
| timestamp | string | Yes | ISO 8601 timestamp when the vote occurred |
|
||||||
|
| ticker | string | No | Ticker symbol identifying the game (e.g. `QPL3`, `TMP2`). When provided, the game is resolved by ticker and timestamp matching is skipped. |
|
||||||
|
|
||||||
|
**Ticker vote:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "viewer123",
|
||||||
|
"vote": "up",
|
||||||
|
"timestamp": "2026-03-15T20:30:00Z",
|
||||||
|
"ticker": "QPL3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`thisgame++`/`thisgame--` vote (no ticker):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -96,7 +116,8 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
|||||||
### Behavior
|
### Behavior
|
||||||
|
|
||||||
- Finds the active session (single session with `is_active = 1`).
|
- Finds the active session (single session with `is_active = 1`).
|
||||||
- Matches the vote timestamp to the game being played at that time (uses interval between consecutive `played_at` timestamps).
|
- **With `ticker`:** Looks up the game globally by ticker symbol. The game does not need to be part of the active session.
|
||||||
|
- **Without `ticker`:** 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.
|
- Broadcasts a `vote.received` WebSocket event to all clients subscribed to the active session. See [WebSocket Protocol](../websocket.md#votereceived) for event payload.
|
||||||
@@ -105,6 +126,8 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
|||||||
|
|
||||||
**200 OK**
|
**200 OK**
|
||||||
|
|
||||||
|
The `ticker` field is included in the response when the vote was submitted with a ticker.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -120,7 +143,8 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
|||||||
"vote": {
|
"vote": {
|
||||||
"username": "viewer123",
|
"username": "viewer123",
|
||||||
"type": "up",
|
"type": "up",
|
||||||
"timestamp": "2026-03-15T20:30:00Z"
|
"timestamp": "2026-03-15T20:30:00Z",
|
||||||
|
"ticker": "QPL3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -133,12 +157,29 @@ Bearer token required. Include in header: `Authorization: Bearer <token>`.
|
|||||||
| 400 | `{ "error": "vote must be either \"up\" or \"down\"" }` | Invalid vote value |
|
| 400 | `{ "error": "vote must be either \"up\" or \"down\"" }` | Invalid vote value |
|
||||||
| 400 | `{ "error": "Invalid timestamp format. Use ISO 8601 format (e.g., 2025-11-01T20:30:00Z)" }` | Invalid timestamp |
|
| 400 | `{ "error": "Invalid timestamp format. Use ISO 8601 format (e.g., 2025-11-01T20:30:00Z)" }` | Invalid timestamp |
|
||||||
| 404 | `{ "error": "No active session found" }` | No session with `is_active = 1` |
|
| 404 | `{ "error": "No active session found" }` | No session with `is_active = 1` |
|
||||||
| 404 | `{ "error": "No games have been played in the active session yet" }` | Active session has no games |
|
| 404 | `{ "error": "Unknown ticker 'XYZ'" }` | Ticker does not match any game |
|
||||||
| 404 | `{ "error": "Vote timestamp does not match any game in the active session", "debug": { "voteTimestamp": "2026-03-15T20:30:00Z", "sessionGames": [{ "title": "Quiplash 3", "played_at": "..." }] } }` | Timestamp outside any game interval |
|
| 404 | `{ "error": "No games have been played in the active session yet" }` | Active session has no games (timestamp voting only) |
|
||||||
|
| 404 | `{ "error": "Vote timestamp does not match any game in the active session", "debug": { ... } }` | Timestamp outside any game interval (timestamp voting only) |
|
||||||
| 409 | `{ "error": "Duplicate vote detected (within 1 second of previous vote)", "message": "Please wait at least 1 second between votes", "timeSinceLastVote": 0.5 }` | Same username voted within 1 second |
|
| 409 | `{ "error": "Duplicate vote detected (within 1 second of previous vote)", "message": "Please wait at least 1 second between votes", "timeSinceLastVote": 0.5 }` | Same username voted within 1 second |
|
||||||
| 500 | `{ "error": "..." }` | Server error |
|
| 500 | `{ "error": "..." }` | Server error |
|
||||||
|
|
||||||
### Example
|
### Examples
|
||||||
|
|
||||||
|
**Ticker vote:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:5000/api/votes/live" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "viewer123",
|
||||||
|
"vote": "up",
|
||||||
|
"timestamp": "2026-03-15T20:30:00Z",
|
||||||
|
"ticker": "QPL3"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**`thisgame++` vote (no ticker):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "http://localhost:5000/api/votes/live" \
|
curl -X POST "http://localhost:5000/api/votes/live" \
|
||||||
|
|||||||
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**
|
||||||
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
|
||||||
@@ -7,6 +7,7 @@ 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';
|
||||||
@@ -168,8 +169,11 @@ function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Admin Presence */}
|
||||||
|
<PresenceBar />
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-12 flex-shrink-0">
|
<footer className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||||
<div className="container mx-auto px-4 py-6">
|
<div className="container mx-auto px-4 py-6">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex flex-col md:flex-row justify-between items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<div className="text-center md:text-left">
|
<div className="text-center md:text-left">
|
||||||
|
|||||||
91
frontend/src/components/PresenceBar.jsx
Normal file
91
frontend/src/components/PresenceBar.jsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { usePresence } from '../hooks/usePresence';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
function GearIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.248a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatBubbleIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceBadge({ name, role }) {
|
||||||
|
const isBot = role === 'bot';
|
||||||
|
const colorClass = isBot
|
||||||
|
? 'bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300'
|
||||||
|
: 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
|
||||||
|
{isBot ? <ChatBubbleIcon /> : <GearIcon />}
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PresenceBar() {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const { viewers, services } = usePresence();
|
||||||
|
|
||||||
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
|
const otherViewers = viewers.filter(v => v.name !== 'me');
|
||||||
|
const hasViewers = otherViewers.length > 0;
|
||||||
|
const hasServices = services.length > 0;
|
||||||
|
|
||||||
|
if (!hasViewers && !hasServices) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticky bottom-0 z-40 flex justify-end px-4 pb-4 pointer-events-none">
|
||||||
|
<div className="pointer-events-auto bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 px-4 py-2 w-fit">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{hasViewers && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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((v, i) => (
|
||||||
|
<span
|
||||||
|
key={`${v.name}-${i}`}
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
v.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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{v.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasServices && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider font-medium flex-shrink-0">
|
||||||
|
connected
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{services.map((s, i) => (
|
||||||
|
<ServiceBadge key={`${s.name}-${i}`} name={s.name} role={s.role} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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.6.2 - Fish Tank Edition',
|
version: '0.7.0 - Fixed For Real Edition',
|
||||||
description: 'Spicing up Hyper Spaceout game nights!',
|
description: 'Spicing up Hyper Spaceout game nights!',
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -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,8 @@ 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 [adminRole, setAdminRole] = useState(localStorage.getItem('adminRole'));
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -20,10 +23,20 @@ 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;
|
||||||
|
const role = response.data.user?.role || 'admin';
|
||||||
|
if (name) {
|
||||||
|
setAdminName(name);
|
||||||
|
setAdminRole(role);
|
||||||
|
localStorage.setItem('adminName', name);
|
||||||
|
localStorage.setItem('adminRole', role);
|
||||||
|
} else {
|
||||||
|
logout();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Token verification failed:', error);
|
console.error('Token verification failed:', error);
|
||||||
logout();
|
logout();
|
||||||
@@ -38,10 +51,15 @@ 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, role } = response.data;
|
||||||
localStorage.setItem('adminToken', newToken);
|
localStorage.setItem('adminToken', newToken);
|
||||||
|
localStorage.setItem('adminName', name);
|
||||||
|
localStorage.setItem('adminRole', role || 'admin');
|
||||||
setToken(newToken);
|
setToken(newToken);
|
||||||
|
setAdminName(name);
|
||||||
|
setAdminRole(role || 'admin');
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
|
migratePreferences(name);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -53,12 +71,18 @@ export const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('adminToken');
|
localStorage.removeItem('adminToken');
|
||||||
|
localStorage.removeItem('adminName');
|
||||||
|
localStorage.removeItem('adminRole');
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
setAdminName(null);
|
||||||
|
setAdminRole(null);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
token,
|
token,
|
||||||
|
adminName,
|
||||||
|
adminRole,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
loading,
|
loading,
|
||||||
login,
|
login,
|
||||||
@@ -67,4 +91,3 @@ export const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
94
frontend/src/hooks/usePresence.js
Normal file
94
frontend/src/hooks/usePresence.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
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 [services, setServices] = useState([]);
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
const pingRef = useRef(null);
|
||||||
|
const reconnectRef = useRef(null);
|
||||||
|
const locationRef = useRef(location.pathname);
|
||||||
|
const adminNameRef = useRef(adminName);
|
||||||
|
|
||||||
|
locationRef.current = location.pathname;
|
||||||
|
adminNameRef.current = adminName;
|
||||||
|
|
||||||
|
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: locationRef.current }));
|
||||||
|
|
||||||
|
pingRef.current = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'ping' }));
|
||||||
|
}
|
||||||
|
}, PING_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'presence_update') {
|
||||||
|
const currentPage = locationRef.current;
|
||||||
|
const currentName = adminNameRef.current;
|
||||||
|
const pageViewers = msg.admins
|
||||||
|
.filter(a => a.role !== 'bot' && a.role !== 'utility' && a.page === currentPage)
|
||||||
|
.map(a => ({ name: a.name === currentName ? 'me' : a.name, role: a.role || 'admin' }));
|
||||||
|
const connectedServices = msg.admins
|
||||||
|
.filter(a => a.role === 'bot' || a.role === 'utility')
|
||||||
|
.map(a => ({ name: a.name, role: a.role }));
|
||||||
|
setViewers(pageViewers);
|
||||||
|
setServices(connectedServices);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
clearInterval(pingRef.current);
|
||||||
|
reconnectRef.current = setTimeout(connect, WS_RECONNECT_DELAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
}, [isAuthenticated, token, 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, services };
|
||||||
|
}
|
||||||
@@ -1,22 +1,26 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef, useMemo } 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 { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import api from '../api/axios';
|
import api from '../api/axios';
|
||||||
import { formatLocalDate, isSunday } from '../utils/dateUtils';
|
import { formatDayHeader, formatTimeOnly, getLocalDateKey, isSunday } from '../utils/dateUtils';
|
||||||
|
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 navigate = useNavigate();
|
||||||
|
|
||||||
const [sessions, setSessions] = useState([]);
|
const [sessions, setSessions] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
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 [filter, setFilter] = useState(() => localStorage.getItem('history-filter') || 'default');
|
const [filter, setFilter] = useState(() => localStorage.getItem(prefixKey(adminName, 'history-filter')) || 'default');
|
||||||
const [limit, setLimit] = useState(() => localStorage.getItem('history-show-limit') || '5');
|
const [limit, setLimit] = useState(() => localStorage.getItem(prefixKey(adminName, 'history-show-limit')) || '5');
|
||||||
|
|
||||||
const [selectMode, setSelectMode] = useState(false);
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState(new Set());
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
||||||
@@ -27,17 +31,26 @@ function History() {
|
|||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
const limitNum = limit === 'all' ? null : parseInt(limit, 10);
|
||||||
|
const offset = limitNum ? (page - 1) * limitNum : 0;
|
||||||
|
|
||||||
const response = await api.get('/sessions', {
|
const response = await api.get('/sessions', {
|
||||||
params: { filter, limit }
|
params: { filter, limit, offset: offset || undefined }
|
||||||
});
|
});
|
||||||
setSessions(response.data);
|
setSessions(response.data);
|
||||||
setTotalCount(parseInt(response.headers['x-total-count'] || '0', 10));
|
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]);
|
}, [filter, limit, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSessions();
|
loadSessions();
|
||||||
@@ -50,16 +63,27 @@ function History() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadSessions]);
|
}, [loadSessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (adminName) {
|
||||||
|
const savedFilter = localStorage.getItem(prefixKey(adminName, 'history-filter'));
|
||||||
|
const savedLimit = localStorage.getItem(prefixKey(adminName, 'history-show-limit'));
|
||||||
|
if (savedFilter) setFilter(savedFilter);
|
||||||
|
if (savedLimit) setLimit(savedLimit);
|
||||||
|
}
|
||||||
|
}, [adminName]);
|
||||||
|
|
||||||
const handleFilterChange = (newFilter) => {
|
const handleFilterChange = (newFilter) => {
|
||||||
setFilter(newFilter);
|
setFilter(newFilter);
|
||||||
localStorage.setItem('history-filter', newFilter);
|
localStorage.setItem(prefixKey(adminName, 'history-filter'), newFilter);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLimitChange = (newLimit) => {
|
const handleLimitChange = (newLimit) => {
|
||||||
setLimit(newLimit);
|
setLimit(newLimit);
|
||||||
localStorage.setItem('history-show-limit', newLimit);
|
localStorage.setItem(prefixKey(adminName, 'history-show-limit'), newLimit);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseSession = async (sessionId, notes) => {
|
const handleCloseSession = async (sessionId, notes) => {
|
||||||
@@ -90,6 +114,7 @@ function History() {
|
|||||||
setSelectMode(false);
|
setSelectMode(false);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setShowBulkDeleteConfirm(false);
|
setShowBulkDeleteConfirm(false);
|
||||||
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerDown = (sessionId) => {
|
const handlePointerDown = (sessionId) => {
|
||||||
@@ -99,6 +124,7 @@ function History() {
|
|||||||
longPressFired.current = true;
|
longPressFired.current = true;
|
||||||
setSelectMode(true);
|
setSelectMode(true);
|
||||||
setSelectedIds(new Set([sessionId]));
|
setSelectedIds(new Set([sessionId]));
|
||||||
|
setPage(1);
|
||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,6 +150,23 @@ function History() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
@@ -169,11 +212,14 @@ function History() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
{totalCount} session{totalCount !== 1 ? 's' : ''} total
|
{sessions.length === absoluteTotal
|
||||||
|
? `${absoluteTotal} session${absoluteTotal !== 1 ? 's' : ''} total`
|
||||||
|
: `${sessions.length} visible (${absoluteTotal} total)`
|
||||||
|
}
|
||||||
</span>
|
</span>
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<button
|
<button
|
||||||
onClick={selectMode ? exitSelectMode : () => setSelectMode(true)}
|
onClick={selectMode ? exitSelectMode : () => { setSelectMode(true); setPage(1); }}
|
||||||
className={`px-3 py-1.5 rounded text-sm font-medium transition ${
|
className={`px-3 py-1.5 rounded text-sm font-medium transition ${
|
||||||
selectMode
|
selectMode
|
||||||
? 'bg-indigo-600 dark:bg-indigo-700 text-white hover:bg-indigo-700 dark:hover:bg-indigo-800'
|
? 'bg-indigo-600 dark:bg-indigo-700 text-white hover:bg-indigo-700 dark:hover:bg-indigo-800'
|
||||||
@@ -191,115 +237,176 @@ function History() {
|
|||||||
<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-2">
|
<div className="space-y-2">
|
||||||
{sessions.map(session => {
|
{groupedSessions.map((group, groupIdx) => {
|
||||||
const isActive = session.is_active === 1;
|
const isSundayGroup = isSunday(group.sessions[0].created_at);
|
||||||
const isSelected = selectedIds.has(session.id);
|
const isContinued = groupIdx === 0 && page > 1 && prevLastDate &&
|
||||||
const isSundaySession = isSunday(session.created_at);
|
getLocalDateKey(prevLastDate) === group.dateKey;
|
||||||
const isArchived = session.archived === 1;
|
|
||||||
const canSelect = selectMode && !isActive;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={group.dateKey}>
|
||||||
key={session.id}
|
{/* Day header bar */}
|
||||||
className={`border rounded-lg transition ${
|
<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">
|
||||||
selectMode && isActive
|
<div className="flex items-center gap-2">
|
||||||
? 'opacity-50 cursor-not-allowed border-gray-300 dark:border-gray-600'
|
<span className="text-sm font-semibold text-indigo-700 dark:text-indigo-300">
|
||||||
: isSelected
|
{formatDayHeader(group.sessions[0].created_at)}
|
||||||
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 cursor-pointer'
|
</span>
|
||||||
: 'border-gray-300 dark:border-gray-600 hover:border-indigo-400 dark:hover:border-indigo-500 cursor-pointer'
|
{isContinued && (
|
||||||
}`}
|
<span className="text-xs text-gray-400 dark:text-gray-500 italic">(continued)</span>
|
||||||
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>
|
||||||
<div className="flex justify-between items-center mb-1">
|
{!isContinued && (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-gray-800 dark:text-gray-100">
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Session #{session.id}
|
{group.sessions.length} session{group.sessions.length !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
{isActive && (
|
{isSundayGroup && (
|
||||||
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs px-2 py-0.5 rounded">
|
<span className="text-xs font-semibold text-amber-700 dark:text-amber-300">🎲 Game Night</span>
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isSundaySession && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{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">
|
|
||||||
{formatLocalDate(session.created_at)}
|
|
||||||
{isSundaySession && (
|
|
||||||
<span className="text-gray-400 dark:text-gray-500"> · Sunday</span>
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
|
|
||||||
{!selectMode && isAuthenticated && isActive && (
|
{/* Session cards under this day */}
|
||||||
<div className="px-4 pb-4 pt-0">
|
<div className="ml-3 space-y-1.5 mb-4">
|
||||||
<button
|
{group.sessions.map(session => {
|
||||||
onClick={(e) => {
|
const isActive = session.is_active === 1;
|
||||||
e.stopPropagation();
|
const isSelected = selectedIds.has(session.id);
|
||||||
setClosingSession(session.id);
|
const isArchived = session.archived === 1;
|
||||||
}}
|
|
||||||
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"
|
return (
|
||||||
>
|
<div
|
||||||
End Session
|
key={session.id}
|
||||||
</button>
|
className={`border rounded-lg transition ${
|
||||||
</div>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Multi-select Action Bar */}
|
{/* Multi-select Action Bar */}
|
||||||
{selectMode && selectedIds.size > 0 && (
|
{selectMode && selectedIds.size > 0 && (
|
||||||
<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">
|
<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">
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,3 +56,44 @@ export function isSunday(sqliteTimestamp) {
|
|||||||
return parseUTCTimestamp(sqliteTimestamp).getDay() === 0;
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ describe('EcastShardClient', () => {
|
|||||||
expect(result.gameStarted).toBe(true);
|
expect(result.gameStarted).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('detects game started from non-Lobby state (Pack 7 Logo)', () => {
|
||||||
|
const roomVal = { state: 'Logo', locale: 'en', platformId: 'PS4' };
|
||||||
|
const result = EcastShardClient.parseRoomEntity(roomVal);
|
||||||
|
expect(result.gameStarted).toBe(true);
|
||||||
|
expect(result.gameState).toBe('Logo');
|
||||||
|
expect(result.lobbyState).toBeNull();
|
||||||
|
expect(result.gameFinished).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('detects game finished', () => {
|
test('detects game finished', () => {
|
||||||
const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true };
|
const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true };
|
||||||
const result = EcastShardClient.parseRoomEntity(roomVal);
|
const result = EcastShardClient.parseRoomEntity(roomVal);
|
||||||
@@ -272,6 +281,26 @@ describe('EcastShardClient', () => {
|
|||||||
expect(startEvents[0].data.players).toEqual(['A', 'B', 'C', 'D']);
|
expect(startEvents[0].data.players).toEqual(['A', 'B', 'C', 'D']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('broadcasts game.started on state transition to Logo (Pack 7)', () => {
|
||||||
|
client.lobbyState = 'Countdown';
|
||||||
|
client.gameState = 'Lobby';
|
||||||
|
client.gameStarted = false;
|
||||||
|
client.playerCount = 4;
|
||||||
|
client.playerNames = ['A', 'B', 'C', 'D'];
|
||||||
|
|
||||||
|
client.handleEntityUpdate({
|
||||||
|
key: 'room',
|
||||||
|
val: { state: 'Logo', locale: 'en', platformId: 'PS4' },
|
||||||
|
version: 14, from: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startEvents = events.filter(e => e.type === 'game.started');
|
||||||
|
expect(startEvents).toHaveLength(1);
|
||||||
|
expect(startEvents[0].data.playerCount).toBe(4);
|
||||||
|
expect(client.gameState).toBe('Logo');
|
||||||
|
expect(client.gameStarted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('does not broadcast game.started if already started', () => {
|
test('does not broadcast game.started if already started', () => {
|
||||||
client.gameStarted = true;
|
client.gameStarted = true;
|
||||||
client.gameState = 'Gameplay';
|
client.gameState = 'Gameplay';
|
||||||
@@ -573,4 +602,80 @@ describe('EcastShardClient', () => {
|
|||||||
expect(events.some(e => e.type === 'room.disconnected' && e.data.reason === 'room_closed')).toBe(true);
|
expect(events.some(e => e.type === 'room.disconnected' && e.data.reason === 'room_closed')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('handleRoomLock', () => {
|
||||||
|
test('emits game.started when game has not yet started', () => {
|
||||||
|
const events = [];
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1,
|
||||||
|
gameId: 5,
|
||||||
|
roomCode: 'TEST',
|
||||||
|
maxPlayers: 8,
|
||||||
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
|
});
|
||||||
|
client.gameStarted = false;
|
||||||
|
client.playerCount = 4;
|
||||||
|
client.playerNames = ['A', 'B', 'C', 'D'];
|
||||||
|
|
||||||
|
client.handleRoomLock();
|
||||||
|
|
||||||
|
expect(client.gameStarted).toBe(true);
|
||||||
|
const startEvents = events.filter(e => e.type === 'game.started');
|
||||||
|
expect(startEvents).toHaveLength(1);
|
||||||
|
expect(startEvents[0].data.playerCount).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not emit game.started if game already started', () => {
|
||||||
|
const events = [];
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1,
|
||||||
|
gameId: 5,
|
||||||
|
roomCode: 'TEST',
|
||||||
|
maxPlayers: 8,
|
||||||
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
|
});
|
||||||
|
client.gameStarted = true;
|
||||||
|
|
||||||
|
client.handleRoomLock();
|
||||||
|
|
||||||
|
expect(events.filter(e => e.type === 'game.started')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleRoomExit', () => {
|
||||||
|
test('emits game.ended and room.disconnected on room exit', () => {
|
||||||
|
const events = [];
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1,
|
||||||
|
gameId: 5,
|
||||||
|
roomCode: 'TEST',
|
||||||
|
maxPlayers: 8,
|
||||||
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
|
});
|
||||||
|
client.playerCount = 3;
|
||||||
|
client.playerNames = ['X', 'Y', 'Z'];
|
||||||
|
|
||||||
|
client.handleRoomExit();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not emit events if game already finished', () => {
|
||||||
|
const events = [];
|
||||||
|
const client = new EcastShardClient({
|
||||||
|
sessionId: 1,
|
||||||
|
gameId: 5,
|
||||||
|
roomCode: 'TEST',
|
||||||
|
maxPlayers: 8,
|
||||||
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
|
});
|
||||||
|
client.gameFinished = true;
|
||||||
|
|
||||||
|
client.handleRoomExit();
|
||||||
|
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
296
tests/api/named-admins.test.js
Normal file
296
tests/api/named-admins.test.js
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
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', role: 'admin', key: 'key-a' },
|
||||||
|
{ name: 'Bob', role: 'bot', key: 'key-b' }
|
||||||
|
]);
|
||||||
|
process.env.ADMIN_CONFIG_PATH = configPath;
|
||||||
|
|
||||||
|
const { findAdminByKey } = require('../../backend/config/load-admins');
|
||||||
|
expect(findAdminByKey('key-a')).toEqual({ name: 'Alice', role: 'admin' });
|
||||||
|
expect(findAdminByKey('key-b')).toEqual({ name: 'Bob', role: 'bot' });
|
||||||
|
expect(findAdminByKey('wrong')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaults role to admin when not specified', () => {
|
||||||
|
const configPath = writeConfig([
|
||||||
|
{ name: 'Alice', key: 'key-a' }
|
||||||
|
]);
|
||||||
|
process.env.ADMIN_CONFIG_PATH = configPath;
|
||||||
|
|
||||||
|
const { findAdminByKey } = require('../../backend/config/load-admins');
|
||||||
|
expect(findAdminByKey('key-a')).toEqual({ name: 'Alice', role: 'admin' });
|
||||||
|
});
|
||||||
|
|
||||||
|
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', role: '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 and role 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.role).toBe('admin');
|
||||||
|
expect(res.body.token).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verify returns admin name and role 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();
|
||||||
|
expect(res.body.user.role).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
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, role = 'admin') {
|
||||||
|
return jwt.sign({ role, name }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectAndAuth(name, role = 'admin') {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
ws.on('open', () => {
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: makeToken(name, role) }));
|
||||||
|
});
|
||||||
|
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, role, 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', role: 'admin', 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bot role appears in presence without page_focus', async () => {
|
||||||
|
const wsAdmin = await connectAndAuth('Alice');
|
||||||
|
const wsBot = await connectAndAuth('ChatBot', 'bot');
|
||||||
|
|
||||||
|
wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/history' }));
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
|
const presencePromise = waitForMessage(wsAdmin, 'presence_update');
|
||||||
|
wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/picker' }));
|
||||||
|
|
||||||
|
const msg = await presencePromise;
|
||||||
|
const botEntry = msg.admins.find(a => a.name === 'ChatBot');
|
||||||
|
expect(botEntry).toBeDefined();
|
||||||
|
expect(botEntry.role).toBe('bot');
|
||||||
|
expect(botEntry.page).toBeNull();
|
||||||
|
|
||||||
|
wsAdmin.close();
|
||||||
|
wsBot.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('utility role appears in presence without page_focus', async () => {
|
||||||
|
const wsAdmin = await connectAndAuth('Alice');
|
||||||
|
const wsUtil = await connectAndAuth('OBS', 'utility');
|
||||||
|
|
||||||
|
wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/history' }));
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
|
const presencePromise = waitForMessage(wsAdmin, 'presence_update');
|
||||||
|
wsAdmin.send(JSON.stringify({ type: 'page_focus', page: '/picker' }));
|
||||||
|
|
||||||
|
const msg = await presencePromise;
|
||||||
|
const utilEntry = msg.admins.find(a => a.name === 'OBS');
|
||||||
|
expect(utilEntry).toBeDefined();
|
||||||
|
expect(utilEntry.role).toBe('utility');
|
||||||
|
expect(utilEntry.page).toBeNull();
|
||||||
|
|
||||||
|
wsAdmin.close();
|
||||||
|
wsUtil.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin without page_focus is NOT in presence', async () => {
|
||||||
|
const ws1 = await connectAndAuth('Alice');
|
||||||
|
const ws2 = await connectAndAuth('Bob');
|
||||||
|
|
||||||
|
const presencePromise = waitForMessage(ws1, 'presence_update');
|
||||||
|
ws1.send(JSON.stringify({ type: 'page_focus', page: '/history' }));
|
||||||
|
|
||||||
|
const msg = await presencePromise;
|
||||||
|
const bobEntry = msg.admins.find(a => a.name === 'Bob');
|
||||||
|
expect(bobEntry).toBeUndefined();
|
||||||
|
|
||||||
|
ws1.close();
|
||||||
|
ws2.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -92,6 +92,97 @@ describe('GET /api/sessions — filter and limit', () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toHaveLength(8);
|
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', () => {
|
describe('POST /api/sessions/:id/archive', () => {
|
||||||
@@ -184,6 +275,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'archive', ids: [s1.id, s2.id] });
|
.send({ action: 'archive', ids: [s1.id, s2.id] });
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -203,6 +295,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'unarchive', ids: [s1.id, s2.id] });
|
.send({ action: 'unarchive', ids: [s1.id, s2.id] });
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -219,6 +312,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'delete', ids: [s1.id, s2.id] });
|
.send({ action: 'delete', ids: [s1.id, s2.id] });
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -235,6 +329,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'archive', ids: [active.id, closed.id] });
|
.send({ action: 'archive', ids: [active.id, closed.id] });
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -251,6 +346,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'delete', ids: [active.id] });
|
.send({ action: 'delete', ids: [active.id] });
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -260,6 +356,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'archive', ids: [] });
|
.send({ action: 'archive', ids: [] });
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -269,6 +366,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'nuke', ids: [1] });
|
.send({ action: 'nuke', ids: [1] });
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -278,6 +376,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'archive', ids: 'not-array' });
|
.send({ action: 'archive', ids: 'not-array' });
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -289,6 +388,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'archive', ids: [s1.id, 9999] });
|
.send({ action: 'archive', ids: [s1.id, 9999] });
|
||||||
|
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
@@ -297,6 +397,7 @@ describe('POST /api/sessions/bulk', () => {
|
|||||||
test('returns 401 without auth', async () => {
|
test('returns 401 without auth', async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/sessions/bulk')
|
.post('/api/sessions/bulk')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ action: 'archive', ids: [1] });
|
.send({ action: 'archive', ids: [1] });
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ describe('PUT /api/sessions/:id/notes', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.put(`/api/sessions/${session.id}/notes`)
|
.put(`/api/sessions/${session.id}/notes`)
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ notes: 'New notes here' });
|
.send({ notes: 'New notes here' });
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -169,6 +170,7 @@ describe('PUT /api/sessions/:id/notes', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.put(`/api/sessions/${session.id}/notes`)
|
.put(`/api/sessions/${session.id}/notes`)
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ notes: 'Replacement' });
|
.send({ notes: 'Replacement' });
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -179,6 +181,7 @@ describe('PUT /api/sessions/:id/notes', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.put('/api/sessions/99999/notes')
|
.put('/api/sessions/99999/notes')
|
||||||
.set('Authorization', getAuthHeader())
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ notes: 'test' });
|
.send({ notes: 'test' });
|
||||||
|
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
@@ -189,6 +192,7 @@ describe('PUT /api/sessions/:id/notes', () => {
|
|||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.put(`/api/sessions/${session.id}/notes`)
|
.put(`/api/sessions/${session.id}/notes`)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ notes: 'test' });
|
.send({ notes: 'test' });
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
@@ -200,6 +204,7 @@ describe('PUT /api/sessions/:id/notes', () => {
|
|||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.put(`/api/sessions/${session.id}/notes`)
|
.put(`/api/sessions/${session.id}/notes`)
|
||||||
.set('Authorization', 'Bearer invalid-token')
|
.set('Authorization', 'Bearer invalid-token')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
.send({ notes: 'test' });
|
.send({ notes: 'test' });
|
||||||
|
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
|
|||||||
@@ -163,3 +163,104 @@ describe('POST /api/votes/live -> read-back (end-to-end)', () => {
|
|||||||
expect(sessionVotes.body.votes[0].total_votes).toBe(3);
|
expect(sessionVotes.body.votes[0].total_votes).toBe(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /api/votes/live — ticker voting', () => {
|
||||||
|
let session, tickerGame, sessionGame;
|
||||||
|
const baseTime = '2026-03-15T20:00:00.000Z';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanDb();
|
||||||
|
tickerGame = seedGame({ title: 'Quiplash 3', pack_name: 'Party Pack 7', ticker: 'QPL3' });
|
||||||
|
sessionGame = seedGame({ title: 'Drawful 2', pack_name: 'Party Pack 3' });
|
||||||
|
session = seedSession({ is_active: 1 });
|
||||||
|
seedSessionGame(session.id, sessionGame.id, { status: 'playing', played_at: baseTime });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vote with valid ticker resolves to the correct 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: '2026-03-15T20:05:00.000Z',
|
||||||
|
ticker: 'QPL3',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.game.id).toBe(tickerGame.id);
|
||||||
|
expect(res.body.game.title).toBe('Quiplash 3');
|
||||||
|
expect(res.body.vote.ticker).toBe('QPL3');
|
||||||
|
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT * FROM live_votes WHERE game_id = ? AND username = ?'
|
||||||
|
).get(tickerGame.id, 'viewer1');
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row.vote_type).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ticker vote works for a game not in the active session', async () => {
|
||||||
|
const outsideGame = seedGame({ title: 'Fibbage XL', pack_name: 'Party Pack 1', ticker: 'FBXL' });
|
||||||
|
|
||||||
|
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',
|
||||||
|
ticker: 'FBXL',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.game.id).toBe(outsideGame.id);
|
||||||
|
expect(res.body.game.title).toBe('Fibbage XL');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown ticker returns 404', 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',
|
||||||
|
ticker: 'NOPE',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toMatch(/Unknown ticker 'NOPE'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ticker vote updates game scores', 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',
|
||||||
|
ticker: 'QPL3',
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/votes/live')
|
||||||
|
.set('Authorization', getAuthHeader())
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({
|
||||||
|
username: 'viewer2',
|
||||||
|
vote: 'down',
|
||||||
|
timestamp: '2026-03-15T20:06:05.000Z',
|
||||||
|
ticker: 'QPL3',
|
||||||
|
});
|
||||||
|
|
||||||
|
const game = db.prepare('SELECT upvotes, downvotes, popularity_score FROM games WHERE id = ?').get(tickerGame.id);
|
||||||
|
expect(game.upvotes).toBe(1);
|
||||||
|
expect(game.downvotes).toBe(1);
|
||||||
|
expect(game.popularity_score).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const jwt = require('jsonwebtoken');
|
|||||||
const db = require('../../backend/database');
|
const db = require('../../backend/database');
|
||||||
|
|
||||||
function getAuthToken() {
|
function getAuthToken() {
|
||||||
return jwt.sign({ role: 'admin' }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
return jwt.sign({ role: 'admin', name: 'TestAdmin' }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAuthHeader() {
|
function getAuthHeader() {
|
||||||
@@ -34,12 +34,13 @@ function seedGame(overrides = {}) {
|
|||||||
upvotes: 0,
|
upvotes: 0,
|
||||||
downvotes: 0,
|
downvotes: 0,
|
||||||
popularity_score: 0,
|
popularity_score: 0,
|
||||||
|
ticker: null,
|
||||||
};
|
};
|
||||||
const g = { ...defaults, ...overrides };
|
const g = { ...defaults, ...overrides };
|
||||||
const result = db.prepare(`
|
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)
|
INSERT INTO games (pack_name, title, min_players, max_players, length_minutes, has_audience, family_friendly, game_type, enabled, upvotes, downvotes, popularity_score, ticker)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
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);
|
`).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, g.ticker);
|
||||||
return db.prepare('SELECT * FROM games WHERE id = ?').get(result.lastInsertRowid);
|
return db.prepare('SELECT * FROM games WHERE id = ?').get(result.lastInsertRowid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
492
tools/jackbox-logger.js
Normal file
492
tools/jackbox-logger.js
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
let WebSocket;
|
||||||
|
try {
|
||||||
|
WebSocket = require('ws');
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
WebSocket = require('../backend/node_modules/ws');
|
||||||
|
} catch (_2) {
|
||||||
|
console.error('Error: WebSocket library (ws) not found.');
|
||||||
|
console.error('Run: cd backend && npm install');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ANSI helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const C = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
bold: '\x1b[1m',
|
||||||
|
dim: '\x1b[2m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
magenta: '\x1b[35m',
|
||||||
|
cyan: '\x1b[36m',
|
||||||
|
white: '\x1b[37m',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CLI argument parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function parseArgs() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
|
||||||
|
console.log(`
|
||||||
|
${C.bold}Jackbox API Logger${C.reset}
|
||||||
|
Connects to a Jackbox game room and logs all WebSocket events.
|
||||||
|
|
||||||
|
${C.bold}Usage:${C.reset}
|
||||||
|
node tools/jackbox-logger.js <ROOM_CODE> [options]
|
||||||
|
|
||||||
|
${C.bold}Options:${C.reset}
|
||||||
|
--role <shard|audience|player> Connection role (default: shard)
|
||||||
|
--name <name> Display name (default: JBLogger)
|
||||||
|
--no-file Skip writing to log file
|
||||||
|
--verbose Print full JSON to console
|
||||||
|
--help, -h Show this help
|
||||||
|
|
||||||
|
${C.bold}Examples:${C.reset}
|
||||||
|
node tools/jackbox-logger.js ABCD
|
||||||
|
node tools/jackbox-logger.js ABCD --role audience
|
||||||
|
node tools/jackbox-logger.js ABCD --verbose --no-file
|
||||||
|
`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
roomCode: null,
|
||||||
|
role: 'shard',
|
||||||
|
name: 'JBLogger',
|
||||||
|
noFile: false,
|
||||||
|
verbose: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === '--role') {
|
||||||
|
opts.role = args[++i];
|
||||||
|
} else if (arg === '--name') {
|
||||||
|
opts.name = args[++i];
|
||||||
|
} else if (arg === '--no-file') {
|
||||||
|
opts.noFile = true;
|
||||||
|
} else if (arg === '--verbose') {
|
||||||
|
opts.verbose = true;
|
||||||
|
} else if (!arg.startsWith('-') && !opts.roomCode) {
|
||||||
|
opts.roomCode = arg.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts.roomCode) {
|
||||||
|
console.error(`${C.red}Error: room code is required${C.reset}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['shard', 'audience', 'player'].includes(opts.role)) {
|
||||||
|
console.error(`${C.red}Error: --role must be shard, audience, or player${C.reset}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// REST: fetch room info
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function getRoomInfo(roomCode) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get(`https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => (data += chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
if (json.ok) resolve(json.body);
|
||||||
|
else reject(new Error(json.error || 'Room not found'));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Timestamp
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ts() {
|
||||||
|
return new Date().toISOString().slice(11, 23);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Console summarizer — merges patterns from ws-probe.js and ws-lifecycle-test.js
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function summarize(msg) {
|
||||||
|
const r = msg.result || {};
|
||||||
|
|
||||||
|
switch (msg.opcode) {
|
||||||
|
case 'client/welcome': {
|
||||||
|
const hereEntries = r.here ? Object.entries(r.here) : [];
|
||||||
|
const players = hereEntries
|
||||||
|
.filter(([, v]) => v.roles?.player)
|
||||||
|
.map(([id, v]) => `${v.roles.player.name}(${id})`);
|
||||||
|
const entityKeys = r.entities ? Object.keys(r.entities) : [];
|
||||||
|
return (
|
||||||
|
`id=${r.id} reconnect=${r.reconnect} secret=${r.secret}\n` +
|
||||||
|
` here: ${hereEntries.length} connections [${players.join(', ') || 'no players'}]\n` +
|
||||||
|
` entities: [${entityKeys.join(', ')}]`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'object': {
|
||||||
|
if (r.key === 'room' || r.key === 'bc:room') {
|
||||||
|
const v = r.val || {};
|
||||||
|
return `ROOM state=${v.state} lobby=${v.lobbyState} canStart=${v.gameCanStart} starting=${v.gameIsStarting} finished=${v.gameFinished} v${r.version}`;
|
||||||
|
}
|
||||||
|
if (r.key === 'textDescriptions') {
|
||||||
|
const latest = r.val?.latestDescriptions;
|
||||||
|
if (Array.isArray(latest) && latest.length > 0) {
|
||||||
|
const last = latest[latest.length - 1];
|
||||||
|
return `TEXT "${last.text}" (${last.category}) v${r.version}`;
|
||||||
|
}
|
||||||
|
return `textDescriptions v${r.version}`;
|
||||||
|
}
|
||||||
|
if (r.key?.startsWith('player:')) {
|
||||||
|
const v = r.val || {};
|
||||||
|
return `PLAYER ${r.key} state=${v.state || 'init'} name=${v.playerName || '?'} vip=${v.playerIsVIP} v${r.version}`;
|
||||||
|
}
|
||||||
|
const valKeys = r.val ? Object.keys(r.val).slice(0, 5).join(',') : 'null';
|
||||||
|
return `ENTITY ${r.key} v${r.version} from=${r.from} val=[${valKeys}...]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'client/connected': {
|
||||||
|
const roleName = r.roles ? Object.keys(r.roles)[0] : r.role;
|
||||||
|
const playerName = r.roles?.player?.name || r.name || '';
|
||||||
|
return `id=${r.id} userId=${r.userId} role=${roleName} name=${playerName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'client/disconnected': {
|
||||||
|
const roleName = r.roles ? Object.keys(r.roles)[0] : r.role;
|
||||||
|
return `id=${r.id} role=${roleName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'client/kicked':
|
||||||
|
return JSON.stringify(r).slice(0, 200);
|
||||||
|
|
||||||
|
case 'room/lock':
|
||||||
|
return 'room locked (game starting)';
|
||||||
|
|
||||||
|
case 'room/exit':
|
||||||
|
return 'room closed';
|
||||||
|
|
||||||
|
case 'room/get-audience':
|
||||||
|
return `audience connections=${r.connections}`;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
return `code=${r.code}: ${r.msg}`;
|
||||||
|
|
||||||
|
case 'ok':
|
||||||
|
return `seq response`;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return JSON.stringify(r).slice(0, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function opcodeColor(opcode) {
|
||||||
|
if (opcode === 'client/welcome') return C.green;
|
||||||
|
if (opcode === 'error') return C.red;
|
||||||
|
if (opcode === 'client/connected') return C.cyan;
|
||||||
|
if (opcode === 'client/disconnected') return C.yellow;
|
||||||
|
if (opcode === 'room/lock' || opcode === 'room/exit') return C.magenta;
|
||||||
|
if (opcode === 'object') return C.white;
|
||||||
|
return C.dim;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// File logger
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class FileLogger {
|
||||||
|
constructor(roomCode) {
|
||||||
|
const logsDir = path.join(__dirname, '..', 'logs');
|
||||||
|
fs.mkdirSync(logsDir, { recursive: true });
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
this.filePath = path.join(logsDir, `jackbox-${roomCode}-${timestamp}.jsonl`);
|
||||||
|
this.stream = fs.createWriteStream(this.filePath, { flags: 'a' });
|
||||||
|
}
|
||||||
|
|
||||||
|
write(entry) {
|
||||||
|
this.stream.write(JSON.stringify(entry) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.stream.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main logger
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function main() {
|
||||||
|
const opts = parseArgs();
|
||||||
|
const { roomCode, role, name, noFile, verbose } = opts;
|
||||||
|
|
||||||
|
console.log(`${C.bold}Jackbox API Logger${C.reset}`);
|
||||||
|
console.log(`${C.cyan}Room:${C.reset} ${roomCode} ${C.cyan}Role:${C.reset} ${role} ${C.cyan}Name:${C.reset} ${name}`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// Fetch room info
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} Fetching room info...`);
|
||||||
|
let roomInfo;
|
||||||
|
try {
|
||||||
|
roomInfo = await getRoomInfo(roomCode);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`${C.red}Failed to fetch room info: ${e.message}${C.reset}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.green}Room found${C.reset}`);
|
||||||
|
console.log(` ${C.cyan}Game:${C.reset} ${roomInfo.appTag}`);
|
||||||
|
console.log(` ${C.cyan}Host:${C.reset} ${roomInfo.host}`);
|
||||||
|
console.log(` ${C.cyan}Players:${C.reset} max ${roomInfo.maxPlayers}`);
|
||||||
|
console.log(` ${C.cyan}Locked:${C.reset} ${roomInfo.locked}`);
|
||||||
|
console.log(` ${C.cyan}Full:${C.reset} ${roomInfo.full}`);
|
||||||
|
console.log(` ${C.cyan}Audience:${C.reset} ${roomInfo.audienceEnabled ? 'enabled' : 'disabled'}`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// File logger
|
||||||
|
let fileLogger = null;
|
||||||
|
if (!noFile) {
|
||||||
|
fileLogger = new FileLogger(roomCode);
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} Logging to ${C.bold}${fileLogger.filePath}${C.reset}`);
|
||||||
|
fileLogger.write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
direction: 'meta',
|
||||||
|
type: 'session_start',
|
||||||
|
roomCode,
|
||||||
|
role,
|
||||||
|
name,
|
||||||
|
host: roomInfo.host,
|
||||||
|
appTag: roomInfo.appTag,
|
||||||
|
maxPlayers: roomInfo.maxPlayers,
|
||||||
|
locked: roomInfo.locked,
|
||||||
|
full: roomInfo.full,
|
||||||
|
audienceEnabled: roomInfo.audienceEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// State for reconnection
|
||||||
|
let shardId = null;
|
||||||
|
let secret = null;
|
||||||
|
let msgCount = 0;
|
||||||
|
let reconnecting = false;
|
||||||
|
let manuallyStopped = false;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
function buildWsUrl(reconnect) {
|
||||||
|
if (role === 'audience') {
|
||||||
|
return `wss://${roomInfo.audienceHost || roomInfo.host}/api/v2/audience/${roomCode}/play`;
|
||||||
|
}
|
||||||
|
const base = `wss://${roomInfo.host}/api/v2/rooms/${roomCode}/play`;
|
||||||
|
if (reconnect && secret && shardId) {
|
||||||
|
return `${base}?role=${role}&name=${encodeURIComponent(name)}&format=json&secret=${secret}&id=${shardId}`;
|
||||||
|
}
|
||||||
|
return `${base}?role=${role}&name=${encodeURIComponent(name)}&userId=${name}-${Date.now()}&format=json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(isReconnect) {
|
||||||
|
const url = buildWsUrl(isReconnect);
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} ${isReconnect ? 'Reconnecting' : 'Connecting'}: ${C.dim}${url}${C.reset}`);
|
||||||
|
|
||||||
|
const ws = new WebSocket(url, ['ecast-v0'], {
|
||||||
|
headers: {
|
||||||
|
Origin: 'https://jackbox.tv',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||||
|
},
|
||||||
|
handshakeTimeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.green}${C.bold}CONNECTED${C.reset}`);
|
||||||
|
console.log();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
msgCount++;
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
|
||||||
|
// Capture credentials from welcome for reconnection
|
||||||
|
if (msg.opcode === 'client/welcome' && msg.result) {
|
||||||
|
shardId = msg.result.id;
|
||||||
|
secret = msg.result.secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Console output
|
||||||
|
const color = opcodeColor(msg.opcode);
|
||||||
|
const summary = summarize(msg);
|
||||||
|
console.log(
|
||||||
|
`${C.dim}[${ts()}]${C.reset} ${C.dim}#${msgCount} pc:${msg.pc ?? '-'}${C.reset} ${color}${C.bold}${msg.opcode}${C.reset} ${summary}`
|
||||||
|
);
|
||||||
|
if (verbose) {
|
||||||
|
console.log(JSON.stringify(msg, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// File output
|
||||||
|
if (fileLogger) {
|
||||||
|
fileLogger.write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
direction: 'recv',
|
||||||
|
msgNum: msgCount,
|
||||||
|
pc: msg.pc,
|
||||||
|
opcode: msg.opcode,
|
||||||
|
raw: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}UNPARSEABLE${C.reset} ${raw.toString().slice(0, 200)}`);
|
||||||
|
if (fileLogger) {
|
||||||
|
fileLogger.write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
direction: 'recv',
|
||||||
|
msgNum: msgCount,
|
||||||
|
parseError: true,
|
||||||
|
raw: raw.toString().slice(0, 2000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', (code, reason) => {
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}DISCONNECTED${C.reset} code=${code} reason=${reason}`);
|
||||||
|
if (fileLogger) {
|
||||||
|
fileLogger.write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
direction: 'meta',
|
||||||
|
type: 'disconnected',
|
||||||
|
code,
|
||||||
|
reason: reason?.toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!manuallyStopped && secret != null) {
|
||||||
|
reconnectWithBackoff(ws);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}WS ERROR: ${err.message}${C.reset}`);
|
||||||
|
if (fileLogger) {
|
||||||
|
fileLogger.write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
direction: 'meta',
|
||||||
|
type: 'error',
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SIGINT handler
|
||||||
|
const onSigint = () => {
|
||||||
|
manuallyStopped = true;
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
console.log();
|
||||||
|
console.log(`${C.bold}--- Session Summary ---${C.reset}`);
|
||||||
|
console.log(` ${C.cyan}Messages:${C.reset} ${msgCount}`);
|
||||||
|
console.log(` ${C.cyan}Duration:${C.reset} ${elapsed}s`);
|
||||||
|
if (fileLogger) {
|
||||||
|
console.log(` ${C.cyan}Log file:${C.reset} ${fileLogger.filePath}`);
|
||||||
|
fileLogger.write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
direction: 'meta',
|
||||||
|
type: 'session_end',
|
||||||
|
msgCount,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
fileLogger.close();
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
try {
|
||||||
|
ws.close(1000, 'Logger stopped');
|
||||||
|
} catch (_) {}
|
||||||
|
setTimeout(() => process.exit(0), 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.removeAllListeners('SIGINT');
|
||||||
|
process.on('SIGINT', onSigint);
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconnectWithBackoff() {
|
||||||
|
if (reconnecting || manuallyStopped) return;
|
||||||
|
reconnecting = true;
|
||||||
|
const delays = [2000, 4000, 8000];
|
||||||
|
|
||||||
|
for (let i = 0; i < delays.length; i++) {
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}Reconnect attempt ${i + 1}/${delays.length} in ${delays[i] / 1000}s...${C.reset}`);
|
||||||
|
await new Promise((r) => setTimeout(r, delays[i]));
|
||||||
|
|
||||||
|
if (manuallyStopped) {
|
||||||
|
reconnecting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const freshRoom = await getRoomInfo(roomCode);
|
||||||
|
if (!freshRoom) {
|
||||||
|
console.log(`${C.dim}[${ts()}]${C.reset} ${C.red}Room no longer exists${C.reset}`);
|
||||||
|
reconnecting = false;
|
||||||
|
shutdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
roomInfo.host = freshRoom.host;
|
||||||
|
connect(true);
|
||||||
|
reconnecting = false;
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}Reconnect attempt ${i + 1} failed: ${e.message}${C.reset}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}${C.bold}All reconnect attempts failed. Exiting.${C.reset}`);
|
||||||
|
reconnecting = false;
|
||||||
|
shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
console.log();
|
||||||
|
console.log(`${C.bold}--- Session Summary ---${C.reset}`);
|
||||||
|
console.log(` ${C.cyan}Messages:${C.reset} ${msgCount}`);
|
||||||
|
console.log(` ${C.cyan}Duration:${C.reset} ${elapsed}s`);
|
||||||
|
if (fileLogger) {
|
||||||
|
console.log(` ${C.cyan}Log file:${C.reset} ${fileLogger.filePath}`);
|
||||||
|
fileLogger.write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
direction: 'meta',
|
||||||
|
type: 'session_end',
|
||||||
|
msgCount,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
fileLogger.close();
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(`${C.red}Fatal: ${e.message}${C.reset}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user