Switch to user_id auth and update docs

This commit is contained in:
2026-01-14 22:32:02 +01:00
parent 092c1585c0
commit 7f59116ce9
4 changed files with 196 additions and 262 deletions

235
README.md
View File

@@ -4,30 +4,49 @@ A lightweight, responsive web application for tracking Magic: The Gathering EDH/
## Features
- **Secure Authentication**: JWT-based login system with password hashing
- **Commander Management**: Track all your commanders with MTG color identity
- **Game Logging**: Detailed game statistics including:
- Player count (2-8 players)
- Commander played
- Game date and duration
- Win/loss tracking
- Round count
- Starting player advantage
- Sol Ring turn one impact
- **Statistics Dashboard**: Visual analytics with Chart.js
- **Live Round Counter**: Track ongoing games in real-time
- **Responsive Design**: Mobile-friendly interface with Tailwind CSS
### ✅ Implemented
- **Secure Authentication**: JWT-based login/registration system with password hashing (HS512).
- **Commander Management**:
- CRUD operations for Commanders.
- MTG Color Identity picker (WUBRG).
- Search and filter functionality.
- Validation for names and colors.
- **Game Logging**:
- Log game results (Win/Loss).
- Track player count, rounds, and specific win conditions (Starting Player, Sol Ring T1).
- Associate games with specific Commanders.
- **Statistics Dashboard**:
- **Overview**: Total games, win rate, active decks, average rounds.
- **Visualizations**:
- Win Rate by Color Identity (Chart.js Doughnut).
- Win Rate by Player Count (Chart.js Bar).
- **Detailed Tables**: Per-commander performance metrics.
- **Responsive UI**: Mobile-friendly design using Tailwind CSS and Alpine.js.
- **Infrastructure**: Docker Compose setup for development and production.
### 🚧 Pending / Roadmap
- **Live Round Counter**: Interactive real-time counter during games (currently post-game entry only).
- **Advanced Trends**: Historical performance trends over time (endpoints `/api/stats/trends` not yet implemented).
- **Commander Comparison**: Direct head-to-head comparison tool.
- **HTTPS Configuration**: Production Nginx setup with SSL.
- **Unit/Integration Tests**: Comprehensive test suite (currently partial).
## Technology Stack
- **Backend**: Fastify with JWT authentication (HS512)
- **Database**: SQLite with volume persistence
- **Frontend**: Alpine.js (~10KB) with Tailwind CSS
- **Deployment**: Docker Compose with multi-stage builds
- **Charts**: Chart.js with Alpine.js reactivity
- **Backend**: Fastify (Node.js v20+)
- **Database**: SQLite (better-sqlite3) with WAL mode
- **Frontend**: Alpine.js, Tailwind CSS (CDN)
- **Visualization**: Chart.js
- **Containerization**: Docker & Docker Compose
## Quick Start
### Prerequisites
- Docker & Docker Compose
- Git
### Running with Docker (Recommended)
```bash
# Clone the repository
git clone <repository-url>
@@ -37,149 +56,85 @@ cd edh-stats
docker-compose up -d
# Access the application
# Frontend: http://localhost:80
# Backend API: http://localhost:3000
# Frontend: http://localhost:8081
# Backend API: http://localhost:3002
```
> **Note:** Default ports are `8081` (Frontend) and `3002` (Backend) to avoid conflicts.
### Local Development
If you prefer running without Docker:
```bash
# Backend
cd backend
npm install
npm run dev
# Frontend (served via simple HTTP server or Nginx)
cd frontend
# Use any static file server, e.g., 'serve'
npx serve public -p 8081
```
## Project Structure
```
edh-stats/
├── README.md # This documentation
├── docker-compose.yml # Development environment
├── docker-compose.prod.yml # Production environment
├── .env.example # Environment variables template
├── .gitignore # Git ignore patterns
├── backend/
│ ├── Dockerfile # Multi-stage Docker build
│ ├── package.json # Node.js dependencies
│ ├── .dockerignore # Docker build optimizations
└── src/
├── server.js # Main application entry point
── config/
├── models/
│ ├── routes/
│ ├── middleware/
│ ├── database/
│ └── utils/
│ ├── src/
│ ├── config/ # DB & Auth config
│ ├── database/ # Migrations & Seeds
│ ├── models/ # Data access layer (Commander, Game, User)
├── routes/ # API endpoints
── server.js # App entry point
└── Dockerfile
├── frontend/
│ ├── public/ # HTML pages
│ ├── css/ # Compiled styles
── js/ # Alpine.js components
└── database/
└── data/ # SQLite data directory
│ ├── public/ # Static assets
│ ├── css/ # Custom styles
│ ├── js/ # Alpine.js logic
│ │ └── *.html # Views
└── Dockerfile
├── database/ # Persisted SQLite data
├── docker-compose.yml # Dev orchestration
└── README.md
```
## API Endpoints
### Authentication (`/api/auth`)
- `POST /register` - User registration
- `POST /login` - JWT token generation
- `POST /refresh` - Token refresh
- `POST /register`
- `POST /login`
- `GET /me`
### Commanders (`/api/commanders`)
- `GET /` - List user's commanders
- `POST /` - Create new commander
- `PUT /:id` - Update commander
- `DELETE /:id` - Delete commander
- `GET /` - List/Search
- `POST /` - Create
- `GET /popular` - Top commanders
- `GET /:id` - Details
- `PUT /:id` - Update
- `DELETE /:id` - Remove
### Games (`/api/games`)
- `GET /` - List games with filtering
- `POST /` - Log new game
- `PUT /:id` - Update game
- `DELETE /:id` - Delete game
- `GET /` - History
- `POST /` - Log result
- `PUT /:id` - Edit record
- `DELETE /:id` - Remove record
### Statistics (`/api/stats`)
- `GET /overview` - Overall statistics
- `GET /commanders/:id` - Commander performance
- `GET /trends` - Performance trends
- `GET /comparison` - Commander comparison
- `GET /overview` - KPI cards
- `GET /commanders` - Detailed breakdown & charts
## Database Schema
## Development Notes
The application uses SQLite with the following main tables:
### Database
The SQLite database file is stored in `./database/data/edh-stats.db`. It uses `PRAGMA journal_mode = WAL` for performance.
Migrations are run automatically on server start if `NODE_ENV != 'test'`.
- `users` - User accounts and authentication
- `commanders` - Commander cards with color identity
- `games` - Game records with detailed statistics
## Development
### Prerequisites
- Docker and Docker Compose
- Node.js 20+ (for local development)
- Git
### Development Setup
```bash
# Install dependencies (backend)
cd backend
npm install
# Install dependencies (frontend)
cd ../frontend
npm install
# Start development environment
docker-compose up
# Or run locally
cd backend && npm run dev
cd frontend && npm run dev
```
### Environment Variables
Create a `.env` file from `.env.example`:
```bash
cp .env.example .env
```
Configure the following variables:
- `JWT_SECRET` - JWT signing secret
- `DATABASE_PATH` - SQLite database file location
- `NODE_ENV` - Environment (development/production)
- `CORS_ORIGIN` - Frontend domain
## Deployment
### Production Deployment
```bash
# Build and deploy to production
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# View logs
docker-compose logs -f
# Stop application
docker-compose down
```
### Security Notes
- Use strong JWT secrets in production
- Enable HTTPS in production
- Regular database backups recommended
- Monitor resource usage and logs
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request
### Frontend State
State management is handled by Alpine.js components (`commanderManager`, `gameManager`, `statsManager`).
Authentication tokens are stored in `localStorage` or `sessionStorage`.
## License
This project is licensed under the MIT License.
## Support
For issues and questions:
- Create an issue in the repository
- Check the documentation in `/docs`
- Review API documentation at `/docs/API.md`
MIT

View File

@@ -28,8 +28,8 @@ class Commander {
const commander = db.prepare(`
SELECT id, name, colors, user_id, created_at, updated_at
FROM commanders
WHERE id = ? AND dbManager.getCurrentUser() = ?
`).get([id, dbManager.getCurrentUser()])
WHERE id = ?
`).get([id])
return commander
} catch (error) {
@@ -44,7 +44,7 @@ class Commander {
const query = `
SELECT id, name, colors, user_id, created_at, updated_at
FROM commanders
WHERE dbManager.getCurrentUser() = ?
WHERE user_id = ?
`
if (sortBy) {
@@ -110,7 +110,7 @@ class Commander {
const result = db.prepare(`
UPDATE commanders
SET ${updates.join(', ')}
WHERE id = ? AND dbManager.getCurrentUser() = ?
WHERE id = ? AND user_id = ?
`).run([...values, id, userId])
return result.changes > 0
@@ -131,7 +131,7 @@ class Commander {
const result = db.prepare(`
DELETE FROM commanders
WHERE id = ? AND dbManager.getCurrentUser() = ?
WHERE id = ? AND user_id = ?
`).run([id, userId])
return result.changes > 0
@@ -184,7 +184,7 @@ class Commander {
const commanders = db.prepare(`
SELECT id, name, colors, user_id, created_at, updated_at
FROM commanders
WHERE dbManager.getCurrentUser() = ?
WHERE user_id = ?
ORDER BY name ASC
LIMIT ?
`).all([userId, searchQuery, limit])

View File

@@ -25,7 +25,6 @@ export default async function commanderRoutes(fastify, options) {
preHandler: [async (request, reply) => {
try {
await request.jwtVerify()
dbManager.setCurrentUser(request.user.id)
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
@@ -63,7 +62,6 @@ export default async function commanderRoutes(fastify, options) {
preHandler: [async (request, reply) => {
try {
await request.jwtVerify()
dbManager.setCurrentUser(request.user.id)
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
@@ -107,7 +105,6 @@ export default async function commanderRoutes(fastify, options) {
preHandler: [async (request, reply) => {
try {
await request.jwtVerify()
dbManager.setCurrentUser(request.user.id)
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
@@ -117,8 +114,9 @@ export default async function commanderRoutes(fastify, options) {
}]
}, async (request, reply) => {
try {
const validatedData = createCommanderSchema.parse(request.body)
// Manually parse since fastify.decorate request.user is set by jwtVerify
const userId = request.user.id
const validatedData = createCommanderSchema.parse(request.body)
const commander = await Commander.create({
...validatedData,
@@ -155,7 +153,6 @@ export default async function commanderRoutes(fastify, options) {
preHandler: [async (request, reply) => {
try {
await request.jwtVerify()
dbManager.setCurrentUser(request.user.id)
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
@@ -211,7 +208,6 @@ export default async function commanderRoutes(fastify, options) {
preHandler: [async (request, reply) => {
try {
await request.jwtVerify()
dbManager.setCurrentUser(request.user.id)
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
@@ -251,7 +247,6 @@ export default async function commanderRoutes(fastify, options) {
preHandler: [async (request, reply) => {
try {
await request.jwtVerify()
dbManager.setCurrentUser(request.user.id)
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
@@ -287,7 +282,6 @@ export default async function commanderRoutes(fastify, options) {
preHandler: [async (request, reply) => {
try {
await request.jwtVerify()
dbManager.setCurrentUser(request.user.id)
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',

View File

@@ -1,6 +1,7 @@
// Commander management Alpine.js components
function commanderManager() {
return {
// State
showAddForm: false,
editingCommander: null,
commanders: [],
@@ -10,15 +11,17 @@ function commanderManager() {
searchQuery: '',
submitting: false,
editSubmitting: false,
serverError: '',
// Form Data
newCommander: {
name: '',
colors: []
},
errors: {},
editErrors: {},
serverError: '',
// MTG Color data
// Constants
mtgColors: [
{ id: 'W', name: 'White', hex: '#F0E6D2' },
{ id: 'U', name: 'Blue', hex: '#0E68AB' },
@@ -27,17 +30,18 @@ function commanderManager() {
{ id: 'G', name: 'Green', hex: '#5A7A3B' }
],
// Lifecycle
async init() {
await this.loadCommanders()
},
// API Methods
async loadCommanders() {
this.loading = true
try {
const token = localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')
const response = await fetch('/api/commanders', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`
}
headers: { 'Authorization': `Bearer ${token}` }
})
if (response.ok) {
@@ -58,10 +62,9 @@ function commanderManager() {
this.loading = true
this.showPopular = true
try {
const token = localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')
const response = await fetch('/api/commanders/popular', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`
}
headers: { 'Authorization': `Bearer ${token}` }
})
if (response.ok) {
@@ -71,13 +74,14 @@ function commanderManager() {
this.serverError = 'Failed to load popular commanders'
}
} catch (error) {
console.error('Load popular commanders error:', error)
console.error('Load popular error:', error)
this.serverError = 'Network error occurred'
} finally {
this.loading = false
}
},
// Validation
validateCommanderName() {
if (!this.newCommander.name.trim()) {
this.errors.name = 'Commander name is required'
@@ -91,6 +95,7 @@ function commanderManager() {
},
validateEditCommanderName() {
if (!this.editingCommander) return
if (!this.editingCommander.name.trim()) {
this.editErrors.name = 'Commander name is required'
} else if (this.editingCommander.name.length < 2) {
@@ -102,21 +107,20 @@ function commanderManager() {
}
},
// Actions
async handleAddCommander() {
this.validateCommanderName()
if (this.errors.name) {
return
}
if (this.errors.name) return
this.submitting = true
this.serverError = ''
try {
const token = localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')
const response = await fetch('/api/commanders', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`,
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(this.newCommander)
@@ -126,7 +130,6 @@ function commanderManager() {
const data = await response.json()
this.commanders.unshift(data.commander)
this.resetAddForm()
await this.loadCommanders() // Refresh the list
} else {
const errorData = await response.json()
this.serverError = errorData.message || 'Failed to create commander'
@@ -140,20 +143,20 @@ function commanderManager() {
},
async handleUpdateCommander() {
if (!this.editingCommander) return
this.validateEditCommanderName()
if (this.editErrors.name) {
return
}
if (this.editErrors.name) return
this.editSubmitting = true
this.serverError = ''
try {
const token = localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')
const response = await fetch(`/api/commanders/${this.editingCommander.id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`,
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
@@ -164,7 +167,6 @@ function commanderManager() {
if (response.ok) {
const data = await response.json()
// Update the commander in the list
const index = this.commanders.findIndex(c => c.id === this.editingCommander.id)
if (index !== -1) {
this.commanders[index] = data.commander
@@ -181,6 +183,62 @@ function commanderManager() {
}
},
async deleteCommander(commander) {
if (!confirm(`Are you sure you want to delete "${commander.name}"?`)) return
try {
const token = localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')
const response = await fetch(`/api/commanders/${commander.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
})
if (response.ok) {
this.commanders = this.commanders.filter(c => c.id !== commander.id)
} else {
this.serverError = 'Failed to delete commander'
}
} catch (error) {
console.error('Delete error:', error)
this.serverError = 'Network error occurred'
}
},
// Search
async searchCommanders() {
if (!this.searchQuery.trim()) {
await this.loadCommanders()
return
}
this.loading = true
try {
const token = localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')
const response = await fetch(`/api/commanders?q=${encodeURIComponent(this.searchQuery)}`, {
headers: { 'Authorization': `Bearer ${token}` }
})
if (response.ok) {
const data = await response.json()
this.commanders = data.commanders || []
}
} catch (error) {
console.error('Search error:', error)
} finally {
this.loading = false
}
},
debounceSearch() {
clearTimeout(this._searchTimeout)
this._searchTimeout = setTimeout(() => {
if (this.showPopular) {
this.loadCommanders() // Reset to normal view if searching
this.showPopular = false
}
this.searchCommanders()
}, 300)
},
// UI Helpers
toggleNewColor(colorId) {
const index = this.newCommander.colors.indexOf(colorId)
if (index > -1) {
@@ -191,10 +249,11 @@ function commanderManager() {
},
toggleEditColor(colorId) {
if (!this.editingCommander) return
if (!this.editingCommander.colors) this.editingCommander.colors = []
const index = this.editingCommander.colors.indexOf(colorId)
if (index > -1) {
this.editingCommander.colors.splice(index, 1)
this.editingCommander.colors = this.editingCommander.colors.filter(c => c !== colorId)
} else {
this.editingCommander.colors.push(colorId)
}
@@ -214,33 +273,12 @@ function commanderManager() {
: 'ring-1 ring-offset-1 border-gray-300 hover:border-gray-400'
},
async deleteCommander(commander) {
if (!confirm(`Are you sure you want to delete "${commander.name}"? This action cannot be undone.`)) {
return
}
try {
const response = await fetch(`/api/commanders/${commander.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`
}
})
if (response.ok) {
// Remove from local list
this.commanders = this.commanders.filter(c => c.id !== commander.id)
} else {
this.serverError = 'Failed to delete commander'
}
} catch (error) {
console.error('Delete commander error:', error)
this.serverError = 'Network error occurred'
}
},
// Form Management
editCommander(commander) {
this.editingCommander = { ...commander }
this.editingCommander = JSON.parse(JSON.stringify(commander))
if (!Array.isArray(this.editingCommander.colors)) {
this.editingCommander.colors = []
}
this.editErrors = {}
this.serverError = ''
},
@@ -255,78 +293,25 @@ function commanderManager() {
this.newCommander = { name: '', colors: [] }
this.errors = {}
this.serverError = ''
},
resetEditForm() {
this.editingCommander = null
this.editErrors = {}
this.serverError = ''
},
async debounceSearch() {
this.$nextTick(() => {
if (this.showPopular) {
await this.loadCommanders()
} else {
await this.searchCommanders()
}
})
},
async searchCommanders() {
if (!this.searchQuery.trim()) {
await this.loadCommanders()
return
}
this.loading = true
this.serverError = ''
try {
const response = await fetch(`/api/commanders?q=${encodeURIComponent(this.searchQuery)}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`
}
})
if (response.ok) {
const data = await response.json()
this.commanders = data.commanders || []
} else {
this.serverError = 'Search failed'
}
} catch (error) {
console.error('Search commanders error:', error)
this.serverError = 'Network error occurred'
} finally {
this.loading = false
}
}
}
}
// Utility functions
// Global Utilities
function getColorName(colorId) {
const colorNames = {
'W': 'White',
'U': 'Blue',
'B': 'Black',
'R': 'Red',
'G': 'Green'
}
return colorNames[colorId] || colorId
const map = { 'W': 'White', 'U': 'Blue', 'B': 'Black', 'R': 'Red', 'G': 'Green' }
return map[colorId] || colorId
}
function formatDate(dateString) {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined
month: 'short', day: 'numeric', year: 'numeric'
})
}
// Make functions globally available
// Register Alpine component
document.addEventListener('alpine:init', () => {
Alpine.data('commanderManager', commanderManager)
})
})