Initial commit: VRBattles API
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Rust build artifacts
|
||||
/target/
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
65
Cargo.toml
Normal file
65
Cargo.toml
Normal file
@@ -0,0 +1,65 @@
|
||||
[package]
|
||||
name = "vrbattles-api"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["VRBattles Team"]
|
||||
description = "VRBattles Esports Platform API"
|
||||
|
||||
[dependencies]
|
||||
# Web Framework
|
||||
axum = { version = "0.7", features = ["macros", "multipart"] }
|
||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors", "trace", "limit"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "chrono", "uuid", "json"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Authentication
|
||||
jsonwebtoken = "9"
|
||||
argon2 = "0.5"
|
||||
md5 = "0.7" # For legacy PHP password verification
|
||||
|
||||
# Validation
|
||||
validator = { version = "0.18", features = ["derive"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
|
||||
# Logging & Tracing
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
# Configuration
|
||||
dotenvy = "0.15"
|
||||
config = "0.14"
|
||||
|
||||
# HTTP Client (for external APIs)
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
|
||||
# Utilities
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
rand = "0.8"
|
||||
base64 = "0.22"
|
||||
libm = "0.2" # Math functions for rating calculations
|
||||
|
||||
# S3 compatible storage
|
||||
aws-sdk-s3 = "1"
|
||||
aws-config = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
httpc-test = "0.1"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
138
DEPLOY.md
Normal file
138
DEPLOY.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Deploying VRBattles API with Dokploy
|
||||
|
||||
## Prerequisites
|
||||
- Dokploy installed on your server
|
||||
- Git repository (Gitea, GitHub, GitLab, etc.)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Step 1: Push to Git
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add production deployment configs"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### Step 2: Create Project in Dokploy
|
||||
|
||||
1. Open Dokploy dashboard
|
||||
2. Click **"Create Project"** → Give it a name (e.g., "VRBattles")
|
||||
3. Click **"Add Service"** → Select **"Compose"**
|
||||
|
||||
### Step 3: Configure the Compose Service
|
||||
|
||||
1. **Source**: Select your Git provider and repository
|
||||
2. **Branch**: `main` (or your default branch)
|
||||
3. **Compose Path**: `docker-compose.prod.yml`
|
||||
|
||||
### Step 4: Set Environment Variables
|
||||
|
||||
In Dokploy's Environment tab, add these **required** variables:
|
||||
|
||||
| Variable | Example | Description |
|
||||
|----------|---------|-------------|
|
||||
| `JWT_SECRET` | `$(openssl rand -base64 32)` | Generate a secure random string |
|
||||
| `POSTGRES_PASSWORD` | `your_secure_db_password` | Database password |
|
||||
| `FRONTEND_URL` | `https://vrbattles.com` | Your frontend URL |
|
||||
| `BASE_URL` | `https://api.vrbattles.com` | API base URL |
|
||||
|
||||
**Optional variables** (for S3 storage):
|
||||
- `S3_BUCKET`
|
||||
- `S3_REGION`
|
||||
- `AWS_ACCESS_KEY_ID`
|
||||
- `AWS_SECRET_ACCESS_KEY`
|
||||
|
||||
### Step 5: Configure Domain (Optional)
|
||||
|
||||
1. Go to **Domains** tab in Dokploy
|
||||
2. Add your domain: `api.yourdomain.com`
|
||||
3. Dokploy will auto-configure SSL via Let's Encrypt
|
||||
|
||||
### Step 6: Deploy!
|
||||
|
||||
Click **"Deploy"** and watch the logs.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Dokploy │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ docker-compose.prod.yml │ │
|
||||
│ │ ┌─────────────┐ ┌──────────────┐ │ │
|
||||
│ │ │ api:3000 │────│ db:5432 │ │ │
|
||||
│ │ │ (Rust API) │ │ (PostgreSQL) │ │ │
|
||||
│ │ └─────────────┘ └──────────────┘ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ▼ ▼ │ │
|
||||
│ │ uploads_data postgres_data │ │
|
||||
│ │ (volume) (volume) │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Traefik (reverse proxy) │
|
||||
│ SSL termination │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
https://api.yourdomain.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Useful Commands
|
||||
|
||||
### View Logs
|
||||
In Dokploy UI → Logs tab, or SSH to server:
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml logs -f api
|
||||
```
|
||||
|
||||
### Access Database
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml exec db psql -U postgres -d vrbattles
|
||||
```
|
||||
|
||||
### Manual Deploy
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
### Backup Database
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml exec db pg_dump -U postgres vrbattles > backup.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### API won't start
|
||||
1. Check logs in Dokploy
|
||||
2. Verify `DATABASE_URL` is correct
|
||||
3. Ensure `JWT_SECRET` is set
|
||||
|
||||
### Database connection failed
|
||||
1. Check if db service is healthy
|
||||
2. Verify `POSTGRES_PASSWORD` matches in both services
|
||||
|
||||
### Uploads not working
|
||||
1. Check `uploads_data` volume is mounted
|
||||
2. Verify `BASE_URL` is set correctly
|
||||
3. For S3: check AWS credentials
|
||||
|
||||
---
|
||||
|
||||
## Migrating Data
|
||||
|
||||
If you have existing data to import:
|
||||
|
||||
```bash
|
||||
# Copy SQL dump to server
|
||||
scp backup.sql user@server:/tmp/
|
||||
|
||||
# Import into running container
|
||||
docker compose -f docker-compose.prod.yml exec -T db psql -U postgres -d vrbattles < /tmp/backup.sql
|
||||
```
|
||||
60
Dockerfile
Normal file
60
Dockerfile
Normal file
@@ -0,0 +1,60 @@
|
||||
# Build stage
|
||||
FROM rust:latest AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy manifests
|
||||
COPY Cargo.toml Cargo.lock* ./
|
||||
|
||||
# Create dummy main.rs to cache dependencies
|
||||
RUN mkdir src && echo "fn main() {}" > src/main.rs
|
||||
|
||||
# Build dependencies (this will be cached)
|
||||
RUN cargo build --release && rm -rf src
|
||||
|
||||
# Copy source code
|
||||
COPY src ./src
|
||||
COPY migrations ./migrations
|
||||
|
||||
# Build the actual application
|
||||
RUN touch src/main.rs && cargo build --release
|
||||
|
||||
# Runtime stage
|
||||
FROM debian:trixie-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
libssl3 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy the binary
|
||||
COPY --from=builder /app/target/release/vrbattles-api /app/vrbattles-api
|
||||
|
||||
# Copy migrations
|
||||
COPY --from=builder /app/migrations /app/migrations
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -r -s /bin/false vrbattles && \
|
||||
chown -R vrbattles:vrbattles /app
|
||||
|
||||
USER vrbattles
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/health || exit 1
|
||||
|
||||
# Run the binary
|
||||
CMD ["./vrbattles-api"]
|
||||
93
docker-compose.prod.yml
Normal file
93
docker-compose.prod.yml
Normal file
@@ -0,0 +1,93 @@
|
||||
# =============================================================================
|
||||
# VRBattles API - Production Docker Compose for Dokploy
|
||||
# =============================================================================
|
||||
#
|
||||
# Dokploy Setup Instructions:
|
||||
# 1. Create a new "Compose" project in Dokploy
|
||||
# 2. Connect to your Git repository
|
||||
# 3. Set "Compose Path" to: docker-compose.prod.yml
|
||||
# 4. Add environment variables in Dokploy UI (see .env.example)
|
||||
# 5. Deploy!
|
||||
#
|
||||
# Required Environment Variables in Dokploy:
|
||||
# - DATABASE_URL (or use the db service below)
|
||||
# - JWT_SECRET (generate with: openssl rand -base64 32)
|
||||
# - FRONTEND_URL
|
||||
# - POSTGRES_PASSWORD (if using db service)
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL:-postgres://postgres:${POSTGRES_PASSWORD}@db:5432/vrbattles}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- JWT_EXPIRATION_HOURS=${JWT_EXPIRATION_HOURS:-24}
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- ENVIRONMENT=production
|
||||
- RUST_LOG=${RUST_LOG:-vrbattles_api=info,tower_http=info}
|
||||
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
|
||||
- BASE_URL=${BASE_URL:-http://localhost:3000}
|
||||
- UPLOAD_PATH=/app/uploads
|
||||
# Optional S3 config
|
||||
- S3_BUCKET=${S3_BUCKET:-}
|
||||
- S3_REGION=${S3_REGION:-}
|
||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
|
||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
|
||||
# Optional Email config
|
||||
- SMTP_HOST=${SMTP_HOST:-}
|
||||
- SMTP_PORT=${SMTP_PORT:-}
|
||||
- SMTP_USERNAME=${SMTP_USERNAME:-}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
|
||||
- FROM_EMAIL=${FROM_EMAIL:-}
|
||||
volumes:
|
||||
# Persist uploaded files
|
||||
- uploads_data:/app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- vrbattles-network
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-vrbattles}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- vrbattles-network
|
||||
# Uncomment if you need external access to the database
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
uploads_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
vrbattles-network:
|
||||
driver: bridge
|
||||
40
docker-compose.yml
Normal file
40
docker-compose.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- DATABASE_URL=postgres://postgres:postgres@db:5432/vrbattles
|
||||
- JWT_SECRET=dev-secret-key-change-in-production
|
||||
- JWT_EXPIRATION_HOURS=24
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- ENVIRONMENT=development
|
||||
- RUST_LOG=vrbattles_api=debug,tower_http=debug
|
||||
- FRONTEND_URL=http://localhost:3000
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=vrbattles
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
55
env.example
Normal file
55
env.example
Normal file
@@ -0,0 +1,55 @@
|
||||
# =============================================================================
|
||||
# VRBattles API Environment Configuration
|
||||
# Copy this file to .env and fill in your values
|
||||
# =============================================================================
|
||||
|
||||
# Database Configuration
|
||||
# For Dokploy: Use the internal service name if running PostgreSQL in same compose
|
||||
DATABASE_URL=postgres://postgres:your_secure_password@db:5432/vrbattles
|
||||
|
||||
# JWT Authentication
|
||||
# IMPORTANT: Generate a secure random string for production!
|
||||
# Example: openssl rand -base64 32
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this
|
||||
JWT_EXPIRATION_HOURS=24
|
||||
|
||||
# Server Configuration
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
ENVIRONMENT=production
|
||||
|
||||
# Logging
|
||||
# Options: error, warn, info, debug, trace
|
||||
RUST_LOG=vrbattles_api=info,tower_http=info
|
||||
|
||||
# Frontend URL (for CORS and email links)
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
|
||||
# =============================================================================
|
||||
# Optional: S3 Storage (for file uploads)
|
||||
# If not configured, files are stored locally in ./uploads
|
||||
# =============================================================================
|
||||
# S3_BUCKET=your-bucket-name
|
||||
# S3_REGION=us-east-1
|
||||
# AWS_ACCESS_KEY_ID=your-access-key
|
||||
# AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||
|
||||
# Local uploads (when S3 is not configured)
|
||||
UPLOAD_PATH=./uploads
|
||||
BASE_URL=https://api.yourdomain.com
|
||||
|
||||
# =============================================================================
|
||||
# Optional: Email Configuration (for notifications)
|
||||
# =============================================================================
|
||||
# SMTP_HOST=smtp.example.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USERNAME=your-smtp-user
|
||||
# SMTP_PASSWORD=your-smtp-password
|
||||
# FROM_EMAIL=noreply@yourdomain.com
|
||||
|
||||
# =============================================================================
|
||||
# PostgreSQL (for docker-compose db service)
|
||||
# =============================================================================
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=your_secure_password
|
||||
POSTGRES_DB=vrbattles
|
||||
119
migrations/20260119000001_initial_schema.sql
Normal file
119
migrations/20260119000001_initial_schema.sql
Normal file
@@ -0,0 +1,119 @@
|
||||
-- VRBattles Initial Schema Migration
|
||||
-- Converted from MySQL to PostgreSQL
|
||||
-- Compatible with existing vr_battles_tables database
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date_registered TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
firstname VARCHAR(100) NOT NULL,
|
||||
lastname VARCHAR(100) NOT NULL,
|
||||
username VARCHAR(50) NOT NULL,
|
||||
email VARCHAR(100) NOT NULL,
|
||||
password VARCHAR(255) NOT NULL, -- Supports both MD5 (legacy) and Argon2
|
||||
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
profile VARCHAR(255),
|
||||
password_reset_token VARCHAR(255)
|
||||
);
|
||||
|
||||
-- Index for faster lookups (unique constraints added separately for migration flexibility)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
|
||||
-- Teams table
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
logo TEXT NOT NULL DEFAULT '',
|
||||
bio TEXT,
|
||||
rank INTEGER NOT NULL DEFAULT 0,
|
||||
mmr REAL NOT NULL DEFAULT 1000.0,
|
||||
is_delete BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_teams_name ON teams(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_teams_rank ON teams(rank);
|
||||
|
||||
-- Team players (membership) table
|
||||
CREATE TABLE IF NOT EXISTS team_players (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
player_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
position VARCHAR(20) NOT NULL DEFAULT 'member',
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(team_id, player_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_players_team ON team_players(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_players_player ON team_players(player_id);
|
||||
|
||||
-- Ladders table
|
||||
CREATE TABLE IF NOT EXISTS ladders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
date_expiration VARCHAR(255) NOT NULL DEFAULT '',
|
||||
date_start VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_by_id INTEGER NOT NULL DEFAULT 0,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
logo VARCHAR(255)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ladders_name ON ladders(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_ladders_status ON ladders(status);
|
||||
|
||||
-- Ladder teams (teams enrolled in ladders)
|
||||
CREATE TABLE IF NOT EXISTS ladder_teams (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ladder_id INTEGER NOT NULL REFERENCES ladders(id) ON DELETE CASCADE,
|
||||
team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
UNIQUE(ladder_id, team_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ladder_teams_ladder ON ladder_teams(ladder_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ladder_teams_team ON ladder_teams(team_id);
|
||||
|
||||
-- Matches table
|
||||
CREATE TABLE IF NOT EXISTS matches (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
date_start TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by_id INTEGER NOT NULL DEFAULT 0,
|
||||
challenge_date TIMESTAMPTZ,
|
||||
team_id_1 INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
team_id_2 INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
team_1_status INTEGER NOT NULL DEFAULT 0, -- 0=challenging, 1=pending, 2=challenged, 3=rejected, 4=accepted
|
||||
team_2_status INTEGER NOT NULL DEFAULT 0,
|
||||
matche_status INTEGER NOT NULL DEFAULT 0 -- 0=pending, 1=scheduled, 2=in-progress, 3=done, 4=cancelled
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_team1 ON matches(team_id_1);
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_team2 ON matches(team_id_2);
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_status ON matches(matche_status);
|
||||
|
||||
-- Match rounds table
|
||||
CREATE TABLE IF NOT EXISTS match_rounds (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
match_id INTEGER NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
|
||||
team_1_score INTEGER NOT NULL DEFAULT 0,
|
||||
team_2_score INTEGER NOT NULL DEFAULT 0,
|
||||
score_posted_by_team_id INTEGER NOT NULL,
|
||||
score_get_by_teamid INTEGER,
|
||||
score_acceptance_status INTEGER NOT NULL DEFAULT 0 -- 0=pending, 1=accepted, 2=rejected
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_match_rounds_match ON match_rounds(match_id);
|
||||
|
||||
-- Featured players table
|
||||
CREATE TABLE IF NOT EXISTS featured_players (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
player_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
rank INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_featured_players_rank ON featured_players(rank);
|
||||
88
migrations/20260119000003_seed_original_data.sql
Normal file
88
migrations/20260119000003_seed_original_data.sql
Normal file
@@ -0,0 +1,88 @@
|
||||
-- Seed original VRBattles data from PHP database
|
||||
-- Note: Passwords are MD5 hashed - users will need to reset passwords or we support legacy login
|
||||
|
||||
-- Insert users (preserving original IDs)
|
||||
INSERT INTO users (id, date_registered, firstname, lastname, username, email, password, is_admin, profile) VALUES
|
||||
(2, '2022-01-06 11:17:59', 'Son', 'Goku', 'songoku', 'judithcharisma1978@gmail.com', '827ccb0eea8a706c4c34a16891f84e7b', FALSE, '1647586376.png'),
|
||||
(3, '2022-01-06 11:27:51', 'Game', 'Admin', 'admin', 'admin@gmail.com', '827ccb0eea8a706c4c34a16891f84e7b', TRUE, '1647586388.png'),
|
||||
(8, '2022-02-10 13:02:05', 'Test', 'Player', 'tester', 'test@gmail.com', '827ccb0eea8a706c4c34a16891f84e7b', FALSE, '1647586403.png'),
|
||||
(9, '2022-02-15 20:14:00', 'Demo', 'Player', 'demo', 'demo@gmail.com', '827ccb0eea8a706c4c34a16891f84e7b', FALSE, '1647586441.png'),
|
||||
(10, '2022-02-15 20:18:07', 'Mark', 'Jimenez', 'markjimenez', 'markjimenez@gmail.com', '827ccb0eea8a706c4c34a16891f84e7b', FALSE, '1647586416.png'),
|
||||
(15, '2022-03-14 13:56:56', 'Crysthel', 'Triones', 'crysthel', 'crysthelanndelostrinos1234@gmail.com', '827ccb0eea8a706c4c34a16891f84e7b', FALSE, '1647586426.png'),
|
||||
(16, '2022-03-21 11:56:43', 'crazy', 'coder', 'crazycoder', 'crazycoder09@gmail.com', 'e10adc3949ba59abbe56e057f20f883e', FALSE, NULL),
|
||||
(17, '2022-03-30 17:57:12', 'tony', 'stark', 'mark47', 'mark47@gmail.com', 'e10adc3949ba59abbe56e057f20f883e', FALSE, NULL)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Reset sequence to continue after max id
|
||||
SELECT setval('users_id_seq', (SELECT MAX(id) FROM users));
|
||||
|
||||
-- Insert teams (preserving original IDs)
|
||||
INSERT INTO teams (id, date_created, name, logo, bio, rank, mmr, is_delete) VALUES
|
||||
(15, '2022-02-11 17:09:16', 'Black Ninja', '1647586615.png', 'Together we stand, Divided we fall!', 1, 1000, FALSE),
|
||||
(16, '2022-02-15 20:19:11', 'Cubers', '1647586637.png', 'Run to death', 2, 1000, FALSE),
|
||||
(40, '2022-02-25 15:30:37', 'Team Warzone', '1647586662.png', 'Best of the best', 3, 1000, FALSE)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Reset sequence
|
||||
SELECT setval('teams_id_seq', (SELECT MAX(id) FROM teams));
|
||||
|
||||
-- Insert team players (preserving original IDs)
|
||||
INSERT INTO team_players (id, date_created, team_id, player_id, position, status) VALUES
|
||||
(24, '2022-02-15 20:19:12', 16, 10, 'captain', 1),
|
||||
(28, '2022-02-18 18:09:30', 15, 9, 'co-captain', 1),
|
||||
(43, '2022-02-25 15:30:37', 40, 8, 'captain', 1)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Reset sequence
|
||||
SELECT setval('team_players_id_seq', (SELECT MAX(id) FROM team_players));
|
||||
|
||||
-- Insert ladders (preserving original IDs)
|
||||
INSERT INTO ladders (id, date_created, date_expiration, date_start, created_by_id, name, status, logo) VALUES
|
||||
(10, '2022-02-24 15:46:57', '2022-02-25', '2022-02-25', 0, 'Mighty Worlds', 0, '1647574565.png'),
|
||||
(11, '2022-02-24 15:53:14', '2022-02-25', '2022-02-25', 8, 'Hardcores', 0, '1647574578.png'),
|
||||
(12, '2022-02-24 16:43:41', '2022-02-24', '2022-02-24', 8, 'King of the Hill', 0, '1647574590.png'),
|
||||
(15, '2022-02-25 14:50:28', '2022-02-25', '2022-02-25', 3, 'World Dominator', 0, '1647574600.png'),
|
||||
(16, '2022-02-25 14:51:19', '2022-02-25', '2022-02-25', 3, 'Upcoming Ladder', 0, '1647574613.png'),
|
||||
(19, '2022-02-25 15:06:29', '2022-02-25', '2022-02-25', 3, 'Mighty Kids', 1, NULL),
|
||||
(22, '2022-02-25 15:10:20', '2022-02-25', '2022-02-25', 3, 'The Constructors', 1, NULL),
|
||||
(23, '2022-02-25 15:29:02', '2022-02-25', '2022-02-23', 8, 'Fools', 1, NULL)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Reset sequence
|
||||
SELECT setval('ladders_id_seq', (SELECT MAX(id) FROM ladders));
|
||||
|
||||
-- Insert ladder_teams (preserving original IDs)
|
||||
INSERT INTO ladder_teams (id, date_created, ladder_id, team_id) VALUES
|
||||
(4, '2022-03-18 17:19:44', 10, 15),
|
||||
(5, '2022-03-18 17:21:17', 11, 15),
|
||||
(6, '2022-03-18 17:21:24', 12, 15),
|
||||
(7, '2022-03-18 17:21:34', 15, 15),
|
||||
(8, '2022-03-18 17:21:53', 10, 16),
|
||||
(9, '2022-03-18 17:21:57', 11, 16),
|
||||
(10, '2022-03-18 17:22:21', 11, 40),
|
||||
(11, '2022-03-18 17:22:25', 12, 40),
|
||||
(12, '2022-03-18 17:22:29', 15, 40),
|
||||
(13, '2022-03-18 17:22:37', 16, 40)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Reset sequence
|
||||
SELECT setval('ladder_teams_id_seq', (SELECT MAX(id) FROM ladder_teams));
|
||||
|
||||
-- Insert matches (preserving original IDs)
|
||||
INSERT INTO matches (id, date_created, date_start, created_by_id, challenge_date, team_id_1, team_id_2, team_1_status, team_2_status, matche_status) VALUES
|
||||
(53, '2022-03-30 19:02:00', '2022-03-30 19:01:00', 10, '2022-04-07 16:59:00', 40, 16, 3, 3, 0),
|
||||
(54, '2022-04-01 17:51:11', '2022-04-01 17:51:00', 8, '2022-04-01 17:51:00', 15, 40, 0, 1, 0)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Reset sequence
|
||||
SELECT setval('matches_id_seq', (SELECT MAX(id) FROM matches));
|
||||
|
||||
-- Insert featured_players (preserving original IDs)
|
||||
INSERT INTO featured_players (id, date_created, player_id, rank) VALUES
|
||||
(1, '2022-03-18 15:07:22', 2, 1),
|
||||
(2, '2022-03-18 15:07:22', 8, 2),
|
||||
(3, '2022-03-18 15:07:33', 9, 3)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Reset sequence
|
||||
SELECT setval('featured_players_id_seq', (SELECT MAX(id) FROM featured_players));
|
||||
22
migrations/20260120000001_add_openskill_fields.sql
Normal file
22
migrations/20260120000001_add_openskill_fields.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- Add OpenSkill rating fields to teams and users
|
||||
-- OpenSkill uses mu (mean skill) and sigma (uncertainty)
|
||||
-- ordinal = mu - 3*sigma (display rating)
|
||||
|
||||
-- Add OpenSkill fields to teams
|
||||
ALTER TABLE teams ADD COLUMN IF NOT EXISTS mu REAL NOT NULL DEFAULT 25.0;
|
||||
ALTER TABLE teams ADD COLUMN IF NOT EXISTS sigma REAL NOT NULL DEFAULT 8.333;
|
||||
ALTER TABLE teams ADD COLUMN IF NOT EXISTS ordinal REAL NOT NULL DEFAULT 0.0;
|
||||
|
||||
-- Add OpenSkill fields to users (for individual player ratings)
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS mu REAL NOT NULL DEFAULT 25.0;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS sigma REAL NOT NULL DEFAULT 8.333;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS ordinal REAL NOT NULL DEFAULT 0.0;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS mmr REAL NOT NULL DEFAULT 1000.0;
|
||||
|
||||
-- Update existing ordinal values based on default mu/sigma
|
||||
UPDATE teams SET ordinal = mu - (3.0 * sigma) WHERE ordinal = 0.0;
|
||||
UPDATE users SET ordinal = mu - (3.0 * sigma) WHERE ordinal = 0.0;
|
||||
|
||||
-- Create indexes for faster ranking queries
|
||||
CREATE INDEX IF NOT EXISTS idx_teams_ordinal ON teams(ordinal DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_ordinal ON users(ordinal DESC);
|
||||
91
src/auth/jwt.rs
Normal file
91
src/auth/jwt.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
/// JWT Claims structure
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Claims {
|
||||
/// Subject (user ID)
|
||||
pub sub: i32,
|
||||
/// User's email
|
||||
pub email: String,
|
||||
/// Username
|
||||
pub username: String,
|
||||
/// Is admin flag
|
||||
pub is_admin: bool,
|
||||
/// Expiration time (Unix timestamp)
|
||||
pub exp: i64,
|
||||
/// Issued at time (Unix timestamp)
|
||||
pub iat: i64,
|
||||
}
|
||||
|
||||
impl Claims {
|
||||
/// Create new claims for a user
|
||||
pub fn new(user_id: i32, email: &str, username: &str, is_admin: bool, expiration_hours: i64) -> Self {
|
||||
let now = Utc::now();
|
||||
let exp = now + Duration::hours(expiration_hours);
|
||||
|
||||
Claims {
|
||||
sub: user_id,
|
||||
email: email.to_string(),
|
||||
username: username.to_string(),
|
||||
is_admin,
|
||||
exp: exp.timestamp(),
|
||||
iat: now.timestamp(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the user ID from claims
|
||||
pub fn user_id(&self) -> i32 {
|
||||
self.sub
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a JWT token for a user
|
||||
pub fn create_token(
|
||||
user_id: i32,
|
||||
email: &str,
|
||||
username: &str,
|
||||
is_admin: bool,
|
||||
secret: &str,
|
||||
expiration_hours: i64,
|
||||
) -> Result<String> {
|
||||
let claims = Claims::new(user_id, email, username, is_admin, expiration_hours);
|
||||
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| AppError::Internal(format!("Failed to create token: {}", e)))
|
||||
}
|
||||
|
||||
/// Decode and validate a JWT token
|
||||
pub fn decode_token(token: &str, secret: &str) -> Result<Claims> {
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)?;
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_and_decode_token() {
|
||||
let secret = "test_secret_key_123";
|
||||
let token = create_token(1, "test@example.com", "testuser", false, secret, 24).unwrap();
|
||||
|
||||
let claims = decode_token(&token, secret).unwrap();
|
||||
assert_eq!(claims.sub, 1);
|
||||
assert_eq!(claims.email, "test@example.com");
|
||||
assert_eq!(claims.username, "testuser");
|
||||
assert!(!claims.is_admin);
|
||||
}
|
||||
}
|
||||
121
src/auth/middleware.rs
Normal file
121
src/auth/middleware.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{header::AUTHORIZATION, request::Parts},
|
||||
RequestPartsExt,
|
||||
};
|
||||
use axum_extra::{
|
||||
headers::{authorization::Bearer, Authorization},
|
||||
TypedHeader,
|
||||
};
|
||||
|
||||
use super::jwt::{decode_token, Claims};
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Authenticated user extracted from JWT token
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthUser {
|
||||
pub id: i32,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
impl From<Claims> for AuthUser {
|
||||
fn from(claims: Claims) -> Self {
|
||||
AuthUser {
|
||||
id: claims.sub,
|
||||
email: claims.email,
|
||||
username: claims.username,
|
||||
is_admin: claims.is_admin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extractor for authenticated users
|
||||
///
|
||||
/// Usage in handlers:
|
||||
/// ```rust,ignore
|
||||
/// async fn protected_route(user: AuthUser) -> impl IntoResponse {
|
||||
/// format!("Hello, {}!", user.username)
|
||||
/// }
|
||||
/// ```
|
||||
#[axum::async_trait]
|
||||
impl<S> FromRequestParts<S> for AuthUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
// Get the JWT secret from extensions (set in app state)
|
||||
let jwt_secret = parts
|
||||
.extensions
|
||||
.get::<JwtSecret>()
|
||||
.ok_or_else(|| AppError::Internal("JWT secret not configured".to_string()))?
|
||||
.0
|
||||
.clone();
|
||||
|
||||
// Extract the Authorization header
|
||||
let TypedHeader(Authorization(bearer)) = parts
|
||||
.extract::<TypedHeader<Authorization<Bearer>>>()
|
||||
.await
|
||||
.map_err(|_| AppError::Unauthorized("Missing or invalid authorization header".to_string()))?;
|
||||
|
||||
// Decode and validate the token
|
||||
let claims = decode_token(bearer.token(), &jwt_secret)?;
|
||||
|
||||
Ok(AuthUser::from(claims))
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional authenticated user (for routes that work with or without auth)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OptionalAuthUser(pub Option<AuthUser>);
|
||||
|
||||
#[axum::async_trait]
|
||||
impl<S> FromRequestParts<S> for OptionalAuthUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
// Check if Authorization header exists
|
||||
if parts.headers.get(AUTHORIZATION).is_none() {
|
||||
return Ok(OptionalAuthUser(None));
|
||||
}
|
||||
|
||||
// Try to extract AuthUser
|
||||
match AuthUser::from_request_parts(parts, state).await {
|
||||
Ok(user) => Ok(OptionalAuthUser(Some(user))),
|
||||
Err(_) => Ok(OptionalAuthUser(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper type to store JWT secret in request extensions
|
||||
#[derive(Clone)]
|
||||
pub struct JwtSecret(pub String);
|
||||
|
||||
/// Admin-only user extractor
|
||||
/// Returns 403 Forbidden if user is not an admin
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdminUser(pub AuthUser);
|
||||
|
||||
#[axum::async_trait]
|
||||
impl<S> FromRequestParts<S> for AdminUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let user = AuthUser::from_request_parts(parts, state).await?;
|
||||
|
||||
if !user.is_admin {
|
||||
return Err(AppError::Forbidden("Admin access required".to_string()));
|
||||
}
|
||||
|
||||
Ok(AdminUser(user))
|
||||
}
|
||||
}
|
||||
7
src/auth/mod.rs
Normal file
7
src/auth/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod jwt;
|
||||
pub mod middleware;
|
||||
pub mod password;
|
||||
|
||||
pub use jwt::{create_token, Claims};
|
||||
pub use middleware::{AuthUser, OptionalAuthUser};
|
||||
pub use password::{hash_password, verify_password, needs_password_upgrade};
|
||||
100
src/auth/password.rs
Normal file
100
src/auth/password.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// Hash a password using Argon2
|
||||
pub fn hash_password(password: &str) -> Result<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
|
||||
let hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)?
|
||||
.to_string();
|
||||
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
/// Check if a hash is a legacy MD5 hash (32 hex characters)
|
||||
fn is_md5_hash(hash: &str) -> bool {
|
||||
hash.len() == 32 && hash.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Verify password against MD5 hash (legacy PHP passwords)
|
||||
fn verify_md5_password(password: &str, hash: &str) -> bool {
|
||||
let computed_hash = format!("{:x}", md5::compute(password.as_bytes()));
|
||||
computed_hash == hash.to_lowercase()
|
||||
}
|
||||
|
||||
/// Verify a password against a hash (supports both Argon2 and legacy MD5)
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
||||
// Check if this is a legacy MD5 hash from the PHP database
|
||||
if is_md5_hash(hash) {
|
||||
return Ok(verify_md5_password(password, hash));
|
||||
}
|
||||
|
||||
// Otherwise, verify using Argon2
|
||||
let parsed_hash = PasswordHash::new(hash)
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Invalid password hash: {}", e)))?;
|
||||
|
||||
Ok(Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
/// Check if a password needs to be upgraded from MD5 to Argon2
|
||||
pub fn needs_password_upgrade(hash: &str) -> bool {
|
||||
is_md5_hash(hash)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_and_verify_password() {
|
||||
let password = "secure_password_123";
|
||||
let hash = hash_password(password).unwrap();
|
||||
|
||||
assert!(verify_password(password, &hash).unwrap());
|
||||
assert!(!verify_password("wrong_password", &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_passwords_different_hashes() {
|
||||
let hash1 = hash_password("password1").unwrap();
|
||||
let hash2 = hash_password("password1").unwrap();
|
||||
|
||||
// Same password should produce different hashes (due to salt)
|
||||
assert_ne!(hash1, hash2);
|
||||
|
||||
// But both should verify correctly
|
||||
assert!(verify_password("password1", &hash1).unwrap());
|
||||
assert!(verify_password("password1", &hash2).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_md5_password_verification() {
|
||||
// "12345" hashed with MD5 (from original PHP database)
|
||||
let md5_hash = "827ccb0eea8a706c4c34a16891f84e7b";
|
||||
|
||||
assert!(verify_password("12345", md5_hash).unwrap());
|
||||
assert!(!verify_password("wrong", md5_hash).unwrap());
|
||||
|
||||
// "123456" hashed with MD5
|
||||
let md5_hash_2 = "e10adc3949ba59abbe56e057f20f883e";
|
||||
assert!(verify_password("123456", md5_hash_2).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_password_upgrade() {
|
||||
// MD5 hash should need upgrade
|
||||
assert!(needs_password_upgrade("827ccb0eea8a706c4c34a16891f84e7b"));
|
||||
|
||||
// Argon2 hash should not need upgrade
|
||||
let argon2_hash = hash_password("test").unwrap();
|
||||
assert!(!needs_password_upgrade(&argon2_hash));
|
||||
}
|
||||
}
|
||||
132
src/config.rs
Normal file
132
src/config.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use std::env;
|
||||
|
||||
/// Application configuration loaded from environment variables
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// Database connection URL
|
||||
pub database_url: String,
|
||||
/// JWT secret key for signing tokens
|
||||
pub jwt_secret: String,
|
||||
/// JWT token expiration in hours
|
||||
pub jwt_expiration_hours: i64,
|
||||
/// Server host address
|
||||
pub host: String,
|
||||
/// Server port
|
||||
pub port: u16,
|
||||
/// Environment (development, staging, production)
|
||||
pub environment: String,
|
||||
|
||||
// S3 Storage Configuration
|
||||
/// S3 bucket name for file uploads
|
||||
pub s3_bucket: Option<String>,
|
||||
/// S3 region (e.g., us-east-1)
|
||||
pub s3_region: Option<String>,
|
||||
/// S3 access key ID
|
||||
pub s3_access_key: Option<String>,
|
||||
/// S3 secret access key
|
||||
pub s3_secret_key: Option<String>,
|
||||
/// S3 endpoint URL (optional, for S3-compatible services)
|
||||
pub s3_endpoint: Option<String>,
|
||||
|
||||
// Local Storage Configuration
|
||||
/// Local upload path (when S3 is not configured)
|
||||
pub upload_path: Option<String>,
|
||||
/// Base URL for serving uploaded files
|
||||
pub base_url: Option<String>,
|
||||
|
||||
// Email Configuration
|
||||
/// SMTP host for email
|
||||
pub smtp_host: Option<String>,
|
||||
/// SMTP port
|
||||
pub smtp_port: Option<u16>,
|
||||
/// SMTP username
|
||||
pub smtp_username: Option<String>,
|
||||
/// SMTP password
|
||||
pub smtp_password: Option<String>,
|
||||
/// From email address
|
||||
pub from_email: Option<String>,
|
||||
|
||||
/// Frontend URL for email links
|
||||
pub frontend_url: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load configuration from environment variables
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
Ok(Config {
|
||||
database_url: env::var("DATABASE_URL")
|
||||
.map_err(|_| ConfigError::Missing("DATABASE_URL"))?,
|
||||
jwt_secret: env::var("JWT_SECRET")
|
||||
.map_err(|_| ConfigError::Missing("JWT_SECRET"))?,
|
||||
jwt_expiration_hours: env::var("JWT_EXPIRATION_HOURS")
|
||||
.unwrap_or_else(|_| "24".to_string())
|
||||
.parse()
|
||||
.map_err(|_| ConfigError::Invalid("JWT_EXPIRATION_HOURS"))?,
|
||||
host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
|
||||
port: env::var("PORT")
|
||||
.unwrap_or_else(|_| "3000".to_string())
|
||||
.parse()
|
||||
.map_err(|_| ConfigError::Invalid("PORT"))?,
|
||||
environment: env::var("ENVIRONMENT").unwrap_or_else(|_| "development".to_string()),
|
||||
|
||||
// S3 config - all optional, service will use local storage if not configured
|
||||
s3_bucket: env::var("S3_BUCKET").ok().filter(|s| !s.is_empty()),
|
||||
s3_region: env::var("S3_REGION")
|
||||
.or_else(|_| env::var("AWS_REGION"))
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty()),
|
||||
s3_access_key: env::var("S3_ACCESS_KEY")
|
||||
.or_else(|_| env::var("AWS_ACCESS_KEY_ID"))
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty()),
|
||||
s3_secret_key: env::var("S3_SECRET_KEY")
|
||||
.or_else(|_| env::var("AWS_SECRET_ACCESS_KEY"))
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty()),
|
||||
s3_endpoint: env::var("S3_ENDPOINT").ok().filter(|s| !s.is_empty()),
|
||||
|
||||
// Local storage config
|
||||
upload_path: env::var("UPLOAD_PATH").ok(),
|
||||
base_url: env::var("BASE_URL").ok(),
|
||||
|
||||
// Email config
|
||||
smtp_host: env::var("SMTP_HOST").ok(),
|
||||
smtp_port: env::var("SMTP_PORT").ok().and_then(|p| p.parse().ok()),
|
||||
smtp_username: env::var("SMTP_USERNAME").ok(),
|
||||
smtp_password: env::var("SMTP_PASSWORD").ok(),
|
||||
from_email: env::var("FROM_EMAIL").ok(),
|
||||
|
||||
frontend_url: env::var("FRONTEND_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:3000".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if running in production
|
||||
pub fn is_production(&self) -> bool {
|
||||
self.environment == "production"
|
||||
}
|
||||
|
||||
/// Get the server address
|
||||
pub fn server_addr(&self) -> String {
|
||||
format!("{}:{}", self.host, self.port)
|
||||
}
|
||||
|
||||
/// Check if S3 storage is configured
|
||||
pub fn has_s3_config(&self) -> bool {
|
||||
self.s3_bucket.is_some()
|
||||
&& self.s3_region.is_some()
|
||||
&& self.s3_access_key.is_some()
|
||||
&& self.s3_secret_key.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration errors
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("Missing required environment variable: {0}")]
|
||||
Missing(&'static str),
|
||||
#[error("Invalid value for environment variable: {0}")]
|
||||
Invalid(&'static str),
|
||||
}
|
||||
33
src/db/mod.rs
Normal file
33
src/db/mod.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use sqlx::{postgres::PgPoolOptions, PgPool};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// Type alias for the database connection pool
|
||||
pub type DbPool = PgPool;
|
||||
|
||||
/// Initialize the database connection pool
|
||||
pub async fn init_pool(config: &Config) -> Result<DbPool, sqlx::Error> {
|
||||
tracing::info!("Connecting to database...");
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.min_connections(2)
|
||||
.acquire_timeout(std::time::Duration::from_secs(30))
|
||||
.connect(&config.database_url)
|
||||
.await?;
|
||||
|
||||
tracing::info!("Database connection established");
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// Run database migrations
|
||||
pub async fn run_migrations(pool: &DbPool) -> Result<(), sqlx::migrate::MigrateError> {
|
||||
tracing::info!("Running database migrations...");
|
||||
|
||||
sqlx::migrate!("./migrations").run(pool).await?;
|
||||
|
||||
tracing::info!("Database migrations complete");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
135
src/error.rs
Normal file
135
src/error.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::fmt;
|
||||
|
||||
/// A type alias for Results with AppError
|
||||
pub type Result<T> = std::result::Result<T, AppError>;
|
||||
|
||||
/// Application error type that can be converted into HTTP responses
|
||||
#[derive(Debug)]
|
||||
pub enum AppError {
|
||||
/// 400 Bad Request
|
||||
BadRequest(String),
|
||||
/// 401 Unauthorized
|
||||
Unauthorized(String),
|
||||
/// 403 Forbidden
|
||||
Forbidden(String),
|
||||
/// 404 Not Found
|
||||
NotFound(String),
|
||||
/// 409 Conflict
|
||||
Conflict(String),
|
||||
/// 422 Unprocessable Entity (validation errors)
|
||||
Validation(String),
|
||||
/// 500 Internal Server Error
|
||||
Internal(String),
|
||||
/// Database error
|
||||
Database(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for AppError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
AppError::BadRequest(msg) => write!(f, "Bad request: {}", msg),
|
||||
AppError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
|
||||
AppError::Forbidden(msg) => write!(f, "Forbidden: {}", msg),
|
||||
AppError::NotFound(msg) => write!(f, "Not found: {}", msg),
|
||||
AppError::Conflict(msg) => write!(f, "Conflict: {}", msg),
|
||||
AppError::Validation(msg) => write!(f, "Validation error: {}", msg),
|
||||
AppError::Internal(msg) => write!(f, "Internal error: {}", msg),
|
||||
AppError::Database(msg) => write!(f, "Database error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AppError {}
|
||||
|
||||
/// Error response body
|
||||
#[derive(Serialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
details: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, error_type, message) = match &self {
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
|
||||
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "unauthorized", msg.clone()),
|
||||
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()),
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()),
|
||||
AppError::Conflict(msg) => (StatusCode::CONFLICT, "conflict", msg.clone()),
|
||||
AppError::Validation(msg) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, "validation_error", msg.clone())
|
||||
}
|
||||
AppError::Internal(msg) => {
|
||||
// Log internal errors but don't expose details to client
|
||||
tracing::error!("Internal error: {}", msg);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"An internal error occurred".to_string(),
|
||||
)
|
||||
}
|
||||
AppError::Database(msg) => {
|
||||
tracing::error!("Database error: {}", msg);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"database_error",
|
||||
"A database error occurred".to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let body = ErrorResponse {
|
||||
error: error_type.to_string(),
|
||||
message,
|
||||
details: None,
|
||||
};
|
||||
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
// Implement From traits for common error types
|
||||
|
||||
impl From<sqlx::Error> for AppError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
match err {
|
||||
sqlx::Error::RowNotFound => AppError::NotFound("Resource not found".to_string()),
|
||||
sqlx::Error::Database(db_err) => {
|
||||
// Check for unique constraint violations
|
||||
if let Some(code) = db_err.code() {
|
||||
if code == "23505" {
|
||||
return AppError::Conflict("Resource already exists".to_string());
|
||||
}
|
||||
}
|
||||
AppError::Database(db_err.to_string())
|
||||
}
|
||||
_ => AppError::Database(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<jsonwebtoken::errors::Error> for AppError {
|
||||
fn from(err: jsonwebtoken::errors::Error) -> Self {
|
||||
AppError::Unauthorized(format!("Invalid token: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<argon2::password_hash::Error> for AppError {
|
||||
fn from(err: argon2::password_hash::Error) -> Self {
|
||||
AppError::Internal(format!("Password hashing error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<validator::ValidationErrors> for AppError {
|
||||
fn from(err: validator::ValidationErrors) -> Self {
|
||||
AppError::Validation(err.to_string())
|
||||
}
|
||||
}
|
||||
182
src/handlers/auth.rs
Normal file
182
src/handlers/auth.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::{create_token, hash_password, verify_password},
|
||||
config::Config,
|
||||
error::{AppError, Result},
|
||||
models::user::User,
|
||||
};
|
||||
|
||||
/// Login request
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct LoginRequest {
|
||||
#[validate(email(message = "Invalid email format"))]
|
||||
pub email: String,
|
||||
#[validate(length(min = 1, message = "Password is required"))]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Login response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthResponse {
|
||||
pub token: String,
|
||||
pub user: UserResponse,
|
||||
}
|
||||
|
||||
/// User data in response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserResponse {
|
||||
pub id: i32,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub firstname: String,
|
||||
pub lastname: String,
|
||||
pub is_admin: bool,
|
||||
pub profile: Option<String>,
|
||||
}
|
||||
|
||||
impl From<User> for UserResponse {
|
||||
fn from(user: User) -> Self {
|
||||
UserResponse {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
firstname: user.firstname,
|
||||
lastname: user.lastname,
|
||||
is_admin: user.is_admin,
|
||||
profile: user.profile,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register request
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct RegisterRequest {
|
||||
#[validate(length(min = 1, max = 100, message = "First name is required"))]
|
||||
pub firstname: String,
|
||||
#[validate(length(min = 1, max = 100, message = "Last name is required"))]
|
||||
pub lastname: String,
|
||||
#[validate(length(min = 3, max = 50, message = "Username must be 3-50 characters"))]
|
||||
pub username: String,
|
||||
#[validate(email(message = "Invalid email format"))]
|
||||
pub email: String,
|
||||
#[validate(length(min = 5, message = "Password must be at least 5 characters"))]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// App state for auth handlers
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
pub config: Config,
|
||||
pub storage: crate::services::storage::StorageService,
|
||||
}
|
||||
|
||||
/// POST /api/auth/login
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> Result<Json<AuthResponse>> {
|
||||
payload.validate()?;
|
||||
|
||||
// Find user by email
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"SELECT * FROM users WHERE email = $1"
|
||||
)
|
||||
.bind(&payload.email)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Unauthorized("Invalid email or password".to_string()))?;
|
||||
|
||||
// Verify password
|
||||
if !verify_password(&payload.password, &user.password)? {
|
||||
return Err(AppError::Unauthorized("Invalid email or password".to_string()));
|
||||
}
|
||||
|
||||
// Create JWT token
|
||||
let token = create_token(
|
||||
user.id,
|
||||
&user.email,
|
||||
&user.username,
|
||||
user.is_admin,
|
||||
&state.config.jwt_secret,
|
||||
state.config.jwt_expiration_hours,
|
||||
)?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
token,
|
||||
user: user.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/auth/register
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> Result<(StatusCode, Json<AuthResponse>)> {
|
||||
payload.validate()?;
|
||||
|
||||
// Check if email already exists
|
||||
let existing_email = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM users WHERE email = $1"
|
||||
)
|
||||
.bind(&payload.email)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if existing_email.is_some() {
|
||||
return Err(AppError::Conflict("Email already registered".to_string()));
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
let existing_username = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM users WHERE username = $1"
|
||||
)
|
||||
.bind(&payload.username)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if existing_username.is_some() {
|
||||
return Err(AppError::Conflict("Username already taken".to_string()));
|
||||
}
|
||||
|
||||
// Hash password
|
||||
let password_hash = hash_password(&payload.password)?;
|
||||
|
||||
// Insert new user
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
INSERT INTO users (firstname, lastname, username, email, password, is_admin)
|
||||
VALUES ($1, $2, $3, $4, $5, false)
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(&payload.firstname)
|
||||
.bind(&payload.lastname)
|
||||
.bind(&payload.username)
|
||||
.bind(&payload.email)
|
||||
.bind(&password_hash)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Create JWT token
|
||||
let token = create_token(
|
||||
user.id,
|
||||
&user.email,
|
||||
&user.username,
|
||||
user.is_admin,
|
||||
&state.config.jwt_secret,
|
||||
state.config.jwt_expiration_hours,
|
||||
)?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(AuthResponse {
|
||||
token,
|
||||
user: user.into(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
35
src/handlers/health.rs
Normal file
35
src/handlers/health.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::auth::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HealthResponse {
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
pub database: String,
|
||||
}
|
||||
|
||||
/// Health check endpoint
|
||||
/// GET /health
|
||||
pub async fn health_check(State(state): State<AppState>) -> (StatusCode, Json<HealthResponse>) {
|
||||
// Check database connectivity
|
||||
let db_status = match sqlx::query("SELECT 1").fetch_one(&state.pool).await {
|
||||
Ok(_) => "connected".to_string(),
|
||||
Err(e) => format!("error: {}", e),
|
||||
};
|
||||
|
||||
let response = HealthResponse {
|
||||
status: "ok".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
database: db_status,
|
||||
};
|
||||
|
||||
let status = if response.database.starts_with("error") {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
|
||||
(status, Json(response))
|
||||
}
|
||||
596
src/handlers/ladders.rs
Normal file
596
src/handlers/ladders.rs
Normal file
@@ -0,0 +1,596 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::{AuthUser, OptionalAuthUser},
|
||||
error::{AppError, Result},
|
||||
models::ladder::Ladder,
|
||||
models::ladder_team::LadderTeam,
|
||||
};
|
||||
|
||||
use super::auth::AppState;
|
||||
|
||||
// ============================================================================
|
||||
// Request/Response Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ListLaddersQuery {
|
||||
pub search: Option<String>,
|
||||
pub status: Option<i32>, // 0=open, 1=closed
|
||||
pub page: Option<i64>,
|
||||
pub per_page: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LadderListResponse {
|
||||
pub ladders: Vec<LadderWithTeamCount>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
pub per_page: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct LadderWithTeamCount {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub logo: Option<String>,
|
||||
pub status: i32,
|
||||
pub date_start: Option<String>,
|
||||
pub date_expiration: Option<String>,
|
||||
pub date_created: chrono::DateTime<chrono::Utc>,
|
||||
pub created_by_id: i32,
|
||||
pub team_count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct CreateLadderRequest {
|
||||
#[validate(length(min = 2, max = 255, message = "Ladder name must be 2-255 characters"))]
|
||||
pub name: String,
|
||||
pub date_start: String,
|
||||
pub date_expiration: String,
|
||||
pub logo: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct UpdateLadderRequest {
|
||||
#[validate(length(min = 2, max = 255, message = "Ladder name must be 2-255 characters"))]
|
||||
pub name: Option<String>,
|
||||
pub date_start: Option<String>,
|
||||
pub date_expiration: Option<String>,
|
||||
pub status: Option<i32>,
|
||||
pub logo: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LadderResponse {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub logo: Option<String>,
|
||||
pub status: i32,
|
||||
pub date_start: Option<String>,
|
||||
pub date_expiration: Option<String>,
|
||||
pub date_created: chrono::DateTime<chrono::Utc>,
|
||||
pub created_by_id: i32,
|
||||
}
|
||||
|
||||
impl From<Ladder> for LadderResponse {
|
||||
fn from(ladder: Ladder) -> Self {
|
||||
LadderResponse {
|
||||
id: ladder.id,
|
||||
name: ladder.name,
|
||||
logo: ladder.logo,
|
||||
status: ladder.status,
|
||||
date_start: ladder.date_start,
|
||||
date_expiration: ladder.date_expiration,
|
||||
date_created: ladder.date_created,
|
||||
created_by_id: ladder.created_by_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LadderDetailResponse {
|
||||
pub ladder: LadderResponse,
|
||||
pub teams: Vec<LadderTeamResponse>,
|
||||
pub is_enrolled: bool,
|
||||
pub user_team_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct LadderTeamResponse {
|
||||
pub id: i32,
|
||||
pub team_id: i32,
|
||||
pub team_name: String,
|
||||
pub team_logo: String,
|
||||
pub team_rank: i32,
|
||||
pub team_mmr: f32,
|
||||
pub date_enrolled: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EnrollmentResponse {
|
||||
pub message: String,
|
||||
pub ladder_team_id: i32,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Handlers
|
||||
// ============================================================================
|
||||
|
||||
/// GET /api/ladders
|
||||
/// List all ladders with pagination and search
|
||||
pub async fn list_ladders(
|
||||
Query(query): Query<ListLaddersQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<LadderListResponse>> {
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
|
||||
let offset = (page - 1) * per_page;
|
||||
|
||||
let search_pattern = query.search.as_ref().map(|s| format!("%{}%", s));
|
||||
|
||||
// Build dynamic query based on filters
|
||||
let (total, ladders): (i64, Vec<LadderWithTeamCount>) = match (&search_pattern, query.status) {
|
||||
(Some(pattern), Some(status)) => {
|
||||
let total = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ladders WHERE name ILIKE $1 AND status = $2"
|
||||
)
|
||||
.bind(pattern)
|
||||
.bind(status)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let ladders = sqlx::query_as(
|
||||
r#"
|
||||
SELECT l.id, l.name, l.logo, l.status, l.date_start, l.date_expiration,
|
||||
l.date_created, l.created_by_id,
|
||||
COUNT(lt.id) as team_count
|
||||
FROM ladders l
|
||||
LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id
|
||||
WHERE l.name ILIKE $1 AND l.status = $2
|
||||
GROUP BY l.id
|
||||
ORDER BY l.date_created DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
"#
|
||||
)
|
||||
.bind(pattern)
|
||||
.bind(status)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
(total, ladders)
|
||||
}
|
||||
(Some(pattern), None) => {
|
||||
let total = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ladders WHERE name ILIKE $1"
|
||||
)
|
||||
.bind(pattern)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let ladders = sqlx::query_as(
|
||||
r#"
|
||||
SELECT l.id, l.name, l.logo, l.status, l.date_start, l.date_expiration,
|
||||
l.date_created, l.created_by_id,
|
||||
COUNT(lt.id) as team_count
|
||||
FROM ladders l
|
||||
LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id
|
||||
WHERE l.name ILIKE $1
|
||||
GROUP BY l.id
|
||||
ORDER BY l.date_created DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
"#
|
||||
)
|
||||
.bind(pattern)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
(total, ladders)
|
||||
}
|
||||
(None, Some(status)) => {
|
||||
let total = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ladders WHERE status = $1"
|
||||
)
|
||||
.bind(status)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let ladders = sqlx::query_as(
|
||||
r#"
|
||||
SELECT l.id, l.name, l.logo, l.status, l.date_start, l.date_expiration,
|
||||
l.date_created, l.created_by_id,
|
||||
COUNT(lt.id) as team_count
|
||||
FROM ladders l
|
||||
LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id
|
||||
WHERE l.status = $1
|
||||
GROUP BY l.id
|
||||
ORDER BY l.date_created DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
"#
|
||||
)
|
||||
.bind(status)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
(total, ladders)
|
||||
}
|
||||
(None, None) => {
|
||||
let total = sqlx::query_scalar("SELECT COUNT(*) FROM ladders")
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let ladders = sqlx::query_as(
|
||||
r#"
|
||||
SELECT l.id, l.name, l.logo, l.status, l.date_start, l.date_expiration,
|
||||
l.date_created, l.created_by_id,
|
||||
COUNT(lt.id) as team_count
|
||||
FROM ladders l
|
||||
LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id
|
||||
GROUP BY l.id
|
||||
ORDER BY l.date_created DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
"#
|
||||
)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
(total, ladders)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(LadderListResponse {
|
||||
ladders,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/ladders
|
||||
/// Create a new ladder (admin only)
|
||||
pub async fn create_ladder(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateLadderRequest>,
|
||||
) -> Result<(StatusCode, Json<LadderResponse>)> {
|
||||
// Only admins can create ladders
|
||||
if !user.is_admin {
|
||||
return Err(AppError::Forbidden("Only admins can create ladders".to_string()));
|
||||
}
|
||||
|
||||
payload.validate()?;
|
||||
|
||||
// Check if ladder name already exists
|
||||
let existing = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM ladders WHERE name = $1"
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(AppError::Conflict("Ladder name already exists".to_string()));
|
||||
}
|
||||
|
||||
let ladder = sqlx::query_as::<_, Ladder>(
|
||||
r#"
|
||||
INSERT INTO ladders (name, date_start, date_expiration, logo, created_by_id, status)
|
||||
VALUES ($1, $2, $3, $4, $5, 0)
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.bind(&payload.date_start)
|
||||
.bind(&payload.date_expiration)
|
||||
.bind(&payload.logo)
|
||||
.bind(user.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(ladder.into())))
|
||||
}
|
||||
|
||||
/// GET /api/ladders/:id
|
||||
/// Get ladder details with enrolled teams
|
||||
pub async fn get_ladder(
|
||||
OptionalAuthUser(user): OptionalAuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(ladder_id): Path<i32>,
|
||||
) -> Result<Json<LadderDetailResponse>> {
|
||||
let ladder = sqlx::query_as::<_, Ladder>(
|
||||
"SELECT * FROM ladders WHERE id = $1"
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?;
|
||||
|
||||
// Get enrolled teams
|
||||
let teams = sqlx::query_as::<_, LadderTeamResponse>(
|
||||
r#"
|
||||
SELECT lt.id, lt.team_id, t.name as team_name, t.logo as team_logo,
|
||||
t.rank as team_rank, t.mmr as team_mmr, lt.date_created as date_enrolled
|
||||
FROM ladder_teams lt
|
||||
JOIN teams t ON lt.team_id = t.id
|
||||
WHERE lt.ladder_id = $1 AND t.is_delete = false
|
||||
ORDER BY t.rank ASC, t.mmr DESC
|
||||
"#
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Check if user's team is enrolled
|
||||
let (is_enrolled, user_team_id) = if let Some(ref u) = user {
|
||||
// Get user's team
|
||||
let user_team = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT team_id FROM team_players WHERE player_id = $1 AND status = 1"
|
||||
)
|
||||
.bind(u.id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if let Some(team_id) = user_team {
|
||||
let enrolled = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM ladder_teams WHERE ladder_id = $1 AND team_id = $2"
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.bind(team_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
(enrolled.is_some(), Some(team_id))
|
||||
} else {
|
||||
(false, None)
|
||||
}
|
||||
} else {
|
||||
(false, None)
|
||||
};
|
||||
|
||||
Ok(Json(LadderDetailResponse {
|
||||
ladder: ladder.into(),
|
||||
teams,
|
||||
is_enrolled,
|
||||
user_team_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// PUT /api/ladders/:id
|
||||
/// Update ladder (admin only)
|
||||
pub async fn update_ladder(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(ladder_id): Path<i32>,
|
||||
Json(payload): Json<UpdateLadderRequest>,
|
||||
) -> Result<Json<LadderResponse>> {
|
||||
// Only admins can update ladders
|
||||
if !user.is_admin {
|
||||
return Err(AppError::Forbidden("Only admins can update ladders".to_string()));
|
||||
}
|
||||
|
||||
payload.validate()?;
|
||||
|
||||
// Check if new name conflicts
|
||||
if let Some(ref name) = payload.name {
|
||||
let existing = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM ladders WHERE name = $1 AND id != $2"
|
||||
)
|
||||
.bind(name)
|
||||
.bind(ladder_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(AppError::Conflict("Ladder name already exists".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let ladder = sqlx::query_as::<_, Ladder>(
|
||||
r#"
|
||||
UPDATE ladders
|
||||
SET name = COALESCE($1, name),
|
||||
date_start = COALESCE($2, date_start),
|
||||
date_expiration = COALESCE($3, date_expiration),
|
||||
status = COALESCE($4, status),
|
||||
logo = COALESCE($5, logo)
|
||||
WHERE id = $6
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.bind(&payload.date_start)
|
||||
.bind(&payload.date_expiration)
|
||||
.bind(payload.status)
|
||||
.bind(&payload.logo)
|
||||
.bind(ladder_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?;
|
||||
|
||||
Ok(Json(ladder.into()))
|
||||
}
|
||||
|
||||
/// DELETE /api/ladders/:id
|
||||
/// Delete ladder (admin only)
|
||||
pub async fn delete_ladder(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(ladder_id): Path<i32>,
|
||||
) -> Result<StatusCode> {
|
||||
// Only admins can delete ladders
|
||||
if !user.is_admin {
|
||||
return Err(AppError::Forbidden("Only admins can delete ladders".to_string()));
|
||||
}
|
||||
|
||||
// Delete ladder (cascade will remove ladder_teams entries)
|
||||
let result = sqlx::query("DELETE FROM ladders WHERE id = $1")
|
||||
.bind(ladder_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Ladder not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// POST /api/ladders/:id/enroll
|
||||
/// Enroll user's team in a ladder (captain only)
|
||||
pub async fn enroll_team(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(ladder_id): Path<i32>,
|
||||
) -> Result<(StatusCode, Json<EnrollmentResponse>)> {
|
||||
// Check ladder exists and is open
|
||||
let ladder = sqlx::query_as::<_, Ladder>(
|
||||
"SELECT * FROM ladders WHERE id = $1"
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?;
|
||||
|
||||
if ladder.status != 0 {
|
||||
return Err(AppError::BadRequest("Ladder is not open for enrollment".to_string()));
|
||||
}
|
||||
|
||||
// Get user's team (must be captain)
|
||||
let team_id = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT team_id FROM team_players WHERE player_id = $1 AND position = 'captain' AND status = 1"
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Forbidden("You must be a team captain to enroll".to_string()))?;
|
||||
|
||||
// Check if already enrolled
|
||||
let existing = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM ladder_teams WHERE ladder_id = $1 AND team_id = $2"
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.bind(team_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(AppError::Conflict("Team is already enrolled in this ladder".to_string()));
|
||||
}
|
||||
|
||||
// Enroll team
|
||||
let ladder_team = sqlx::query_as::<_, LadderTeam>(
|
||||
r#"
|
||||
INSERT INTO ladder_teams (ladder_id, team_id)
|
||||
VALUES ($1, $2)
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.bind(team_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(EnrollmentResponse {
|
||||
message: "Successfully enrolled in ladder".to_string(),
|
||||
ladder_team_id: ladder_team.id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// DELETE /api/ladders/:id/enroll
|
||||
/// Withdraw team from a ladder (captain only)
|
||||
pub async fn withdraw_team(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(ladder_id): Path<i32>,
|
||||
) -> Result<StatusCode> {
|
||||
// Get user's team (must be captain)
|
||||
let team_id = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT team_id FROM team_players WHERE player_id = $1 AND position = 'captain' AND status = 1"
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Forbidden("You must be a team captain to withdraw".to_string()))?;
|
||||
|
||||
// Check ladder status - can't withdraw from closed ladders
|
||||
let ladder = sqlx::query_as::<_, Ladder>(
|
||||
"SELECT * FROM ladders WHERE id = $1"
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?;
|
||||
|
||||
if ladder.status != 0 {
|
||||
return Err(AppError::BadRequest("Cannot withdraw from a closed ladder".to_string()));
|
||||
}
|
||||
|
||||
// Remove enrollment
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM ladder_teams WHERE ladder_id = $1 AND team_id = $2"
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.bind(team_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Team is not enrolled in this ladder".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// GET /api/ladders/:id/standings
|
||||
/// Get ladder standings (teams ranked by MMR)
|
||||
pub async fn get_ladder_standings(
|
||||
State(state): State<AppState>,
|
||||
Path(ladder_id): Path<i32>,
|
||||
) -> Result<Json<Vec<LadderTeamResponse>>> {
|
||||
// Check ladder exists
|
||||
let exists = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM ladders WHERE id = $1"
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if exists.is_none() {
|
||||
return Err(AppError::NotFound("Ladder not found".to_string()));
|
||||
}
|
||||
|
||||
// Get standings
|
||||
let standings = sqlx::query_as::<_, LadderTeamResponse>(
|
||||
r#"
|
||||
SELECT lt.id, lt.team_id, t.name as team_name, t.logo as team_logo,
|
||||
t.rank as team_rank, t.mmr as team_mmr, lt.date_created as date_enrolled
|
||||
FROM ladder_teams lt
|
||||
JOIN teams t ON lt.team_id = t.id
|
||||
WHERE lt.ladder_id = $1 AND t.is_delete = false
|
||||
ORDER BY t.mmr DESC, t.rank ASC
|
||||
"#
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(standings))
|
||||
}
|
||||
891
src/handlers/matches.rs
Normal file
891
src/handlers/matches.rs
Normal file
@@ -0,0 +1,891 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, Result},
|
||||
models::match_model::Match,
|
||||
models::match_round::MatchRound,
|
||||
};
|
||||
|
||||
use super::auth::AppState;
|
||||
|
||||
// ============================================================================
|
||||
// Constants for Match Status
|
||||
// ============================================================================
|
||||
|
||||
/// Team status in a match
|
||||
pub mod team_status {
|
||||
pub const CHALLENGING: i32 = 0;
|
||||
pub const PENDING_RESPONSE: i32 = 1;
|
||||
pub const CHALLENGED: i32 = 2;
|
||||
pub const REJECTED: i32 = 3;
|
||||
pub const ACCEPTED: i32 = 4;
|
||||
}
|
||||
|
||||
/// Match overall status
|
||||
pub mod match_status {
|
||||
pub const PENDING: i32 = 0;
|
||||
pub const SCHEDULED: i32 = 1;
|
||||
pub const IN_PROGRESS: i32 = 2;
|
||||
pub const DONE: i32 = 3;
|
||||
pub const CANCELLED: i32 = 4;
|
||||
}
|
||||
|
||||
/// Score acceptance status
|
||||
pub mod score_status {
|
||||
pub const PENDING: i32 = 0;
|
||||
pub const ACCEPTED: i32 = 1;
|
||||
pub const REJECTED: i32 = 2;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Request/Response Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ListMatchesQuery {
|
||||
pub team_id: Option<i32>,
|
||||
pub ladder_id: Option<i32>,
|
||||
pub status: Option<i32>,
|
||||
pub page: Option<i64>,
|
||||
pub per_page: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MatchListResponse {
|
||||
pub matches: Vec<MatchWithTeams>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
pub per_page: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct MatchWithTeams {
|
||||
pub id: i32,
|
||||
pub date_created: chrono::DateTime<chrono::Utc>,
|
||||
pub date_start: chrono::DateTime<chrono::Utc>,
|
||||
pub challenge_date: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub team_id_1: i32,
|
||||
pub team_1_name: String,
|
||||
pub team_1_logo: String,
|
||||
pub team_1_status: i32,
|
||||
pub team_id_2: i32,
|
||||
pub team_2_name: String,
|
||||
pub team_2_logo: String,
|
||||
pub team_2_status: i32,
|
||||
pub matche_status: i32,
|
||||
pub created_by_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct CreateChallengeRequest {
|
||||
pub opponent_team_id: i32,
|
||||
#[validate(length(min = 1, message = "Challenge date is required"))]
|
||||
pub challenge_date: String, // ISO 8601 datetime
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MatchResponse {
|
||||
pub id: i32,
|
||||
pub date_created: chrono::DateTime<chrono::Utc>,
|
||||
pub date_start: chrono::DateTime<chrono::Utc>,
|
||||
pub challenge_date: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub team_id_1: i32,
|
||||
pub team_id_2: i32,
|
||||
pub team_1_status: i32,
|
||||
pub team_2_status: i32,
|
||||
pub matche_status: i32,
|
||||
pub created_by_id: i32,
|
||||
}
|
||||
|
||||
impl From<Match> for MatchResponse {
|
||||
fn from(m: Match) -> Self {
|
||||
MatchResponse {
|
||||
id: m.id,
|
||||
date_created: m.date_created,
|
||||
date_start: m.date_start,
|
||||
challenge_date: m.challenge_date,
|
||||
team_id_1: m.team_id_1,
|
||||
team_id_2: m.team_id_2,
|
||||
team_1_status: m.team_1_status,
|
||||
team_2_status: m.team_2_status,
|
||||
matche_status: m.matche_status,
|
||||
created_by_id: m.created_by_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MatchDetailResponse {
|
||||
pub match_info: MatchWithTeams,
|
||||
pub rounds: Vec<MatchRoundResponse>,
|
||||
pub user_team_id: Option<i32>,
|
||||
pub can_accept: bool,
|
||||
pub can_report_score: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct MatchRoundResponse {
|
||||
pub id: i32,
|
||||
pub date_created: chrono::DateTime<chrono::Utc>,
|
||||
pub match_id: i32,
|
||||
pub team_1_score: i32,
|
||||
pub team_2_score: i32,
|
||||
pub score_posted_by_team_id: i32,
|
||||
pub score_acceptance_status: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct ReportScoreRequest {
|
||||
#[validate(range(min = 0, message = "Score must be non-negative"))]
|
||||
pub team_1_score: i32,
|
||||
#[validate(range(min = 0, message = "Score must be non-negative"))]
|
||||
pub team_2_score: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RescheduleRequest {
|
||||
pub challenge_date: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Handlers
|
||||
// ============================================================================
|
||||
|
||||
/// GET /api/matches
|
||||
/// List matches with filters
|
||||
pub async fn list_matches(
|
||||
Query(query): Query<ListMatchesQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<MatchListResponse>> {
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
|
||||
let offset = (page - 1) * per_page;
|
||||
|
||||
// Build query based on filters
|
||||
let base_query = r#"
|
||||
SELECT m.id, m.date_created, m.date_start, m.challenge_date,
|
||||
m.team_id_1, t1.name as team_1_name, t1.logo as team_1_logo, m.team_1_status,
|
||||
m.team_id_2, t2.name as team_2_name, t2.logo as team_2_logo, m.team_2_status,
|
||||
m.matche_status, m.created_by_id
|
||||
FROM matches m
|
||||
JOIN teams t1 ON m.team_id_1 = t1.id
|
||||
JOIN teams t2 ON m.team_id_2 = t2.id
|
||||
"#;
|
||||
|
||||
let count_base = "SELECT COUNT(*) FROM matches m";
|
||||
|
||||
let (total, matches): (i64, Vec<MatchWithTeams>) = match (query.team_id, query.status) {
|
||||
(Some(team_id), Some(status)) => {
|
||||
let total = sqlx::query_scalar(&format!(
|
||||
"{} WHERE (m.team_id_1 = $1 OR m.team_id_2 = $1) AND m.matche_status = $2",
|
||||
count_base
|
||||
))
|
||||
.bind(team_id)
|
||||
.bind(status)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let matches = sqlx::query_as(&format!(
|
||||
"{} WHERE (m.team_id_1 = $1 OR m.team_id_2 = $1) AND m.matche_status = $2
|
||||
ORDER BY m.date_created DESC LIMIT $3 OFFSET $4",
|
||||
base_query
|
||||
))
|
||||
.bind(team_id)
|
||||
.bind(status)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
(total, matches)
|
||||
}
|
||||
(Some(team_id), None) => {
|
||||
let total = sqlx::query_scalar(&format!(
|
||||
"{} WHERE m.team_id_1 = $1 OR m.team_id_2 = $1",
|
||||
count_base
|
||||
))
|
||||
.bind(team_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let matches = sqlx::query_as(&format!(
|
||||
"{} WHERE m.team_id_1 = $1 OR m.team_id_2 = $1
|
||||
ORDER BY m.date_created DESC LIMIT $2 OFFSET $3",
|
||||
base_query
|
||||
))
|
||||
.bind(team_id)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
(total, matches)
|
||||
}
|
||||
(None, Some(status)) => {
|
||||
let total = sqlx::query_scalar(&format!(
|
||||
"{} WHERE m.matche_status = $1",
|
||||
count_base
|
||||
))
|
||||
.bind(status)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let matches = sqlx::query_as(&format!(
|
||||
"{} WHERE m.matche_status = $1 ORDER BY m.date_created DESC LIMIT $2 OFFSET $3",
|
||||
base_query
|
||||
))
|
||||
.bind(status)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
(total, matches)
|
||||
}
|
||||
(None, None) => {
|
||||
let total = sqlx::query_scalar(count_base)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let matches = sqlx::query_as(&format!(
|
||||
"{} ORDER BY m.date_created DESC LIMIT $1 OFFSET $2",
|
||||
base_query
|
||||
))
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
(total, matches)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(MatchListResponse {
|
||||
matches,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/matches/:id
|
||||
/// Get match details with rounds
|
||||
pub async fn get_match(
|
||||
user: Option<AuthUser>,
|
||||
State(state): State<AppState>,
|
||||
Path(match_id): Path<i32>,
|
||||
) -> Result<Json<MatchDetailResponse>> {
|
||||
// Get match with team info
|
||||
let match_info = sqlx::query_as::<_, MatchWithTeams>(
|
||||
r#"
|
||||
SELECT m.id, m.date_created, m.date_start, m.challenge_date,
|
||||
m.team_id_1, t1.name as team_1_name, t1.logo as team_1_logo, m.team_1_status,
|
||||
m.team_id_2, t2.name as team_2_name, t2.logo as team_2_logo, m.team_2_status,
|
||||
m.matche_status, m.created_by_id
|
||||
FROM matches m
|
||||
JOIN teams t1 ON m.team_id_1 = t1.id
|
||||
JOIN teams t2 ON m.team_id_2 = t2.id
|
||||
WHERE m.id = $1
|
||||
"#
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
|
||||
// Get rounds
|
||||
let rounds = sqlx::query_as::<_, MatchRoundResponse>(
|
||||
"SELECT * FROM match_rounds WHERE match_id = $1 ORDER BY date_created ASC"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Check user's team and permissions
|
||||
let (user_team_id, can_accept, can_report_score) = if let Some(ref u) = user {
|
||||
let team_id = get_user_team_id(&state.pool, u.id).await?;
|
||||
|
||||
if let Some(tid) = team_id {
|
||||
let is_team_1 = tid == match_info.team_id_1;
|
||||
let is_team_2 = tid == match_info.team_id_2;
|
||||
|
||||
// Can accept if you're team 2 and status is pending
|
||||
let can_accept = is_team_2
|
||||
&& match_info.team_2_status == team_status::PENDING_RESPONSE
|
||||
&& match_info.matche_status == match_status::PENDING;
|
||||
|
||||
// Can report score if match is in progress and you're part of it
|
||||
let can_report = (is_team_1 || is_team_2)
|
||||
&& match_info.matche_status == match_status::IN_PROGRESS;
|
||||
|
||||
(Some(tid), can_accept, can_report)
|
||||
} else {
|
||||
(None, false, false)
|
||||
}
|
||||
} else {
|
||||
(None, false, false)
|
||||
};
|
||||
|
||||
Ok(Json(MatchDetailResponse {
|
||||
match_info,
|
||||
rounds,
|
||||
user_team_id,
|
||||
can_accept,
|
||||
can_report_score,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/matches/challenge
|
||||
/// Create a new challenge
|
||||
pub async fn create_challenge(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateChallengeRequest>,
|
||||
) -> Result<(StatusCode, Json<MatchResponse>)> {
|
||||
payload.validate()?;
|
||||
|
||||
// Get user's team (must be captain)
|
||||
let user_team_id = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT team_id FROM team_players WHERE player_id = $1 AND position = 'captain' AND status = 1"
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Forbidden("You must be a team captain to create challenges".to_string()))?;
|
||||
|
||||
// Validate opponent team exists
|
||||
let opponent_exists = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM teams WHERE id = $1 AND is_delete = false"
|
||||
)
|
||||
.bind(payload.opponent_team_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if opponent_exists.is_none() {
|
||||
return Err(AppError::NotFound("Opponent team not found".to_string()));
|
||||
}
|
||||
|
||||
// Can't challenge yourself
|
||||
if user_team_id == payload.opponent_team_id {
|
||||
return Err(AppError::BadRequest("Cannot challenge your own team".to_string()));
|
||||
}
|
||||
|
||||
// Check for existing pending challenge between teams
|
||||
let existing = sqlx::query_scalar::<_, i32>(
|
||||
r#"
|
||||
SELECT id FROM matches
|
||||
WHERE ((team_id_1 = $1 AND team_id_2 = $2) OR (team_id_1 = $2 AND team_id_2 = $1))
|
||||
AND matche_status IN (0, 1, 2)
|
||||
"#
|
||||
)
|
||||
.bind(user_team_id)
|
||||
.bind(payload.opponent_team_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(AppError::Conflict("A pending match already exists between these teams".to_string()));
|
||||
}
|
||||
|
||||
// Parse challenge date
|
||||
let challenge_date = chrono::DateTime::parse_from_rfc3339(&payload.challenge_date)
|
||||
.map_err(|_| AppError::BadRequest("Invalid date format. Use ISO 8601 format.".to_string()))?
|
||||
.with_timezone(&chrono::Utc);
|
||||
|
||||
// Create match
|
||||
let new_match = sqlx::query_as::<_, Match>(
|
||||
r#"
|
||||
INSERT INTO matches (
|
||||
team_id_1, team_id_2, created_by_id, challenge_date,
|
||||
team_1_status, team_2_status, matche_status
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(user_team_id)
|
||||
.bind(payload.opponent_team_id)
|
||||
.bind(user.id)
|
||||
.bind(challenge_date)
|
||||
.bind(team_status::CHALLENGING)
|
||||
.bind(team_status::PENDING_RESPONSE)
|
||||
.bind(match_status::PENDING)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(new_match.into())))
|
||||
}
|
||||
|
||||
/// POST /api/matches/:id/accept
|
||||
/// Accept a challenge
|
||||
pub async fn accept_challenge(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(match_id): Path<i32>,
|
||||
) -> Result<Json<MatchResponse>> {
|
||||
let user_team_id = get_user_captain_team(&state.pool, user.id).await?;
|
||||
|
||||
// Get match
|
||||
let current_match = sqlx::query_as::<_, Match>(
|
||||
"SELECT * FROM matches WHERE id = $1"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
|
||||
// Verify user is captain of team 2
|
||||
if current_match.team_id_2 != user_team_id {
|
||||
return Err(AppError::Forbidden("Only the challenged team captain can accept".to_string()));
|
||||
}
|
||||
|
||||
// Verify match is pending
|
||||
if current_match.matche_status != match_status::PENDING {
|
||||
return Err(AppError::BadRequest("Match is not pending acceptance".to_string()));
|
||||
}
|
||||
|
||||
// Update match
|
||||
let updated = sqlx::query_as::<_, Match>(
|
||||
r#"
|
||||
UPDATE matches
|
||||
SET team_1_status = $1, team_2_status = $2, matche_status = $3,
|
||||
date_start = challenge_date
|
||||
WHERE id = $4
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(team_status::ACCEPTED)
|
||||
.bind(team_status::ACCEPTED)
|
||||
.bind(match_status::SCHEDULED)
|
||||
.bind(match_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(updated.into()))
|
||||
}
|
||||
|
||||
/// POST /api/matches/:id/reject
|
||||
/// Reject a challenge
|
||||
pub async fn reject_challenge(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(match_id): Path<i32>,
|
||||
) -> Result<Json<MatchResponse>> {
|
||||
let user_team_id = get_user_captain_team(&state.pool, user.id).await?;
|
||||
|
||||
let current_match = sqlx::query_as::<_, Match>(
|
||||
"SELECT * FROM matches WHERE id = $1"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
|
||||
// Verify user is captain of team 2
|
||||
if current_match.team_id_2 != user_team_id {
|
||||
return Err(AppError::Forbidden("Only the challenged team captain can reject".to_string()));
|
||||
}
|
||||
|
||||
if current_match.matche_status != match_status::PENDING {
|
||||
return Err(AppError::BadRequest("Match is not pending".to_string()));
|
||||
}
|
||||
|
||||
let updated = sqlx::query_as::<_, Match>(
|
||||
r#"
|
||||
UPDATE matches
|
||||
SET team_1_status = $1, team_2_status = $2, matche_status = $3
|
||||
WHERE id = $4
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(team_status::REJECTED)
|
||||
.bind(team_status::REJECTED)
|
||||
.bind(match_status::CANCELLED)
|
||||
.bind(match_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(updated.into()))
|
||||
}
|
||||
|
||||
/// POST /api/matches/:id/start
|
||||
/// Start a scheduled match
|
||||
pub async fn start_match(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(match_id): Path<i32>,
|
||||
) -> Result<Json<MatchResponse>> {
|
||||
let user_team_id = get_user_captain_team(&state.pool, user.id).await?;
|
||||
|
||||
let current_match = sqlx::query_as::<_, Match>(
|
||||
"SELECT * FROM matches WHERE id = $1"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
|
||||
// Verify user is part of the match
|
||||
if current_match.team_id_1 != user_team_id && current_match.team_id_2 != user_team_id {
|
||||
return Err(AppError::Forbidden("You are not part of this match".to_string()));
|
||||
}
|
||||
|
||||
if current_match.matche_status != match_status::SCHEDULED {
|
||||
return Err(AppError::BadRequest("Match must be scheduled to start".to_string()));
|
||||
}
|
||||
|
||||
let updated = sqlx::query_as::<_, Match>(
|
||||
"UPDATE matches SET matche_status = $1 WHERE id = $2 RETURNING *"
|
||||
)
|
||||
.bind(match_status::IN_PROGRESS)
|
||||
.bind(match_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(updated.into()))
|
||||
}
|
||||
|
||||
/// POST /api/matches/:id/cancel
|
||||
/// Cancel a match (before it starts)
|
||||
pub async fn cancel_match(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(match_id): Path<i32>,
|
||||
) -> Result<Json<MatchResponse>> {
|
||||
let user_team_id = get_user_captain_team(&state.pool, user.id).await?;
|
||||
|
||||
let current_match = sqlx::query_as::<_, Match>(
|
||||
"SELECT * FROM matches WHERE id = $1"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
|
||||
// Verify user is part of the match
|
||||
if current_match.team_id_1 != user_team_id && current_match.team_id_2 != user_team_id {
|
||||
return Err(AppError::Forbidden("You are not part of this match".to_string()));
|
||||
}
|
||||
|
||||
// Can only cancel pending or scheduled matches
|
||||
if current_match.matche_status > match_status::SCHEDULED {
|
||||
return Err(AppError::BadRequest("Cannot cancel a match that has started or ended".to_string()));
|
||||
}
|
||||
|
||||
let updated = sqlx::query_as::<_, Match>(
|
||||
"UPDATE matches SET matche_status = $1 WHERE id = $2 RETURNING *"
|
||||
)
|
||||
.bind(match_status::CANCELLED)
|
||||
.bind(match_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(updated.into()))
|
||||
}
|
||||
|
||||
/// POST /api/matches/:id/score
|
||||
/// Report a score for a match round
|
||||
pub async fn report_score(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(match_id): Path<i32>,
|
||||
Json(payload): Json<ReportScoreRequest>,
|
||||
) -> Result<(StatusCode, Json<MatchRoundResponse>)> {
|
||||
payload.validate()?;
|
||||
|
||||
let user_team_id = get_user_captain_team(&state.pool, user.id).await?;
|
||||
|
||||
let current_match = sqlx::query_as::<_, Match>(
|
||||
"SELECT * FROM matches WHERE id = $1"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
|
||||
// Verify user is part of the match
|
||||
if current_match.team_id_1 != user_team_id && current_match.team_id_2 != user_team_id {
|
||||
return Err(AppError::Forbidden("You are not part of this match".to_string()));
|
||||
}
|
||||
|
||||
// Match must be in progress
|
||||
if current_match.matche_status != match_status::IN_PROGRESS {
|
||||
return Err(AppError::BadRequest("Match must be in progress to report scores".to_string()));
|
||||
}
|
||||
|
||||
// Check if there's already a pending score report
|
||||
let pending_round = sqlx::query_as::<_, MatchRound>(
|
||||
"SELECT * FROM match_rounds WHERE match_id = $1 AND score_acceptance_status = $2"
|
||||
)
|
||||
.bind(match_id)
|
||||
.bind(score_status::PENDING)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if pending_round.is_some() {
|
||||
return Err(AppError::Conflict("There is already a pending score report".to_string()));
|
||||
}
|
||||
|
||||
// Create round with score
|
||||
let round = sqlx::query_as::<_, MatchRound>(
|
||||
r#"
|
||||
INSERT INTO match_rounds (match_id, team_1_score, team_2_score, score_posted_by_team_id, score_acceptance_status)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(match_id)
|
||||
.bind(payload.team_1_score)
|
||||
.bind(payload.team_2_score)
|
||||
.bind(user_team_id)
|
||||
.bind(score_status::PENDING)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(MatchRoundResponse {
|
||||
id: round.id,
|
||||
date_created: round.date_created,
|
||||
match_id: round.match_id,
|
||||
team_1_score: round.team_1_score,
|
||||
team_2_score: round.team_2_score,
|
||||
score_posted_by_team_id: round.score_posted_by_team_id,
|
||||
score_acceptance_status: round.score_acceptance_status,
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/matches/:id/score/:round_id/accept
|
||||
/// Accept a reported score
|
||||
pub async fn accept_score(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path((match_id, round_id)): Path<(i32, i32)>,
|
||||
) -> Result<Json<MatchRoundResponse>> {
|
||||
let user_team_id = get_user_captain_team(&state.pool, user.id).await?;
|
||||
|
||||
let current_match = sqlx::query_as::<_, Match>(
|
||||
"SELECT * FROM matches WHERE id = $1"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
|
||||
let round = sqlx::query_as::<_, MatchRound>(
|
||||
"SELECT * FROM match_rounds WHERE id = $1 AND match_id = $2"
|
||||
)
|
||||
.bind(round_id)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Round not found".to_string()))?;
|
||||
|
||||
// Must be the other team to accept
|
||||
if round.score_posted_by_team_id == user_team_id {
|
||||
return Err(AppError::BadRequest("Cannot accept your own score report".to_string()));
|
||||
}
|
||||
|
||||
// Must be part of the match
|
||||
if current_match.team_id_1 != user_team_id && current_match.team_id_2 != user_team_id {
|
||||
return Err(AppError::Forbidden("You are not part of this match".to_string()));
|
||||
}
|
||||
|
||||
if round.score_acceptance_status != score_status::PENDING {
|
||||
return Err(AppError::BadRequest("Score has already been processed".to_string()));
|
||||
}
|
||||
|
||||
// Update round
|
||||
let updated_round = sqlx::query_as::<_, MatchRound>(
|
||||
"UPDATE match_rounds SET score_acceptance_status = $1 WHERE id = $2 RETURNING *"
|
||||
)
|
||||
.bind(score_status::ACCEPTED)
|
||||
.bind(round_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Update match status to done
|
||||
sqlx::query("UPDATE matches SET matche_status = $1 WHERE id = $2")
|
||||
.bind(match_status::DONE)
|
||||
.bind(match_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Update team and player ratings using OpenSkill
|
||||
if updated_round.team_1_score != updated_round.team_2_score {
|
||||
// Only update ratings if there's a clear winner (not a draw)
|
||||
let rating_result = crate::services::rating::process_match_result(
|
||||
&state.pool,
|
||||
current_match.team_id_1,
|
||||
updated_round.team_1_score,
|
||||
current_match.team_id_2,
|
||||
updated_round.team_2_score,
|
||||
).await;
|
||||
|
||||
match rating_result {
|
||||
Ok((winner_update, loser_update)) => {
|
||||
tracing::info!(
|
||||
"Match {} ratings updated: Winner {} ({:+.2} ordinal), Loser {} ({:+.2} ordinal)",
|
||||
match_id,
|
||||
winner_update.entity_id, winner_update.ordinal_change(),
|
||||
loser_update.entity_id, loser_update.ordinal_change()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to update ratings for match {}: {}", match_id, e);
|
||||
// Don't fail the request, ratings are secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(MatchRoundResponse {
|
||||
id: updated_round.id,
|
||||
date_created: updated_round.date_created,
|
||||
match_id: updated_round.match_id,
|
||||
team_1_score: updated_round.team_1_score,
|
||||
team_2_score: updated_round.team_2_score,
|
||||
score_posted_by_team_id: updated_round.score_posted_by_team_id,
|
||||
score_acceptance_status: updated_round.score_acceptance_status,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/matches/:id/score/:round_id/reject
|
||||
/// Reject a reported score
|
||||
pub async fn reject_score(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path((match_id, round_id)): Path<(i32, i32)>,
|
||||
) -> Result<Json<MatchRoundResponse>> {
|
||||
let user_team_id = get_user_captain_team(&state.pool, user.id).await?;
|
||||
|
||||
let current_match = sqlx::query_as::<_, Match>(
|
||||
"SELECT * FROM matches WHERE id = $1"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
|
||||
let round = sqlx::query_as::<_, MatchRound>(
|
||||
"SELECT * FROM match_rounds WHERE id = $1 AND match_id = $2"
|
||||
)
|
||||
.bind(round_id)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Round not found".to_string()))?;
|
||||
|
||||
// Must be the other team to reject
|
||||
if round.score_posted_by_team_id == user_team_id {
|
||||
return Err(AppError::BadRequest("Cannot reject your own score report".to_string()));
|
||||
}
|
||||
|
||||
// Must be part of the match
|
||||
if current_match.team_id_1 != user_team_id && current_match.team_id_2 != user_team_id {
|
||||
return Err(AppError::Forbidden("You are not part of this match".to_string()));
|
||||
}
|
||||
|
||||
if round.score_acceptance_status != score_status::PENDING {
|
||||
return Err(AppError::BadRequest("Score has already been processed".to_string()));
|
||||
}
|
||||
|
||||
let updated_round = sqlx::query_as::<_, MatchRound>(
|
||||
"UPDATE match_rounds SET score_acceptance_status = $1 WHERE id = $2 RETURNING *"
|
||||
)
|
||||
.bind(score_status::REJECTED)
|
||||
.bind(round_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(MatchRoundResponse {
|
||||
id: updated_round.id,
|
||||
date_created: updated_round.date_created,
|
||||
match_id: updated_round.match_id,
|
||||
team_1_score: updated_round.team_1_score,
|
||||
team_2_score: updated_round.team_2_score,
|
||||
score_posted_by_team_id: updated_round.score_posted_by_team_id,
|
||||
score_acceptance_status: updated_round.score_acceptance_status,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/my-matches
|
||||
/// Get current user's team matches
|
||||
pub async fn get_my_matches(
|
||||
user: AuthUser,
|
||||
Query(query): Query<ListMatchesQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<MatchListResponse>> {
|
||||
let team_id = get_user_team_id(&state.pool, user.id).await?
|
||||
.ok_or_else(|| AppError::NotFound("You are not in a team".to_string()))?;
|
||||
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
|
||||
let offset = (page - 1) * per_page;
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM matches WHERE team_id_1 = $1 OR team_id_2 = $1"
|
||||
)
|
||||
.bind(team_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let matches = sqlx::query_as::<_, MatchWithTeams>(
|
||||
r#"
|
||||
SELECT m.id, m.date_created, m.date_start, m.challenge_date,
|
||||
m.team_id_1, t1.name as team_1_name, t1.logo as team_1_logo, m.team_1_status,
|
||||
m.team_id_2, t2.name as team_2_name, t2.logo as team_2_logo, m.team_2_status,
|
||||
m.matche_status, m.created_by_id
|
||||
FROM matches m
|
||||
JOIN teams t1 ON m.team_id_1 = t1.id
|
||||
JOIN teams t2 ON m.team_id_2 = t2.id
|
||||
WHERE m.team_id_1 = $1 OR m.team_id_2 = $1
|
||||
ORDER BY m.date_created DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
"#
|
||||
)
|
||||
.bind(team_id)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(MatchListResponse {
|
||||
matches,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
async fn get_user_team_id(pool: &sqlx::PgPool, user_id: i32) -> Result<Option<i32>> {
|
||||
let team_id = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT team_id FROM team_players WHERE player_id = $1 AND status = 1"
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(team_id)
|
||||
}
|
||||
|
||||
async fn get_user_captain_team(pool: &sqlx::PgPool, user_id: i32) -> Result<i32> {
|
||||
let team_id = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT team_id FROM team_players WHERE player_id = $1 AND position = 'captain' AND status = 1"
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Forbidden("You must be a team captain".to_string()))?;
|
||||
|
||||
Ok(team_id)
|
||||
}
|
||||
30
src/handlers/mod.rs
Normal file
30
src/handlers/mod.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
pub mod auth;
|
||||
pub mod health;
|
||||
pub mod ladders;
|
||||
pub mod matches;
|
||||
pub mod players;
|
||||
pub mod teams;
|
||||
pub mod uploads;
|
||||
pub mod users;
|
||||
|
||||
pub use health::health_check;
|
||||
pub use teams::{
|
||||
list_teams, create_team, get_team, update_team, delete_team,
|
||||
request_join_team, cancel_join_request, accept_member, reject_member,
|
||||
remove_member, change_member_position, get_my_team,
|
||||
};
|
||||
pub use ladders::{
|
||||
list_ladders, create_ladder, get_ladder, update_ladder, delete_ladder,
|
||||
enroll_team, withdraw_team, get_ladder_standings,
|
||||
};
|
||||
pub use matches::{
|
||||
list_matches, get_match, create_challenge, accept_challenge, reject_challenge,
|
||||
start_match, cancel_match, report_score, accept_score, reject_score, get_my_matches,
|
||||
};
|
||||
pub use players::{
|
||||
list_players, get_player, get_featured_players, set_featured_player,
|
||||
remove_featured_player, get_leaderboard,
|
||||
};
|
||||
pub use uploads::{
|
||||
upload_team_logo, upload_profile_picture, upload_ladder_logo, delete_team_logo,
|
||||
};
|
||||
669
src/handlers/players.rs
Normal file
669
src/handlers/players.rs
Normal file
@@ -0,0 +1,669 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, Result},
|
||||
};
|
||||
|
||||
use super::auth::AppState;
|
||||
|
||||
// ============================================================================
|
||||
// Request/Response Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ListPlayersQuery {
|
||||
pub search: Option<String>,
|
||||
pub page: Option<i64>,
|
||||
pub per_page: Option<i64>,
|
||||
pub sort_by: Option<String>, // "mmr", "username", "date_registered"
|
||||
pub sort_order: Option<String>, // "asc", "desc"
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PlayerListResponse {
|
||||
pub players: Vec<PlayerSummary>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
pub per_page: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct PlayerSummary {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub firstname: String,
|
||||
pub lastname: String,
|
||||
pub profile: Option<String>,
|
||||
pub date_registered: chrono::DateTime<chrono::Utc>,
|
||||
pub team_id: Option<i32>,
|
||||
pub team_name: Option<String>,
|
||||
pub team_position: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PlayerProfileResponse {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub firstname: String,
|
||||
pub lastname: String,
|
||||
pub profile: Option<String>,
|
||||
pub date_registered: chrono::DateTime<chrono::Utc>,
|
||||
pub is_admin: bool,
|
||||
pub mu: f32,
|
||||
pub sigma: f32,
|
||||
pub ordinal: f32,
|
||||
pub mmr: f32,
|
||||
pub team: Option<PlayerTeamInfo>,
|
||||
pub stats: PlayerStats,
|
||||
pub recent_matches: Vec<PlayerMatchSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PlayerTeamInfo {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub logo: String,
|
||||
pub position: String,
|
||||
pub date_joined: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PlayerStats {
|
||||
pub matches_played: i64,
|
||||
pub matches_won: i64,
|
||||
pub matches_lost: i64,
|
||||
pub win_rate: f32,
|
||||
pub ladders_participated: i64,
|
||||
// OpenSkill rating stats
|
||||
pub mu: f64,
|
||||
pub sigma: f64,
|
||||
pub ordinal: f64,
|
||||
pub mmr: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct PlayerMatchSummary {
|
||||
pub match_id: i32,
|
||||
pub date_played: chrono::DateTime<chrono::Utc>,
|
||||
pub opponent_team_name: String,
|
||||
pub own_team_name: String,
|
||||
pub own_score: i32,
|
||||
pub opponent_score: i32,
|
||||
pub result: String, // "win", "loss", "draw"
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FeaturedPlayersResponse {
|
||||
pub players: Vec<FeaturedPlayerInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct FeaturedPlayerInfo {
|
||||
pub id: i32,
|
||||
pub player_id: i32,
|
||||
pub rank: i32,
|
||||
pub username: String,
|
||||
pub firstname: String,
|
||||
pub lastname: String,
|
||||
pub profile: Option<String>,
|
||||
pub team_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetFeaturedPlayerRequest {
|
||||
pub player_id: i32,
|
||||
pub rank: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LeaderboardResponse {
|
||||
pub players: Vec<LeaderboardEntry>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
pub per_page: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct LeaderboardEntry {
|
||||
pub rank: i64,
|
||||
pub player_id: i32,
|
||||
pub username: String,
|
||||
pub firstname: String,
|
||||
pub lastname: String,
|
||||
pub profile: Option<String>,
|
||||
pub team_name: Option<String>,
|
||||
pub matches_won: Option<i64>,
|
||||
pub matches_played: Option<i64>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Handlers
|
||||
// ============================================================================
|
||||
|
||||
/// GET /api/players
|
||||
/// List all players with pagination and search
|
||||
pub async fn list_players(
|
||||
Query(query): Query<ListPlayersQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<PlayerListResponse>> {
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
|
||||
let offset = (page - 1) * per_page;
|
||||
|
||||
let search_pattern = query.search.as_ref().map(|s| format!("%{}%", s));
|
||||
|
||||
// Determine sort order
|
||||
let sort_column = match query.sort_by.as_deref() {
|
||||
Some("username") => "u.username",
|
||||
Some("date_registered") => "u.date_registered",
|
||||
_ => "u.date_registered",
|
||||
};
|
||||
let sort_order = match query.sort_order.as_deref() {
|
||||
Some("asc") => "ASC",
|
||||
_ => "DESC",
|
||||
};
|
||||
|
||||
let (total, players): (i64, Vec<PlayerSummary>) = if let Some(ref pattern) = search_pattern {
|
||||
let total = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM users WHERE username ILIKE $1 OR firstname ILIKE $1 OR lastname ILIKE $1"
|
||||
)
|
||||
.bind(pattern)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let query_str = format!(
|
||||
r#"
|
||||
SELECT u.id, u.username, u.firstname, u.lastname, u.profile, u.date_registered,
|
||||
tp.team_id, t.name as team_name, tp.position as team_position
|
||||
FROM users u
|
||||
LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
|
||||
WHERE u.username ILIKE $1 OR u.firstname ILIKE $1 OR u.lastname ILIKE $1
|
||||
ORDER BY {} {}
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
sort_column, sort_order
|
||||
);
|
||||
|
||||
let players = sqlx::query_as(&query_str)
|
||||
.bind(pattern)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
(total, players)
|
||||
} else {
|
||||
let total = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let query_str = format!(
|
||||
r#"
|
||||
SELECT u.id, u.username, u.firstname, u.lastname, u.profile, u.date_registered,
|
||||
tp.team_id, t.name as team_name, tp.position as team_position
|
||||
FROM users u
|
||||
LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
|
||||
ORDER BY {} {}
|
||||
LIMIT $1 OFFSET $2
|
||||
"#,
|
||||
sort_column, sort_order
|
||||
);
|
||||
|
||||
let players = sqlx::query_as(&query_str)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
(total, players)
|
||||
};
|
||||
|
||||
Ok(Json(PlayerListResponse {
|
||||
players,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/players/:id
|
||||
/// Get player profile with stats
|
||||
pub async fn get_player(
|
||||
State(state): State<AppState>,
|
||||
Path(player_id): Path<i32>,
|
||||
) -> Result<Json<PlayerProfileResponse>> {
|
||||
// Get basic player info including OpenSkill ratings
|
||||
let player = sqlx::query_as::<_, (i32, String, String, String, Option<String>, chrono::DateTime<chrono::Utc>, bool, Option<f32>, Option<f32>, Option<f32>, Option<f32>)>(
|
||||
"SELECT id, username, firstname, lastname, profile, date_registered, is_admin, mu, sigma, ordinal, mmr FROM users WHERE id = $1"
|
||||
)
|
||||
.bind(player_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Player not found".to_string()))?;
|
||||
|
||||
// Get team info
|
||||
let team_info = sqlx::query_as::<_, (i32, String, String, String, chrono::DateTime<chrono::Utc>)>(
|
||||
r#"
|
||||
SELECT t.id, t.name, t.logo, tp.position, tp.date_created
|
||||
FROM team_players tp
|
||||
JOIN teams t ON tp.team_id = t.id
|
||||
WHERE tp.player_id = $1 AND tp.status = 1 AND t.is_delete = false
|
||||
"#
|
||||
)
|
||||
.bind(player_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let team = team_info.map(|(id, name, logo, position, date_joined)| PlayerTeamInfo {
|
||||
id,
|
||||
name,
|
||||
logo,
|
||||
position,
|
||||
date_joined,
|
||||
});
|
||||
|
||||
// Get match stats
|
||||
let stats = get_player_stats(&state.pool, player_id, team.as_ref().map(|t| t.id)).await?;
|
||||
|
||||
// Get recent matches
|
||||
let recent_matches = if let Some(ref t) = team {
|
||||
get_recent_matches(&state.pool, t.id, 5).await?
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
Ok(Json(PlayerProfileResponse {
|
||||
id: player.0,
|
||||
username: player.1,
|
||||
firstname: player.2,
|
||||
lastname: player.3,
|
||||
profile: player.4,
|
||||
date_registered: player.5,
|
||||
is_admin: player.6,
|
||||
mu: player.7.unwrap_or(25.0),
|
||||
sigma: player.8.unwrap_or(8.333),
|
||||
ordinal: player.9.unwrap_or(0.0),
|
||||
mmr: player.10.unwrap_or(1000.0),
|
||||
team,
|
||||
stats,
|
||||
recent_matches,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/players/featured
|
||||
/// Get featured players
|
||||
pub async fn get_featured_players(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<FeaturedPlayersResponse>> {
|
||||
let players = sqlx::query_as::<_, FeaturedPlayerInfo>(
|
||||
r#"
|
||||
SELECT fp.id, fp.player_id, fp.rank, u.username, u.firstname, u.lastname, u.profile, t.name as team_name
|
||||
FROM featured_players fp
|
||||
JOIN users u ON fp.player_id = u.id
|
||||
LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
|
||||
ORDER BY fp.rank ASC
|
||||
"#
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(FeaturedPlayersResponse { players }))
|
||||
}
|
||||
|
||||
/// POST /api/players/featured
|
||||
/// Add/update featured player (admin only)
|
||||
pub async fn set_featured_player(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<SetFeaturedPlayerRequest>,
|
||||
) -> Result<(StatusCode, Json<FeaturedPlayerInfo>)> {
|
||||
if !user.is_admin {
|
||||
return Err(AppError::Forbidden("Only admins can manage featured players".to_string()));
|
||||
}
|
||||
|
||||
// Verify player exists
|
||||
let player_exists = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM users WHERE id = $1"
|
||||
)
|
||||
.bind(payload.player_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if player_exists.is_none() {
|
||||
return Err(AppError::NotFound("Player not found".to_string()));
|
||||
}
|
||||
|
||||
// Upsert featured player
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO featured_players (player_id, rank)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (player_id) DO UPDATE SET rank = $2
|
||||
"#
|
||||
)
|
||||
.bind(payload.player_id)
|
||||
.bind(payload.rank)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Fetch the full featured player info
|
||||
let featured = sqlx::query_as::<_, FeaturedPlayerInfo>(
|
||||
r#"
|
||||
SELECT fp.id, fp.player_id, fp.rank, u.username, u.firstname, u.lastname, u.profile, t.name as team_name
|
||||
FROM featured_players fp
|
||||
JOIN users u ON fp.player_id = u.id
|
||||
LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
|
||||
WHERE fp.player_id = $1
|
||||
"#
|
||||
)
|
||||
.bind(payload.player_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(featured)))
|
||||
}
|
||||
|
||||
/// DELETE /api/players/featured/:player_id
|
||||
/// Remove featured player (admin only)
|
||||
pub async fn remove_featured_player(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(player_id): Path<i32>,
|
||||
) -> Result<StatusCode> {
|
||||
if !user.is_admin {
|
||||
return Err(AppError::Forbidden("Only admins can manage featured players".to_string()));
|
||||
}
|
||||
|
||||
let result = sqlx::query("DELETE FROM featured_players WHERE player_id = $1")
|
||||
.bind(player_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Featured player not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// GET /api/players/leaderboard
|
||||
/// Get player leaderboard (by match wins)
|
||||
pub async fn get_leaderboard(
|
||||
Query(query): Query<ListPlayersQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<LeaderboardResponse>> {
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
|
||||
let offset = (page - 1) * per_page;
|
||||
|
||||
// Count total players with at least one match
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(DISTINCT u.id)
|
||||
FROM users u
|
||||
JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
|
||||
JOIN (
|
||||
SELECT team_id_1 as team_id FROM matches WHERE matche_status = 3
|
||||
UNION ALL
|
||||
SELECT team_id_2 FROM matches WHERE matche_status = 3
|
||||
) m ON t.id = m.team_id
|
||||
"#
|
||||
)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
// Get leaderboard with match stats
|
||||
let players = sqlx::query_as::<_, LeaderboardEntry>(
|
||||
r#"
|
||||
WITH player_matches AS (
|
||||
SELECT
|
||||
u.id as player_id,
|
||||
u.username,
|
||||
u.firstname,
|
||||
u.lastname,
|
||||
u.profile,
|
||||
t.name as team_name,
|
||||
t.id as team_id
|
||||
FROM users u
|
||||
JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
|
||||
),
|
||||
match_results AS (
|
||||
SELECT
|
||||
pm.player_id,
|
||||
COUNT(DISTINCT m.id) as matches_played,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN (m.team_id_1 = pm.team_id AND mr.team_1_score > mr.team_2_score)
|
||||
OR (m.team_id_2 = pm.team_id AND mr.team_2_score > mr.team_1_score)
|
||||
THEN m.id
|
||||
END) as matches_won
|
||||
FROM player_matches pm
|
||||
LEFT JOIN matches m ON (m.team_id_1 = pm.team_id OR m.team_id_2 = pm.team_id)
|
||||
AND m.matche_status = 3
|
||||
LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_acceptance_status = 1
|
||||
GROUP BY pm.player_id
|
||||
)
|
||||
SELECT
|
||||
ROW_NUMBER() OVER (ORDER BY COALESCE(mr.matches_won, 0) DESC, pm.username ASC) as rank,
|
||||
pm.player_id,
|
||||
pm.username,
|
||||
pm.firstname,
|
||||
pm.lastname,
|
||||
pm.profile,
|
||||
pm.team_name,
|
||||
mr.matches_won,
|
||||
mr.matches_played
|
||||
FROM player_matches pm
|
||||
LEFT JOIN match_results mr ON pm.player_id = mr.player_id
|
||||
ORDER BY COALESCE(mr.matches_won, 0) DESC, pm.username ASC
|
||||
LIMIT $1 OFFSET $2
|
||||
"#
|
||||
)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(LeaderboardResponse {
|
||||
players,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
async fn get_player_stats(
|
||||
pool: &sqlx::PgPool,
|
||||
player_id: i32,
|
||||
team_id: Option<i32>,
|
||||
) -> Result<PlayerStats> {
|
||||
// Get player's OpenSkill rating
|
||||
let rating: (Option<f32>, Option<f32>, Option<f32>, Option<f32>) = sqlx::query_as(
|
||||
"SELECT mu, sigma, ordinal, mmr FROM users WHERE id = $1"
|
||||
)
|
||||
.bind(player_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or((None, None, None, None));
|
||||
|
||||
let mu = rating.0.unwrap_or(25.0) as f64;
|
||||
let sigma = rating.1.unwrap_or(8.333333) as f64;
|
||||
let ordinal = rating.2.unwrap_or(0.0) as f64;
|
||||
let mmr = rating.3.unwrap_or(1500.0) as f64;
|
||||
|
||||
let Some(team_id) = team_id else {
|
||||
return Ok(PlayerStats {
|
||||
matches_played: 0,
|
||||
matches_won: 0,
|
||||
matches_lost: 0,
|
||||
win_rate: 0.0,
|
||||
ladders_participated: 0,
|
||||
mu,
|
||||
sigma,
|
||||
ordinal,
|
||||
mmr,
|
||||
});
|
||||
};
|
||||
|
||||
// Count completed matches
|
||||
let matches_played: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*) FROM matches
|
||||
WHERE (team_id_1 = $1 OR team_id_2 = $1) AND matche_status = 3
|
||||
"#
|
||||
)
|
||||
.bind(team_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
// Count wins (based on accepted scores)
|
||||
let matches_won: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(DISTINCT m.id) FROM matches m
|
||||
JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_acceptance_status = 1
|
||||
WHERE m.matche_status = 3
|
||||
AND (
|
||||
(m.team_id_1 = $1 AND mr.team_1_score > mr.team_2_score)
|
||||
OR (m.team_id_2 = $1 AND mr.team_2_score > mr.team_1_score)
|
||||
)
|
||||
"#
|
||||
)
|
||||
.bind(team_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let matches_lost = matches_played - matches_won;
|
||||
let win_rate = if matches_played > 0 {
|
||||
(matches_won as f32 / matches_played as f32) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Count ladders participated
|
||||
let ladders_participated: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ladder_teams WHERE team_id = $1"
|
||||
)
|
||||
.bind(team_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(PlayerStats {
|
||||
matches_played,
|
||||
matches_won,
|
||||
matches_lost,
|
||||
win_rate,
|
||||
ladders_participated,
|
||||
mu,
|
||||
sigma,
|
||||
ordinal,
|
||||
mmr,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_recent_matches(
|
||||
pool: &sqlx::PgPool,
|
||||
team_id: i32,
|
||||
limit: i64,
|
||||
) -> Result<Vec<PlayerMatchSummary>> {
|
||||
let matches = sqlx::query_as::<_, PlayerMatchSummary>(
|
||||
r#"
|
||||
SELECT
|
||||
m.id as match_id,
|
||||
m.date_start as date_played,
|
||||
CASE WHEN m.team_id_1 = $1 THEN t2.name ELSE t1.name END as opponent_team_name,
|
||||
CASE WHEN m.team_id_1 = $1 THEN t1.name ELSE t2.name END as own_team_name,
|
||||
CASE WHEN m.team_id_1 = $1 THEN COALESCE(mr.team_1_score, 0) ELSE COALESCE(mr.team_2_score, 0) END as own_score,
|
||||
CASE WHEN m.team_id_1 = $1 THEN COALESCE(mr.team_2_score, 0) ELSE COALESCE(mr.team_1_score, 0) END as opponent_score,
|
||||
CASE
|
||||
WHEN mr.id IS NULL THEN 'pending'
|
||||
WHEN m.team_id_1 = $1 AND mr.team_1_score > mr.team_2_score THEN 'win'
|
||||
WHEN m.team_id_2 = $1 AND mr.team_2_score > mr.team_1_score THEN 'win'
|
||||
WHEN mr.team_1_score = mr.team_2_score THEN 'draw'
|
||||
ELSE 'loss'
|
||||
END as result
|
||||
FROM matches m
|
||||
JOIN teams t1 ON m.team_id_1 = t1.id
|
||||
JOIN teams t2 ON m.team_id_2 = t2.id
|
||||
LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_acceptance_status = 1
|
||||
WHERE (m.team_id_1 = $1 OR m.team_id_2 = $1) AND m.matche_status = 3
|
||||
ORDER BY m.date_start DESC
|
||||
LIMIT $2
|
||||
"#
|
||||
)
|
||||
.bind(team_id)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
// Note: Profile upload is now handled by uploads.rs using multipart forms
|
||||
|
||||
/// GET /api/players/rating-history/:player_id
|
||||
/// Get a player's rating history
|
||||
pub async fn get_rating_history(
|
||||
State(state): State<AppState>,
|
||||
Path(player_id): Path<i32>,
|
||||
) -> Result<Json<Vec<RatingHistoryEntry>>> {
|
||||
let history = sqlx::query_as::<_, RatingHistoryEntry>(
|
||||
r#"
|
||||
SELECT
|
||||
rh.id, rh.date_created, rh.match_id,
|
||||
rh.old_mu, rh.old_sigma, rh.old_ordinal,
|
||||
rh.new_mu, rh.new_sigma, rh.new_ordinal,
|
||||
t1.name as opponent_name
|
||||
FROM rating_history rh
|
||||
LEFT JOIN matches m ON rh.match_id = m.id
|
||||
LEFT JOIN teams t1 ON (
|
||||
CASE
|
||||
WHEN m.team_id_1 IN (SELECT tp.team_id FROM team_players tp WHERE tp.player_id = $1 AND tp.status = 1)
|
||||
THEN m.team_id_2
|
||||
ELSE m.team_id_1
|
||||
END = t1.id
|
||||
)
|
||||
WHERE rh.entity_type = 'player' AND rh.entity_id = $1
|
||||
ORDER BY rh.date_created DESC
|
||||
LIMIT 50
|
||||
"#
|
||||
)
|
||||
.bind(player_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(history))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct RatingHistoryEntry {
|
||||
pub id: i32,
|
||||
pub date_created: chrono::DateTime<chrono::Utc>,
|
||||
pub match_id: Option<i32>,
|
||||
pub old_mu: f64,
|
||||
pub old_sigma: f64,
|
||||
pub old_ordinal: f64,
|
||||
pub new_mu: f64,
|
||||
pub new_sigma: f64,
|
||||
pub new_ordinal: f64,
|
||||
pub opponent_name: Option<String>,
|
||||
}
|
||||
751
src/handlers/teams.rs
Normal file
751
src/handlers/teams.rs
Normal file
@@ -0,0 +1,751 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::{AuthUser, OptionalAuthUser},
|
||||
error::{AppError, Result},
|
||||
models::team::Team,
|
||||
models::team_player::{PlayerPosition, TeamPlayer},
|
||||
};
|
||||
|
||||
use super::auth::AppState;
|
||||
|
||||
// ============================================================================
|
||||
// Request/Response Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ListTeamsQuery {
|
||||
pub search: Option<String>,
|
||||
pub page: Option<i64>,
|
||||
pub per_page: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TeamListResponse {
|
||||
pub teams: Vec<TeamWithMemberCount>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
pub per_page: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct TeamWithMemberCount {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub logo: String,
|
||||
pub bio: Option<String>,
|
||||
pub rank: i32,
|
||||
pub mmr: f32,
|
||||
pub date_created: chrono::DateTime<chrono::Utc>,
|
||||
pub member_count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct CreateTeamRequest {
|
||||
#[validate(length(min = 2, max = 255, message = "Team name must be 2-255 characters"))]
|
||||
pub name: String,
|
||||
pub bio: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct UpdateTeamRequest {
|
||||
#[validate(length(min = 2, max = 255, message = "Team name must be 2-255 characters"))]
|
||||
pub name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TeamResponse {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub logo: String,
|
||||
pub bio: Option<String>,
|
||||
pub rank: i32,
|
||||
pub mmr: f32,
|
||||
pub mu: f32,
|
||||
pub sigma: f32,
|
||||
pub ordinal: f32,
|
||||
pub date_created: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<Team> for TeamResponse {
|
||||
fn from(team: Team) -> Self {
|
||||
TeamResponse {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
logo: team.logo,
|
||||
bio: team.bio,
|
||||
rank: team.rank,
|
||||
mmr: team.mmr,
|
||||
mu: team.mu.unwrap_or(25.0),
|
||||
sigma: team.sigma.unwrap_or(8.333),
|
||||
ordinal: team.ordinal.unwrap_or(0.0),
|
||||
date_created: team.date_created,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TeamDetailResponse {
|
||||
pub team: TeamResponse,
|
||||
pub members: Vec<TeamMemberResponse>,
|
||||
pub is_member: bool,
|
||||
pub is_captain: bool,
|
||||
pub pending_request: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct TeamMemberResponse {
|
||||
pub id: i32,
|
||||
pub player_id: i32,
|
||||
pub username: String,
|
||||
pub firstname: String,
|
||||
pub lastname: String,
|
||||
pub profile: Option<String>,
|
||||
pub position: String,
|
||||
pub status: i32,
|
||||
pub date_joined: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChangePositionRequest {
|
||||
pub position: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MyTeamResponse {
|
||||
pub team: Option<TeamResponse>,
|
||||
pub position: Option<String>,
|
||||
pub members: Vec<TeamMemberResponse>,
|
||||
pub pending_requests: Vec<TeamMemberResponse>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Handlers
|
||||
// ============================================================================
|
||||
|
||||
/// GET /api/teams
|
||||
/// List all teams with pagination and search
|
||||
pub async fn list_teams(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListTeamsQuery>,
|
||||
) -> Result<Json<TeamListResponse>> {
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
|
||||
let offset = (page - 1) * per_page;
|
||||
|
||||
let search_pattern = query.search.as_ref().map(|s| format!("%{}%", s));
|
||||
|
||||
// Get total count
|
||||
let total: i64 = if let Some(ref pattern) = search_pattern {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM teams WHERE is_delete = false AND name ILIKE $1"
|
||||
)
|
||||
.bind(pattern)
|
||||
.fetch_one(&state.pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM teams WHERE is_delete = false"
|
||||
)
|
||||
.fetch_one(&state.pool)
|
||||
.await?
|
||||
};
|
||||
|
||||
// Get teams with member count
|
||||
let teams: Vec<TeamWithMemberCount> = if let Some(ref pattern) = search_pattern {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT t.id, t.name, t.logo, t.bio, t.rank, t.mmr, t.date_created,
|
||||
COUNT(tp.id) FILTER (WHERE tp.status = 1) as member_count
|
||||
FROM teams t
|
||||
LEFT JOIN team_players tp ON t.id = tp.team_id
|
||||
WHERE t.is_delete = false AND t.name ILIKE $1
|
||||
GROUP BY t.id
|
||||
ORDER BY t.rank ASC, t.mmr DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
"#
|
||||
)
|
||||
.bind(pattern)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT t.id, t.name, t.logo, t.bio, t.rank, t.mmr, t.date_created,
|
||||
COUNT(tp.id) FILTER (WHERE tp.status = 1) as member_count
|
||||
FROM teams t
|
||||
LEFT JOIN team_players tp ON t.id = tp.team_id
|
||||
WHERE t.is_delete = false
|
||||
GROUP BY t.id
|
||||
ORDER BY t.rank ASC, t.mmr DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
"#
|
||||
)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(Json(TeamListResponse {
|
||||
teams,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/teams
|
||||
/// Create a new team (creator becomes captain)
|
||||
pub async fn create_team(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateTeamRequest>,
|
||||
) -> Result<(StatusCode, Json<TeamDetailResponse>)> {
|
||||
payload.validate()?;
|
||||
|
||||
// Check if user is already in a team
|
||||
let existing_membership = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM team_players WHERE player_id = $1 AND status = 1"
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if existing_membership.is_some() {
|
||||
return Err(AppError::Conflict("You are already a member of a team".to_string()));
|
||||
}
|
||||
|
||||
// Check if team name already exists
|
||||
let existing_team = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM teams WHERE name = $1 AND is_delete = false"
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if existing_team.is_some() {
|
||||
return Err(AppError::Conflict("Team name already exists".to_string()));
|
||||
}
|
||||
|
||||
// Create team (logo uses default empty string)
|
||||
let team = sqlx::query_as::<_, Team>(
|
||||
r#"
|
||||
INSERT INTO teams (name, bio, logo, rank, mmr)
|
||||
VALUES ($1, $2, '', 0, 1000.0)
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.bind(&payload.bio)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Add creator as captain
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO team_players (team_id, player_id, position, status)
|
||||
VALUES ($1, $2, 'captain', 1)
|
||||
"#
|
||||
)
|
||||
.bind(team.id)
|
||||
.bind(user.id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Get members (just the captain)
|
||||
let members = get_team_members(&state.pool, team.id).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(TeamDetailResponse {
|
||||
team: team.into(),
|
||||
members,
|
||||
is_member: true,
|
||||
is_captain: true,
|
||||
pending_request: false,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// GET /api/teams/:id
|
||||
/// Get team details with members
|
||||
pub async fn get_team(
|
||||
OptionalAuthUser(user): OptionalAuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(team_id): Path<i32>,
|
||||
) -> Result<Json<TeamDetailResponse>> {
|
||||
let team = sqlx::query_as::<_, Team>(
|
||||
"SELECT * FROM teams WHERE id = $1 AND is_delete = false"
|
||||
)
|
||||
.bind(team_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Team not found".to_string()))?;
|
||||
|
||||
let members = get_team_members(&state.pool, team_id).await?;
|
||||
|
||||
// Check user's relationship with team
|
||||
let (is_member, is_captain, pending_request) = if let Some(ref u) = user {
|
||||
let membership = sqlx::query_as::<_, TeamPlayer>(
|
||||
"SELECT * FROM team_players WHERE team_id = $1 AND player_id = $2"
|
||||
)
|
||||
.bind(team_id)
|
||||
.bind(u.id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
match membership {
|
||||
Some(m) => (
|
||||
m.status == 1,
|
||||
m.status == 1 && m.position == "captain",
|
||||
m.status == 0,
|
||||
),
|
||||
None => (false, false, false),
|
||||
}
|
||||
} else {
|
||||
(false, false, false)
|
||||
};
|
||||
|
||||
Ok(Json(TeamDetailResponse {
|
||||
team: team.into(),
|
||||
members,
|
||||
is_member,
|
||||
is_captain,
|
||||
pending_request,
|
||||
}))
|
||||
}
|
||||
|
||||
/// PUT /api/teams/:id
|
||||
/// Update team (captain only)
|
||||
pub async fn update_team(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(team_id): Path<i32>,
|
||||
Json(payload): Json<UpdateTeamRequest>,
|
||||
) -> Result<Json<TeamResponse>> {
|
||||
payload.validate()?;
|
||||
|
||||
// Check if user is captain
|
||||
let is_captain = check_is_captain(&state.pool, team_id, user.id).await?;
|
||||
if !is_captain {
|
||||
return Err(AppError::Forbidden("Only the captain can update the team".to_string()));
|
||||
}
|
||||
|
||||
// Check if new name conflicts
|
||||
if let Some(ref name) = payload.name {
|
||||
let existing = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM teams WHERE name = $1 AND id != $2 AND is_delete = false"
|
||||
)
|
||||
.bind(name)
|
||||
.bind(team_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(AppError::Conflict("Team name already exists".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let team = sqlx::query_as::<_, Team>(
|
||||
r#"
|
||||
UPDATE teams
|
||||
SET name = COALESCE($1, name),
|
||||
bio = COALESCE($2, bio)
|
||||
WHERE id = $3 AND is_delete = false
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.bind(&payload.bio)
|
||||
.bind(team_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Team not found".to_string()))?;
|
||||
|
||||
Ok(Json(team.into()))
|
||||
}
|
||||
|
||||
/// DELETE /api/teams/:id
|
||||
/// Delete team (captain only)
|
||||
pub async fn delete_team(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(team_id): Path<i32>,
|
||||
) -> Result<StatusCode> {
|
||||
// Check if user is captain
|
||||
let is_captain = check_is_captain(&state.pool, team_id, user.id).await?;
|
||||
if !is_captain && !user.is_admin {
|
||||
return Err(AppError::Forbidden("Only the captain can delete the team".to_string()));
|
||||
}
|
||||
|
||||
// Soft delete team
|
||||
let result = sqlx::query(
|
||||
"UPDATE teams SET is_delete = true WHERE id = $1"
|
||||
)
|
||||
.bind(team_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Team not found".to_string()));
|
||||
}
|
||||
|
||||
// Remove all team members
|
||||
sqlx::query("DELETE FROM team_players WHERE team_id = $1")
|
||||
.bind(team_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Remove from ladders
|
||||
sqlx::query("DELETE FROM ladder_teams WHERE team_id = $1")
|
||||
.bind(team_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// POST /api/teams/:id/join-request
|
||||
/// Request to join a team
|
||||
pub async fn request_join_team(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(team_id): Path<i32>,
|
||||
) -> Result<StatusCode> {
|
||||
// Check team exists
|
||||
let team_exists = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM teams WHERE id = $1 AND is_delete = false"
|
||||
)
|
||||
.bind(team_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if team_exists.is_none() {
|
||||
return Err(AppError::NotFound("Team not found".to_string()));
|
||||
}
|
||||
|
||||
// Check if user is already in a team (active)
|
||||
let existing_active = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM team_players WHERE player_id = $1 AND status = 1"
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if existing_active.is_some() {
|
||||
return Err(AppError::Conflict("You are already a member of a team".to_string()));
|
||||
}
|
||||
|
||||
// Check if already requested
|
||||
let existing_request = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM team_players WHERE team_id = $1 AND player_id = $2"
|
||||
)
|
||||
.bind(team_id)
|
||||
.bind(user.id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if existing_request.is_some() {
|
||||
return Err(AppError::Conflict("You already have a pending request".to_string()));
|
||||
}
|
||||
|
||||
// Create join request
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO team_players (team_id, player_id, position, status)
|
||||
VALUES ($1, $2, 'member', 0)
|
||||
"#
|
||||
)
|
||||
.bind(team_id)
|
||||
.bind(user.id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
/// DELETE /api/teams/:id/join-request
|
||||
/// Cancel join request
|
||||
pub async fn cancel_join_request(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(team_id): Path<i32>,
|
||||
) -> Result<StatusCode> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 0"
|
||||
)
|
||||
.bind(team_id)
|
||||
.bind(user.id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("No pending request found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// POST /api/teams/:id/members/:player_id/accept
|
||||
/// Accept a join request (captain only)
|
||||
pub async fn accept_member(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path((team_id, player_id)): Path<(i32, i32)>,
|
||||
) -> Result<StatusCode> {
|
||||
// Check if user is captain
|
||||
let is_captain = check_is_captain(&state.pool, team_id, user.id).await?;
|
||||
if !is_captain {
|
||||
return Err(AppError::Forbidden("Only the captain can accept members".to_string()));
|
||||
}
|
||||
|
||||
// Update status to active
|
||||
let result = sqlx::query(
|
||||
"UPDATE team_players SET status = 1 WHERE team_id = $1 AND player_id = $2 AND status = 0"
|
||||
)
|
||||
.bind(team_id)
|
||||
.bind(player_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("No pending request found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// POST /api/teams/:id/members/:player_id/reject
|
||||
/// Reject a join request (captain only)
|
||||
pub async fn reject_member(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path((team_id, player_id)): Path<(i32, i32)>,
|
||||
) -> Result<StatusCode> {
|
||||
// Check if user is captain
|
||||
let is_captain = check_is_captain(&state.pool, team_id, user.id).await?;
|
||||
if !is_captain {
|
||||
return Err(AppError::Forbidden("Only the captain can reject members".to_string()));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 0"
|
||||
)
|
||||
.bind(team_id)
|
||||
.bind(player_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("No pending request found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// DELETE /api/teams/:id/members/:player_id
|
||||
/// Remove a member or leave team
|
||||
pub async fn remove_member(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path((team_id, player_id)): Path<(i32, i32)>,
|
||||
) -> Result<StatusCode> {
|
||||
let is_captain = check_is_captain(&state.pool, team_id, user.id).await?;
|
||||
let is_self = user.id == player_id;
|
||||
|
||||
// Check permission
|
||||
if !is_captain && !is_self && !user.is_admin {
|
||||
return Err(AppError::Forbidden("You can only remove yourself or be captain to remove others".to_string()));
|
||||
}
|
||||
|
||||
// Check if target is captain
|
||||
let target_membership = sqlx::query_as::<_, TeamPlayer>(
|
||||
"SELECT * FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 1"
|
||||
)
|
||||
.bind(team_id)
|
||||
.bind(player_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Member not found".to_string()))?;
|
||||
|
||||
// Captain can't leave without transferring or deleting team
|
||||
if target_membership.position == "captain" && is_self {
|
||||
return Err(AppError::BadRequest(
|
||||
"Captain cannot leave. Transfer captaincy or delete the team.".to_string()
|
||||
));
|
||||
}
|
||||
|
||||
// Remove member
|
||||
sqlx::query(
|
||||
"DELETE FROM team_players WHERE team_id = $1 AND player_id = $2"
|
||||
)
|
||||
.bind(team_id)
|
||||
.bind(player_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// PUT /api/teams/:id/members/:player_id/position
|
||||
/// Change member position (captain only)
|
||||
pub async fn change_member_position(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path((team_id, player_id)): Path<(i32, i32)>,
|
||||
Json(payload): Json<ChangePositionRequest>,
|
||||
) -> Result<StatusCode> {
|
||||
// Validate position
|
||||
let position = PlayerPosition::from_str(&payload.position)
|
||||
.ok_or_else(|| AppError::BadRequest("Invalid position. Use: captain, co-captain, or member".to_string()))?;
|
||||
|
||||
// Check if user is captain
|
||||
let is_captain = check_is_captain(&state.pool, team_id, user.id).await?;
|
||||
if !is_captain {
|
||||
return Err(AppError::Forbidden("Only the captain can change positions".to_string()));
|
||||
}
|
||||
|
||||
// If promoting to captain, demote current captain
|
||||
if position == PlayerPosition::Captain && player_id != user.id {
|
||||
// Demote current captain to co-captain
|
||||
sqlx::query(
|
||||
"UPDATE team_players SET position = 'co-captain' WHERE team_id = $1 AND player_id = $2"
|
||||
)
|
||||
.bind(team_id)
|
||||
.bind(user.id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Update position
|
||||
let result = sqlx::query(
|
||||
"UPDATE team_players SET position = $1 WHERE team_id = $2 AND player_id = $3 AND status = 1"
|
||||
)
|
||||
.bind(position.as_str())
|
||||
.bind(team_id)
|
||||
.bind(player_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Member not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// GET /api/my-team
|
||||
/// Get current user's team
|
||||
pub async fn get_my_team(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<MyTeamResponse>> {
|
||||
// Get user's team membership
|
||||
let membership = sqlx::query_as::<_, TeamPlayer>(
|
||||
"SELECT * FROM team_players WHERE player_id = $1 AND status = 1"
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let Some(membership) = membership else {
|
||||
return Ok(Json(MyTeamResponse {
|
||||
team: None,
|
||||
position: None,
|
||||
members: vec![],
|
||||
pending_requests: vec![],
|
||||
}));
|
||||
};
|
||||
|
||||
// Get team
|
||||
let team = sqlx::query_as::<_, Team>(
|
||||
"SELECT * FROM teams WHERE id = $1 AND is_delete = false"
|
||||
)
|
||||
.bind(membership.team_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Get members
|
||||
let members = get_team_members(&state.pool, membership.team_id).await?;
|
||||
|
||||
// Get pending requests (if captain)
|
||||
let pending_requests = if membership.position == "captain" {
|
||||
sqlx::query_as::<_, TeamMemberResponse>(
|
||||
r#"
|
||||
SELECT tp.id, tp.player_id, u.username, u.firstname, u.lastname, u.profile,
|
||||
tp.position, tp.status, tp.date_created as date_joined
|
||||
FROM team_players tp
|
||||
JOIN users u ON tp.player_id = u.id
|
||||
WHERE tp.team_id = $1 AND tp.status = 0
|
||||
ORDER BY tp.date_created ASC
|
||||
"#
|
||||
)
|
||||
.bind(membership.team_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
Ok(Json(MyTeamResponse {
|
||||
team: Some(team.into()),
|
||||
position: Some(membership.position),
|
||||
members,
|
||||
pending_requests,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
async fn get_team_members(pool: &sqlx::PgPool, team_id: i32) -> Result<Vec<TeamMemberResponse>> {
|
||||
let members = sqlx::query_as::<_, TeamMemberResponse>(
|
||||
r#"
|
||||
SELECT tp.id, tp.player_id, u.username, u.firstname, u.lastname, u.profile,
|
||||
tp.position, tp.status, tp.date_created as date_joined
|
||||
FROM team_players tp
|
||||
JOIN users u ON tp.player_id = u.id
|
||||
WHERE tp.team_id = $1 AND tp.status = 1
|
||||
ORDER BY
|
||||
CASE tp.position
|
||||
WHEN 'captain' THEN 1
|
||||
WHEN 'co-captain' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
tp.date_created ASC
|
||||
"#
|
||||
)
|
||||
.bind(team_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(members)
|
||||
}
|
||||
|
||||
async fn check_is_captain(pool: &sqlx::PgPool, team_id: i32, user_id: i32) -> Result<bool> {
|
||||
let is_captain = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM team_players WHERE team_id = $1 AND player_id = $2 AND position = 'captain' AND status = 1"
|
||||
)
|
||||
.bind(team_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(is_captain.is_some())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Logo Upload
|
||||
// Note: Team logo upload is now handled by uploads.rs using multipart forms
|
||||
217
src/handlers/uploads.rs
Normal file
217
src/handlers/uploads.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
//! File upload handlers for teams, players, and ladders
|
||||
|
||||
use axum::{
|
||||
extract::{Multipart, Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, Result},
|
||||
services::storage::UploadType,
|
||||
};
|
||||
|
||||
use super::auth::AppState;
|
||||
|
||||
/// Upload response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UploadResponse {
|
||||
pub url: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// POST /api/teams/:id/logo
|
||||
/// Upload team logo (captain only)
|
||||
pub async fn upload_team_logo(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(team_id): Path<i32>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<UploadResponse>> {
|
||||
// Verify user is captain of this team
|
||||
let is_captain: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM team_players WHERE team_id = $1 AND player_id = $2 AND position = 'captain' AND status = 1)"
|
||||
)
|
||||
.bind(team_id)
|
||||
.bind(user.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
if !is_captain && !user.is_admin {
|
||||
return Err(AppError::Forbidden("Only team captain can upload logo".to_string()));
|
||||
}
|
||||
|
||||
// Process multipart form
|
||||
let (file_data, filename, content_type) = extract_file_from_multipart(&mut multipart).await?;
|
||||
|
||||
// Upload file
|
||||
let url = state.storage.upload_file(
|
||||
&file_data,
|
||||
&filename,
|
||||
&content_type,
|
||||
UploadType::TeamLogo,
|
||||
).await?;
|
||||
|
||||
// Update team logo in database
|
||||
sqlx::query("UPDATE teams SET logo = $1 WHERE id = $2")
|
||||
.bind(&url)
|
||||
.bind(team_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(UploadResponse {
|
||||
url,
|
||||
message: "Team logo uploaded successfully".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/users/me/profile
|
||||
/// Upload user profile picture
|
||||
pub async fn upload_profile_picture(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<UploadResponse>> {
|
||||
// Process multipart form
|
||||
let (file_data, filename, content_type) = extract_file_from_multipart(&mut multipart).await?;
|
||||
|
||||
// Upload file
|
||||
let url = state.storage.upload_file(
|
||||
&file_data,
|
||||
&filename,
|
||||
&content_type,
|
||||
UploadType::PlayerProfile,
|
||||
).await?;
|
||||
|
||||
// Update user profile in database
|
||||
sqlx::query("UPDATE users SET profile = $1 WHERE id = $2")
|
||||
.bind(&url)
|
||||
.bind(user.id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(UploadResponse {
|
||||
url,
|
||||
message: "Profile picture uploaded successfully".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/ladders/:id/logo
|
||||
/// Upload ladder logo (admin only)
|
||||
pub async fn upload_ladder_logo(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(ladder_id): Path<i32>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<UploadResponse>> {
|
||||
// Only admins can upload ladder logos
|
||||
if !user.is_admin {
|
||||
return Err(AppError::Forbidden("Only admins can upload ladder logos".to_string()));
|
||||
}
|
||||
|
||||
// Verify ladder exists
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM ladders WHERE id = $1)")
|
||||
.bind(ladder_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
if !exists {
|
||||
return Err(AppError::NotFound("Ladder not found".to_string()));
|
||||
}
|
||||
|
||||
// Process multipart form
|
||||
let (file_data, filename, content_type) = extract_file_from_multipart(&mut multipart).await?;
|
||||
|
||||
// Upload file
|
||||
let url = state.storage.upload_file(
|
||||
&file_data,
|
||||
&filename,
|
||||
&content_type,
|
||||
UploadType::LadderLogo,
|
||||
).await?;
|
||||
|
||||
// Update ladder logo in database
|
||||
sqlx::query("UPDATE ladders SET logo = $1 WHERE id = $2")
|
||||
.bind(&url)
|
||||
.bind(ladder_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(UploadResponse {
|
||||
url,
|
||||
message: "Ladder logo uploaded successfully".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// DELETE /api/teams/:id/logo
|
||||
/// Remove team logo (captain only)
|
||||
pub async fn delete_team_logo(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
Path(team_id): Path<i32>,
|
||||
) -> Result<StatusCode> {
|
||||
// Verify user is captain of this team
|
||||
let is_captain: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM team_players WHERE team_id = $1 AND player_id = $2 AND position = 'captain' AND status = 1)"
|
||||
)
|
||||
.bind(team_id)
|
||||
.bind(user.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
if !is_captain && !user.is_admin {
|
||||
return Err(AppError::Forbidden("Only team captain can delete logo".to_string()));
|
||||
}
|
||||
|
||||
// Get current logo URL
|
||||
let logo_url: Option<String> = sqlx::query_scalar("SELECT logo FROM teams WHERE id = $1")
|
||||
.bind(team_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Delete from storage if exists
|
||||
if let Some(url) = logo_url {
|
||||
if !url.is_empty() {
|
||||
let _ = state.storage.delete_file(&url).await; // Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Clear logo in database
|
||||
sqlx::query("UPDATE teams SET logo = '' WHERE id = $1")
|
||||
.bind(team_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Helper function to extract file from multipart form
|
||||
async fn extract_file_from_multipart(
|
||||
multipart: &mut Multipart,
|
||||
) -> Result<(Vec<u8>, String, String)> {
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| {
|
||||
AppError::BadRequest(format!("Failed to read multipart form: {}", e))
|
||||
})? {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
|
||||
if name == "file" || name == "logo" || name == "profile" || name == "image" {
|
||||
let filename = field.file_name()
|
||||
.unwrap_or("upload.jpg")
|
||||
.to_string();
|
||||
|
||||
let content_type = field.content_type()
|
||||
.unwrap_or("image/jpeg")
|
||||
.to_string();
|
||||
|
||||
let data = field.bytes().await.map_err(|e| {
|
||||
AppError::BadRequest(format!("Failed to read file data: {}", e))
|
||||
})?;
|
||||
|
||||
return Ok((data.to_vec(), filename, content_type));
|
||||
}
|
||||
}
|
||||
|
||||
Err(AppError::BadRequest("No file found in upload".to_string()))
|
||||
}
|
||||
54
src/handlers/users.rs
Normal file
54
src/handlers/users.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::Result,
|
||||
models::user::User,
|
||||
};
|
||||
|
||||
use super::auth::AppState;
|
||||
|
||||
/// User profile response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserProfileResponse {
|
||||
pub id: i32,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub firstname: String,
|
||||
pub lastname: String,
|
||||
pub is_admin: bool,
|
||||
pub profile: Option<String>,
|
||||
pub date_registered: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<User> for UserProfileResponse {
|
||||
fn from(user: User) -> Self {
|
||||
UserProfileResponse {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
firstname: user.firstname,
|
||||
lastname: user.lastname,
|
||||
is_admin: user.is_admin,
|
||||
profile: user.profile,
|
||||
date_registered: user.date_registered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/users/me
|
||||
/// Get current authenticated user's profile
|
||||
pub async fn get_current_user(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<UserProfileResponse>> {
|
||||
let db_user = sqlx::query_as::<_, User>(
|
||||
"SELECT * FROM users WHERE id = $1"
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(db_user.into()))
|
||||
}
|
||||
16
src/lib.rs
Normal file
16
src/lib.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
// VRBattles API - Core Library
|
||||
//
|
||||
// This module exports all the components needed for the VRBattles esports platform API.
|
||||
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod handlers;
|
||||
pub mod models;
|
||||
pub mod services;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use config::Config;
|
||||
pub use db::DbPool;
|
||||
pub use error::{AppError, Result};
|
||||
146
src/main.rs
Normal file
146
src/main.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use axum::{
|
||||
http::{header, Method},
|
||||
routing::{delete, get, post, put},
|
||||
Extension, Router,
|
||||
};
|
||||
use tower_http::{
|
||||
cors::{Any, CorsLayer},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use vrbattles_api::{
|
||||
auth::middleware::JwtSecret,
|
||||
config::Config,
|
||||
db::{init_pool, run_migrations},
|
||||
handlers::{
|
||||
auth::{login, register, AppState},
|
||||
health::health_check,
|
||||
ladders::{
|
||||
list_ladders, create_ladder, get_ladder, update_ladder, delete_ladder,
|
||||
enroll_team, withdraw_team, get_ladder_standings,
|
||||
},
|
||||
matches::{
|
||||
list_matches, get_match, create_challenge, accept_challenge, reject_challenge,
|
||||
start_match, cancel_match, report_score, accept_score, reject_score, get_my_matches,
|
||||
},
|
||||
players::{
|
||||
list_players, get_player, get_featured_players, set_featured_player,
|
||||
remove_featured_player, get_leaderboard,
|
||||
},
|
||||
teams::{
|
||||
list_teams, create_team, get_team, update_team, delete_team,
|
||||
request_join_team, cancel_join_request, accept_member, reject_member,
|
||||
remove_member, change_member_position, get_my_team,
|
||||
},
|
||||
uploads::{
|
||||
upload_team_logo, upload_profile_picture, upload_ladder_logo, delete_team_logo,
|
||||
},
|
||||
users::get_current_user,
|
||||
},
|
||||
services::storage::StorageService,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "vrbattles_api=debug,tower_http=debug".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
tracing::info!("Starting VRBattles API...");
|
||||
|
||||
// Load configuration
|
||||
let config = Config::from_env().expect("Failed to load configuration");
|
||||
tracing::info!("Configuration loaded. Environment: {}", config.environment);
|
||||
|
||||
// Initialize database pool
|
||||
let pool = init_pool(&config).await?;
|
||||
|
||||
// Run migrations
|
||||
run_migrations(&pool).await?;
|
||||
|
||||
// Initialize storage service
|
||||
let storage = StorageService::new(&config).await;
|
||||
tracing::info!(
|
||||
"Storage service initialized. Using S3: {}",
|
||||
storage.is_s3_enabled()
|
||||
);
|
||||
|
||||
// Create app state
|
||||
let app_state = AppState {
|
||||
pool: pool.clone(),
|
||||
config: config.clone(),
|
||||
storage,
|
||||
};
|
||||
|
||||
// Configure CORS
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::PATCH])
|
||||
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]);
|
||||
|
||||
// Build router
|
||||
let app = Router::new()
|
||||
// Health check
|
||||
.route("/health", get(health_check))
|
||||
// Auth routes
|
||||
.route("/api/auth/login", post(login))
|
||||
.route("/api/auth/register", post(register))
|
||||
// User routes
|
||||
.route("/api/users/me", get(get_current_user))
|
||||
// Team routes
|
||||
.route("/api/teams", get(list_teams).post(create_team))
|
||||
.route("/api/teams/:id", get(get_team).put(update_team).delete(delete_team))
|
||||
.route("/api/teams/:id/join-request", post(request_join_team).delete(cancel_join_request))
|
||||
.route("/api/teams/:id/members/:player_id/accept", post(accept_member))
|
||||
.route("/api/teams/:id/members/:player_id/reject", post(reject_member))
|
||||
.route("/api/teams/:id/members/:player_id", delete(remove_member))
|
||||
.route("/api/teams/:id/members/:player_id/position", put(change_member_position))
|
||||
.route("/api/my-team", get(get_my_team))
|
||||
// Ladder routes
|
||||
.route("/api/ladders", get(list_ladders).post(create_ladder))
|
||||
.route("/api/ladders/:id", get(get_ladder).put(update_ladder).delete(delete_ladder))
|
||||
.route("/api/ladders/:id/enroll", post(enroll_team).delete(withdraw_team))
|
||||
.route("/api/ladders/:id/standings", get(get_ladder_standings))
|
||||
// Match routes
|
||||
.route("/api/matches", get(list_matches))
|
||||
.route("/api/matches/challenge", post(create_challenge))
|
||||
.route("/api/matches/:id", get(get_match))
|
||||
.route("/api/matches/:id/accept", post(accept_challenge))
|
||||
.route("/api/matches/:id/reject", post(reject_challenge))
|
||||
.route("/api/matches/:id/start", post(start_match))
|
||||
.route("/api/matches/:id/cancel", post(cancel_match))
|
||||
.route("/api/matches/:id/score", post(report_score))
|
||||
.route("/api/matches/:id/score/:round_id/accept", post(accept_score))
|
||||
.route("/api/matches/:id/score/:round_id/reject", post(reject_score))
|
||||
.route("/api/my-matches", get(get_my_matches))
|
||||
// Player routes
|
||||
.route("/api/players", get(list_players))
|
||||
.route("/api/players/featured", get(get_featured_players).post(set_featured_player))
|
||||
.route("/api/players/featured/:player_id", delete(remove_featured_player))
|
||||
.route("/api/players/leaderboard", get(get_leaderboard))
|
||||
.route("/api/players/:id", get(get_player))
|
||||
// Upload routes
|
||||
.route("/api/teams/:id/logo", post(upload_team_logo).delete(delete_team_logo))
|
||||
.route("/api/users/me/profile", post(upload_profile_picture))
|
||||
.route("/api/ladders/:id/logo", post(upload_ladder_logo))
|
||||
// Add state and middleware
|
||||
.layer(Extension(JwtSecret(config.jwt_secret.clone())))
|
||||
.layer(cors)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(app_state);
|
||||
|
||||
// Start server
|
||||
let addr = config.server_addr();
|
||||
tracing::info!("Server listening on {}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
24
src/models/featured_player.rs
Normal file
24
src/models/featured_player.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Featured player model representing the featured_players table
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct FeaturedPlayer {
|
||||
pub id: i32,
|
||||
pub date_created: DateTime<Utc>,
|
||||
pub player_id: i32,
|
||||
pub rank: i32,
|
||||
}
|
||||
|
||||
/// Featured player with user details (joined query result)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct FeaturedPlayerWithUser {
|
||||
pub id: i32,
|
||||
pub player_id: i32,
|
||||
pub rank: i32,
|
||||
pub firstname: String,
|
||||
pub lastname: String,
|
||||
pub username: String,
|
||||
pub profile: Option<String>,
|
||||
}
|
||||
50
src/models/ladder.rs
Normal file
50
src/models/ladder.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Ladder status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum LadderStatus {
|
||||
Open = 0,
|
||||
Closed = 1,
|
||||
}
|
||||
|
||||
impl From<i32> for LadderStatus {
|
||||
fn from(val: i32) -> Self {
|
||||
match val {
|
||||
1 => LadderStatus::Closed,
|
||||
_ => LadderStatus::Open,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ladder model representing the ladders table
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Ladder {
|
||||
pub id: i32,
|
||||
pub date_created: DateTime<Utc>,
|
||||
pub date_expiration: Option<String>,
|
||||
pub date_start: Option<String>,
|
||||
pub created_by_id: i32,
|
||||
pub name: String,
|
||||
pub status: i32,
|
||||
pub logo: Option<String>,
|
||||
}
|
||||
|
||||
/// Create ladder request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateLadder {
|
||||
pub name: String,
|
||||
pub date_start: String,
|
||||
pub date_expiration: String,
|
||||
}
|
||||
|
||||
/// Update ladder request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateLadder {
|
||||
pub name: Option<String>,
|
||||
pub date_start: Option<String>,
|
||||
pub date_expiration: Option<String>,
|
||||
pub status: Option<i32>,
|
||||
pub logo: Option<String>,
|
||||
}
|
||||
18
src/models/ladder_team.rs
Normal file
18
src/models/ladder_team.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Ladder team model representing the ladder_teams table
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct LadderTeam {
|
||||
pub id: i32,
|
||||
pub date_created: DateTime<Utc>,
|
||||
pub ladder_id: i32,
|
||||
pub team_id: i32,
|
||||
}
|
||||
|
||||
/// Join ladder request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct JoinLadderRequest {
|
||||
pub team_id: i32,
|
||||
}
|
||||
75
src/models/match_model.rs
Normal file
75
src/models/match_model.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Match status values
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum MatchStatus {
|
||||
Open = 0, // Initial state
|
||||
Scheduled = 1, // Both teams accepted
|
||||
InProgress = 2, // Match is being played
|
||||
Completed = 3, // Match finished
|
||||
Cancelled = 4, // Match cancelled
|
||||
}
|
||||
|
||||
impl From<i32> for MatchStatus {
|
||||
fn from(val: i32) -> Self {
|
||||
match val {
|
||||
1 => MatchStatus::Scheduled,
|
||||
2 => MatchStatus::InProgress,
|
||||
3 => MatchStatus::Completed,
|
||||
4 => MatchStatus::Cancelled,
|
||||
_ => MatchStatus::Open,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Team challenge status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum TeamChallengeStatus {
|
||||
Challenging = 0, // Sent challenge
|
||||
PendingResponse = 1, // Waiting for response
|
||||
Challenged = 2, // Received challenge
|
||||
Rejected = 3, // Challenge rejected
|
||||
Accepted = 4, // Challenge accepted
|
||||
}
|
||||
|
||||
impl From<i32> for TeamChallengeStatus {
|
||||
fn from(val: i32) -> Self {
|
||||
match val {
|
||||
1 => TeamChallengeStatus::PendingResponse,
|
||||
2 => TeamChallengeStatus::Challenged,
|
||||
3 => TeamChallengeStatus::Rejected,
|
||||
4 => TeamChallengeStatus::Accepted,
|
||||
_ => TeamChallengeStatus::Challenging,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Match model representing the matches table
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Match {
|
||||
pub id: i32,
|
||||
pub date_created: DateTime<Utc>,
|
||||
pub date_start: DateTime<Utc>,
|
||||
pub created_by_id: i32,
|
||||
pub challenge_date: Option<DateTime<Utc>>,
|
||||
pub team_id_1: i32,
|
||||
pub team_id_2: i32,
|
||||
pub team_1_status: i32,
|
||||
pub team_2_status: i32,
|
||||
pub matche_status: i32,
|
||||
}
|
||||
|
||||
/// Create match/challenge request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateChallengeRequest {
|
||||
pub to_team_id: i32,
|
||||
pub challenge_date: String,
|
||||
}
|
||||
|
||||
/// Accept/Counter challenge request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RespondChallengeRequest {
|
||||
pub challenge_date: Option<String>,
|
||||
}
|
||||
42
src/models/match_round.rs
Normal file
42
src/models/match_round.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Score acceptance status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ScoreAcceptanceStatus {
|
||||
Pending = 0,
|
||||
Submitted = 1,
|
||||
Accepted = 2,
|
||||
Rejected = 3,
|
||||
}
|
||||
|
||||
impl From<i32> for ScoreAcceptanceStatus {
|
||||
fn from(val: i32) -> Self {
|
||||
match val {
|
||||
1 => ScoreAcceptanceStatus::Submitted,
|
||||
2 => ScoreAcceptanceStatus::Accepted,
|
||||
3 => ScoreAcceptanceStatus::Rejected,
|
||||
_ => ScoreAcceptanceStatus::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Match round model representing the match_rounds table
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct MatchRound {
|
||||
pub id: i32,
|
||||
pub date_created: DateTime<Utc>,
|
||||
pub match_id: i32,
|
||||
pub team_1_score: i32,
|
||||
pub team_2_score: i32,
|
||||
pub score_posted_by_team_id: i32,
|
||||
pub score_acceptance_status: i32,
|
||||
}
|
||||
|
||||
/// Submit score request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SubmitScoreRequest {
|
||||
pub team_1_score: i32,
|
||||
pub team_2_score: i32,
|
||||
}
|
||||
8
src/models/mod.rs
Normal file
8
src/models/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod featured_player;
|
||||
pub mod ladder;
|
||||
pub mod ladder_team;
|
||||
pub mod match_model;
|
||||
pub mod match_round;
|
||||
pub mod team;
|
||||
pub mod team_player;
|
||||
pub mod user;
|
||||
83
src/models/team.rs
Normal file
83
src/models/team.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Team model representing the teams table
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Team {
|
||||
pub id: i32,
|
||||
pub date_created: DateTime<Utc>,
|
||||
pub name: String,
|
||||
pub logo: String, // NOT NULL DEFAULT '' in DB
|
||||
pub bio: Option<String>,
|
||||
pub rank: i32,
|
||||
pub mmr: f32,
|
||||
pub is_delete: bool,
|
||||
// OpenSkill rating fields
|
||||
pub mu: Option<f32>,
|
||||
pub sigma: Option<f32>,
|
||||
pub ordinal: Option<f32>,
|
||||
}
|
||||
|
||||
/// Team with full stats (for API responses)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TeamWithStats {
|
||||
pub id: i32,
|
||||
pub date_created: DateTime<Utc>,
|
||||
pub name: String,
|
||||
pub logo: String,
|
||||
pub bio: Option<String>,
|
||||
pub rank: i32,
|
||||
pub mmr: f64,
|
||||
pub mu: f64,
|
||||
pub sigma: f64,
|
||||
pub ordinal: f64,
|
||||
}
|
||||
|
||||
impl From<Team> for TeamWithStats {
|
||||
fn from(team: Team) -> Self {
|
||||
Self {
|
||||
id: team.id,
|
||||
date_created: team.date_created,
|
||||
name: team.name,
|
||||
logo: team.logo,
|
||||
bio: team.bio,
|
||||
rank: team.rank,
|
||||
mmr: team.mmr as f64,
|
||||
mu: team.mu.unwrap_or(25.0) as f64,
|
||||
sigma: team.sigma.unwrap_or(8.333333) as f64,
|
||||
ordinal: team.ordinal.unwrap_or(0.0) as f64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create team request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTeam {
|
||||
pub name: String,
|
||||
pub bio: Option<String>,
|
||||
}
|
||||
|
||||
/// Update team request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateTeam {
|
||||
pub name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub logo: Option<String>,
|
||||
}
|
||||
|
||||
/// Team member info
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TeamMember {
|
||||
pub player_id: i32,
|
||||
pub username: String,
|
||||
pub firstname: String,
|
||||
pub lastname: String,
|
||||
pub profile: Option<String>,
|
||||
pub position: String,
|
||||
pub status: i32,
|
||||
pub mu: f64,
|
||||
pub sigma: f64,
|
||||
pub ordinal: f64,
|
||||
pub mmr: f64,
|
||||
}
|
||||
71
src/models/team_player.rs
Normal file
71
src/models/team_player.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Team player positions
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PlayerPosition {
|
||||
Captain,
|
||||
#[serde(rename = "co-captain")]
|
||||
CoCaptain,
|
||||
Member,
|
||||
}
|
||||
|
||||
impl PlayerPosition {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
PlayerPosition::Captain => "captain",
|
||||
PlayerPosition::CoCaptain => "co-captain",
|
||||
PlayerPosition::Member => "member",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"captain" => Some(PlayerPosition::Captain),
|
||||
"co-captain" => Some(PlayerPosition::CoCaptain),
|
||||
"member" => Some(PlayerPosition::Member),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Team player status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum TeamPlayerStatus {
|
||||
Pending = 0,
|
||||
Active = 1,
|
||||
}
|
||||
|
||||
impl From<i32> for TeamPlayerStatus {
|
||||
fn from(val: i32) -> Self {
|
||||
match val {
|
||||
1 => TeamPlayerStatus::Active,
|
||||
_ => TeamPlayerStatus::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Team player model representing the team_players table
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct TeamPlayer {
|
||||
pub id: i32,
|
||||
pub date_created: DateTime<Utc>,
|
||||
pub team_id: i32,
|
||||
pub player_id: i32,
|
||||
pub position: String,
|
||||
pub status: i32,
|
||||
}
|
||||
|
||||
/// Join team request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct JoinTeamRequest {
|
||||
pub team_id: i32,
|
||||
}
|
||||
|
||||
/// Change position request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChangePositionRequest {
|
||||
pub position: String,
|
||||
}
|
||||
93
src/models/user.rs
Normal file
93
src/models/user.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// User model representing the users table
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct User {
|
||||
pub id: i32,
|
||||
pub date_registered: DateTime<Utc>,
|
||||
pub firstname: String,
|
||||
pub lastname: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub password: String,
|
||||
pub is_admin: bool,
|
||||
pub profile: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
pub password_reset_token: Option<String>,
|
||||
// OpenSkill rating fields
|
||||
pub mu: Option<f32>,
|
||||
pub sigma: Option<f32>,
|
||||
pub ordinal: Option<f32>,
|
||||
pub mmr: Option<f32>,
|
||||
}
|
||||
|
||||
/// User public profile (without sensitive data)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UserProfile {
|
||||
pub id: i32,
|
||||
pub date_registered: DateTime<Utc>,
|
||||
pub firstname: String,
|
||||
pub lastname: String,
|
||||
pub username: String,
|
||||
pub profile: Option<String>,
|
||||
pub mu: f64,
|
||||
pub sigma: f64,
|
||||
pub ordinal: f64,
|
||||
pub mmr: f64,
|
||||
}
|
||||
|
||||
impl From<User> for UserProfile {
|
||||
fn from(user: User) -> Self {
|
||||
Self {
|
||||
id: user.id,
|
||||
date_registered: user.date_registered,
|
||||
firstname: user.firstname,
|
||||
lastname: user.lastname,
|
||||
username: user.username,
|
||||
profile: user.profile,
|
||||
mu: user.mu.unwrap_or(25.0) as f64,
|
||||
sigma: user.sigma.unwrap_or(8.333333) as f64,
|
||||
ordinal: user.ordinal.unwrap_or(0.0) as f64,
|
||||
mmr: user.mmr.unwrap_or(1500.0) as f64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create user request (internal use)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUser {
|
||||
pub firstname: String,
|
||||
pub lastname: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Update user request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateUser {
|
||||
pub firstname: Option<String>,
|
||||
pub lastname: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub profile: Option<String>,
|
||||
}
|
||||
|
||||
/// Player stats response
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PlayerStats {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub firstname: String,
|
||||
pub lastname: String,
|
||||
pub profile: Option<String>,
|
||||
pub mu: f64,
|
||||
pub sigma: f64,
|
||||
pub ordinal: f64,
|
||||
pub mmr: f64,
|
||||
pub team_id: Option<i32>,
|
||||
pub team_name: Option<String>,
|
||||
pub position: Option<String>,
|
||||
}
|
||||
37
src/services/email.rs
Normal file
37
src/services/email.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
// Email service - to be implemented in Phase 10
|
||||
// Placeholder module
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// Email service for sending notifications
|
||||
pub struct EmailService {
|
||||
_config: Config,
|
||||
}
|
||||
|
||||
impl EmailService {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self { _config: config }
|
||||
}
|
||||
|
||||
/// Send a challenge notification email
|
||||
pub async fn send_challenge_notification(
|
||||
&self,
|
||||
_to_email: &str,
|
||||
_from_team_name: &str,
|
||||
) -> Result<(), String> {
|
||||
// TODO: Implement email sending
|
||||
tracing::info!("Email service: challenge notification (not implemented)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a password reset email
|
||||
pub async fn send_password_reset(
|
||||
&self,
|
||||
_to_email: &str,
|
||||
_reset_token: &str,
|
||||
) -> Result<(), String> {
|
||||
// TODO: Implement email sending
|
||||
tracing::info!("Email service: password reset (not implemented)");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
8
src/services/mod.rs
Normal file
8
src/services/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
// Services module - business logic layer
|
||||
// These will be implemented as we build out each feature phase
|
||||
|
||||
pub mod email;
|
||||
pub mod rating;
|
||||
pub mod storage;
|
||||
|
||||
// Re-exports will be added as services are implemented
|
||||
454
src/services/rating.rs
Normal file
454
src/services/rating.rs
Normal file
@@ -0,0 +1,454 @@
|
||||
//! Rating Service - OpenSkill-compatible Bayesian rating system
|
||||
//!
|
||||
//! This implements a rating system compatible with the PHP app's OpenSkill algorithm.
|
||||
//! Uses mu (mean skill) and sigma (uncertainty) with ordinal = mu - 3*sigma for display.
|
||||
|
||||
use sqlx::PgPool;
|
||||
use crate::error::Result;
|
||||
|
||||
/// Default mu (mean skill) for new players/teams
|
||||
pub const DEFAULT_MU: f64 = 25.0;
|
||||
|
||||
/// Default sigma (uncertainty) for new players/teams
|
||||
pub const DEFAULT_SIGMA: f64 = 25.0 / 3.0; // ~8.333
|
||||
|
||||
/// Beta squared - variance of performance around skill (default: sigma/2)
|
||||
pub const BETA_SQ: f64 = (DEFAULT_SIGMA / 2.0) * (DEFAULT_SIGMA / 2.0);
|
||||
|
||||
/// Tau squared - additive dynamics factor (how much sigma can increase over time)
|
||||
pub const TAU_SQ: f64 = (DEFAULT_SIGMA / 100.0) * (DEFAULT_SIGMA / 100.0);
|
||||
|
||||
/// Minimum sigma to prevent over-confidence
|
||||
pub const MIN_SIGMA: f64 = 0.01;
|
||||
|
||||
/// Calculate ordinal rating from mu and sigma
|
||||
/// This is the "display" rating: mu - 3*sigma
|
||||
pub fn ordinal(mu: f64, sigma: f64) -> f64 {
|
||||
mu - 3.0 * sigma
|
||||
}
|
||||
|
||||
/// Convert ordinal to a more user-friendly MMR scale (0-3000)
|
||||
/// Maps ordinal range roughly -25 to 75 -> 0 to 3000
|
||||
pub fn ordinal_to_mmr(ordinal: f64) -> f64 {
|
||||
// Center around 1000 MMR at ordinal 0
|
||||
// Scale so that 1 ordinal point = ~30 MMR
|
||||
(1000.0 + ordinal * 30.0).clamp(0.0, 3000.0)
|
||||
}
|
||||
|
||||
/// Rating for a single entity (player or team)
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Rating {
|
||||
pub mu: f64,
|
||||
pub sigma: f64,
|
||||
}
|
||||
|
||||
impl Rating {
|
||||
pub fn new(mu: f64, sigma: f64) -> Self {
|
||||
Self { mu, sigma }
|
||||
}
|
||||
|
||||
pub fn default_rating() -> Self {
|
||||
Self {
|
||||
mu: DEFAULT_MU,
|
||||
sigma: DEFAULT_SIGMA,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ordinal(&self) -> f64 {
|
||||
ordinal(self.mu, self.sigma)
|
||||
}
|
||||
|
||||
pub fn mmr(&self) -> f64 {
|
||||
ordinal_to_mmr(self.ordinal())
|
||||
}
|
||||
|
||||
/// Variance (sigma squared)
|
||||
pub fn variance(&self) -> f64 {
|
||||
self.sigma * self.sigma
|
||||
}
|
||||
}
|
||||
|
||||
/// Rating update result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RatingUpdate {
|
||||
pub entity_id: i32,
|
||||
pub old_mu: f64,
|
||||
pub old_sigma: f64,
|
||||
pub new_mu: f64,
|
||||
pub new_sigma: f64,
|
||||
}
|
||||
|
||||
impl RatingUpdate {
|
||||
pub fn old_ordinal(&self) -> f64 {
|
||||
ordinal(self.old_mu, self.old_sigma)
|
||||
}
|
||||
|
||||
pub fn new_ordinal(&self) -> f64 {
|
||||
ordinal(self.new_mu, self.new_sigma)
|
||||
}
|
||||
|
||||
pub fn ordinal_change(&self) -> f64 {
|
||||
self.new_ordinal() - self.old_ordinal()
|
||||
}
|
||||
|
||||
pub fn old_mmr(&self) -> f64 {
|
||||
ordinal_to_mmr(self.old_ordinal())
|
||||
}
|
||||
|
||||
pub fn new_mmr(&self) -> f64 {
|
||||
ordinal_to_mmr(self.new_ordinal())
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard normal CDF approximation
|
||||
fn phi(x: f64) -> f64 {
|
||||
0.5 * (1.0 + libm::erf(x / std::f64::consts::SQRT_2))
|
||||
}
|
||||
|
||||
/// Standard normal PDF
|
||||
fn phi_prime(x: f64) -> f64 {
|
||||
(-x * x / 2.0).exp() / (2.0 * std::f64::consts::PI).sqrt()
|
||||
}
|
||||
|
||||
/// V function for TrueSkill-like updates (truncated Gaussian)
|
||||
fn v_func(t: f64, epsilon: f64) -> f64 {
|
||||
let denom = phi(t - epsilon);
|
||||
if denom < 1e-10 {
|
||||
-t + epsilon
|
||||
} else {
|
||||
phi_prime(t - epsilon) / denom
|
||||
}
|
||||
}
|
||||
|
||||
/// W function for TrueSkill-like updates (variance correction)
|
||||
fn w_func(t: f64, epsilon: f64) -> f64 {
|
||||
let v = v_func(t, epsilon);
|
||||
v * (v + t - epsilon)
|
||||
}
|
||||
|
||||
/// Calculate new ratings after a 1v1 match (team vs team)
|
||||
///
|
||||
/// This uses a simplified Bradley-Terry model with Gaussian priors,
|
||||
/// similar to TrueSkill/OpenSkill but without draw support.
|
||||
pub fn rate_1v1(winner: Rating, loser: Rating) -> (Rating, Rating) {
|
||||
// Combined variance
|
||||
let c_sq = winner.variance() + loser.variance() + 2.0 * BETA_SQ;
|
||||
let c = c_sq.sqrt();
|
||||
|
||||
// Performance difference (winner expected to have higher performance)
|
||||
let delta_mu = winner.mu - loser.mu;
|
||||
let t = delta_mu / c;
|
||||
|
||||
// Draw margin (epsilon) - set to 0 for no-draw games
|
||||
let epsilon = 0.0;
|
||||
|
||||
// Update factors
|
||||
let v = v_func(t, epsilon);
|
||||
let w = w_func(t, epsilon);
|
||||
|
||||
// Winner update
|
||||
let winner_mu_factor = winner.variance() / c;
|
||||
let winner_sigma_factor = winner.variance() / c_sq;
|
||||
let new_winner_mu = winner.mu + winner_mu_factor * v;
|
||||
let new_winner_sigma = (winner.variance() * (1.0 - w * winner_sigma_factor) + TAU_SQ)
|
||||
.sqrt()
|
||||
.max(MIN_SIGMA);
|
||||
|
||||
// Loser update (mirror of winner)
|
||||
let loser_mu_factor = loser.variance() / c;
|
||||
let loser_sigma_factor = loser.variance() / c_sq;
|
||||
let new_loser_mu = loser.mu - loser_mu_factor * v;
|
||||
let new_loser_sigma = (loser.variance() * (1.0 - w * loser_sigma_factor) + TAU_SQ)
|
||||
.sqrt()
|
||||
.max(MIN_SIGMA);
|
||||
|
||||
(
|
||||
Rating::new(new_winner_mu, new_winner_sigma),
|
||||
Rating::new(new_loser_mu, new_loser_sigma),
|
||||
)
|
||||
}
|
||||
|
||||
/// Rate multiple players on a team (average their ratings for team performance)
|
||||
pub fn team_rating(players: &[Rating]) -> Rating {
|
||||
if players.is_empty() {
|
||||
return Rating::default_rating();
|
||||
}
|
||||
|
||||
let mu_sum: f64 = players.iter().map(|p| p.mu).sum();
|
||||
let sigma_sq_sum: f64 = players.iter().map(|p| p.variance()).sum();
|
||||
|
||||
Rating::new(
|
||||
mu_sum / players.len() as f64,
|
||||
(sigma_sq_sum / players.len() as f64).sqrt(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Apply rating updates to teams in the database
|
||||
pub async fn apply_team_rating_updates(
|
||||
pool: &PgPool,
|
||||
winner_id: i32,
|
||||
winner_rating: &Rating,
|
||||
loser_id: i32,
|
||||
loser_rating: &Rating,
|
||||
) -> Result<()> {
|
||||
let winner_ordinal = winner_rating.ordinal();
|
||||
let winner_mmr = winner_rating.mmr();
|
||||
let loser_ordinal = loser_rating.ordinal();
|
||||
let loser_mmr = loser_rating.mmr();
|
||||
|
||||
// Update winner
|
||||
sqlx::query(
|
||||
"UPDATE teams SET mu = $1, sigma = $2, ordinal = $3, mmr = $4 WHERE id = $5"
|
||||
)
|
||||
.bind(winner_rating.mu)
|
||||
.bind(winner_rating.sigma)
|
||||
.bind(winner_ordinal)
|
||||
.bind(winner_mmr as f32)
|
||||
.bind(winner_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Update loser
|
||||
sqlx::query(
|
||||
"UPDATE teams SET mu = $1, sigma = $2, ordinal = $3, mmr = $4 WHERE id = $5"
|
||||
)
|
||||
.bind(loser_rating.mu)
|
||||
.bind(loser_rating.sigma)
|
||||
.bind(loser_ordinal)
|
||||
.bind(loser_mmr as f32)
|
||||
.bind(loser_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
"Team rating updated: {} ({:.2} -> {:.2} ordinal, {:.0} MMR) vs {} ({:.2} -> {:.2} ordinal, {:.0} MMR)",
|
||||
winner_id, winner_rating.mu - 3.0 * DEFAULT_SIGMA, winner_ordinal, winner_mmr,
|
||||
loser_id, loser_rating.mu - 3.0 * DEFAULT_SIGMA, loser_ordinal, loser_mmr,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply rating updates to players in the database
|
||||
pub async fn apply_player_rating_updates(
|
||||
pool: &PgPool,
|
||||
player_updates: &[(i32, Rating)],
|
||||
) -> Result<()> {
|
||||
for (player_id, rating) in player_updates {
|
||||
let player_ordinal = rating.ordinal();
|
||||
let player_mmr = rating.mmr();
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE users SET mu = $1, sigma = $2, ordinal = $3, mmr = $4 WHERE id = $5"
|
||||
)
|
||||
.bind(rating.mu)
|
||||
.bind(rating.sigma)
|
||||
.bind(player_ordinal)
|
||||
.bind(player_mmr as f32)
|
||||
.bind(player_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update team rankings based on ordinal rating
|
||||
pub async fn update_team_rankings(pool: &PgPool) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
WITH ranked_teams AS (
|
||||
SELECT id, ROW_NUMBER() OVER (ORDER BY ordinal DESC) as new_rank
|
||||
FROM teams
|
||||
WHERE is_delete = false
|
||||
)
|
||||
UPDATE teams
|
||||
SET rank = ranked_teams.new_rank
|
||||
FROM ranked_teams
|
||||
WHERE teams.id = ranked_teams.id
|
||||
"#
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
tracing::debug!("Team rankings updated by ordinal");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process a completed match and update ratings for teams and players
|
||||
pub async fn process_match_result(
|
||||
pool: &PgPool,
|
||||
team1_id: i32,
|
||||
team1_score: i32,
|
||||
team2_id: i32,
|
||||
team2_score: i32,
|
||||
) -> Result<(RatingUpdate, RatingUpdate)> {
|
||||
// Determine winner/loser
|
||||
let (winner_id, loser_id) = if team1_score > team2_score {
|
||||
(team1_id, team2_id)
|
||||
} else {
|
||||
(team2_id, team1_id)
|
||||
};
|
||||
|
||||
// Get current team ratings (cast to FLOAT8 for f64 binding)
|
||||
let winner_data: (f64, f64) = sqlx::query_as(
|
||||
"SELECT COALESCE(mu::float8, 25.0), COALESCE(sigma::float8, 8.333) FROM teams WHERE id = $1"
|
||||
)
|
||||
.bind(winner_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let loser_data: (f64, f64) = sqlx::query_as(
|
||||
"SELECT COALESCE(mu::float8, 25.0), COALESCE(sigma::float8, 8.333) FROM teams WHERE id = $1"
|
||||
)
|
||||
.bind(loser_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let winner_rating = Rating::new(winner_data.0, winner_data.1);
|
||||
let loser_rating = Rating::new(loser_data.0, loser_data.1);
|
||||
|
||||
// Calculate new ratings
|
||||
let (new_winner_rating, new_loser_rating) = rate_1v1(winner_rating, loser_rating);
|
||||
|
||||
// Apply team updates
|
||||
apply_team_rating_updates(pool, winner_id, &new_winner_rating, loser_id, &new_loser_rating).await?;
|
||||
|
||||
// Get and update player ratings for both teams
|
||||
let winner_players: Vec<(i32, f64, f64)> = sqlx::query_as(
|
||||
"SELECT u.id, COALESCE(u.mu::float8, 25.0), COALESCE(u.sigma::float8, 8.333)
|
||||
FROM users u
|
||||
JOIN team_players tp ON u.id = tp.player_id
|
||||
WHERE tp.team_id = $1 AND tp.status = 1"
|
||||
)
|
||||
.bind(winner_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let loser_players: Vec<(i32, f64, f64)> = sqlx::query_as(
|
||||
"SELECT u.id, COALESCE(u.mu::float8, 25.0), COALESCE(u.sigma::float8, 8.333)
|
||||
FROM users u
|
||||
JOIN team_players tp ON u.id = tp.player_id
|
||||
WHERE tp.team_id = $1 AND tp.status = 1"
|
||||
)
|
||||
.bind(loser_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
// Update individual player ratings
|
||||
let mut player_updates = Vec::new();
|
||||
|
||||
// Calculate average ratings for team-vs-player comparisons
|
||||
let avg_loser = team_rating(&loser_players.iter().map(|(_, m, s)| Rating::new(*m, *s)).collect::<Vec<_>>());
|
||||
let avg_winner = team_rating(&winner_players.iter().map(|(_, m, s)| Rating::new(*m, *s)).collect::<Vec<_>>());
|
||||
|
||||
for (player_id, mu, sigma) in &winner_players {
|
||||
let player_rating = Rating::new(*mu, *sigma);
|
||||
// Simulate 1v1 against average loser
|
||||
let (new_rating, _) = rate_1v1(player_rating, avg_loser);
|
||||
player_updates.push((*player_id, new_rating));
|
||||
}
|
||||
|
||||
for (player_id, mu, sigma) in &loser_players {
|
||||
let player_rating = Rating::new(*mu, *sigma);
|
||||
// Simulate 1v1 against average winner
|
||||
let (_, new_rating) = rate_1v1(avg_winner, player_rating);
|
||||
player_updates.push((*player_id, new_rating));
|
||||
}
|
||||
|
||||
apply_player_rating_updates(pool, &player_updates).await?;
|
||||
|
||||
// Update rankings
|
||||
update_team_rankings(pool).await?;
|
||||
|
||||
let winner_update = RatingUpdate {
|
||||
entity_id: winner_id,
|
||||
old_mu: winner_rating.mu,
|
||||
old_sigma: winner_rating.sigma,
|
||||
new_mu: new_winner_rating.mu,
|
||||
new_sigma: new_winner_rating.sigma,
|
||||
};
|
||||
|
||||
let loser_update = RatingUpdate {
|
||||
entity_id: loser_id,
|
||||
old_mu: loser_rating.mu,
|
||||
old_sigma: loser_rating.sigma,
|
||||
new_mu: new_loser_rating.mu,
|
||||
new_sigma: new_loser_rating.sigma,
|
||||
};
|
||||
|
||||
Ok((winner_update, loser_update))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_rating() {
|
||||
let r = Rating::default_rating();
|
||||
assert!((r.mu - 25.0).abs() < 0.01);
|
||||
assert!((r.sigma - 8.333).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ordinal() {
|
||||
let r = Rating::default_rating();
|
||||
// ordinal = 25 - 3*8.333 = 25 - 25 = 0
|
||||
assert!((r.ordinal() - 0.0).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ordinal_to_mmr() {
|
||||
// ordinal 0 -> MMR 1000
|
||||
assert!((ordinal_to_mmr(0.0) - 1000.0).abs() < 0.1);
|
||||
// ordinal 10 -> MMR 1300
|
||||
assert!((ordinal_to_mmr(10.0) - 1300.0).abs() < 0.1);
|
||||
// ordinal -10 -> MMR 700
|
||||
assert!((ordinal_to_mmr(-10.0) - 700.0).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_1v1_equal_ratings() {
|
||||
let r1 = Rating::default_rating();
|
||||
let r2 = Rating::default_rating();
|
||||
|
||||
let (winner, loser) = rate_1v1(r1, r2);
|
||||
|
||||
// Winner should gain, loser should lose
|
||||
assert!(winner.mu > r1.mu);
|
||||
assert!(loser.mu < r2.mu);
|
||||
|
||||
// Both should have reduced uncertainty
|
||||
assert!(winner.sigma < r1.sigma);
|
||||
assert!(loser.sigma < r2.sigma);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_1v1_upset() {
|
||||
// Lower rated player beats higher rated
|
||||
let underdog = Rating::new(20.0, 8.0);
|
||||
let favorite = Rating::new(30.0, 6.0);
|
||||
|
||||
let (winner, loser) = rate_1v1(underdog, favorite);
|
||||
|
||||
// Underdog wins: big mu gain
|
||||
assert!(winner.mu > underdog.mu);
|
||||
// Favorite loses: big mu loss
|
||||
assert!(loser.mu < favorite.mu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_team_rating() {
|
||||
let players = vec![
|
||||
Rating::new(25.0, 8.0),
|
||||
Rating::new(30.0, 6.0),
|
||||
Rating::new(20.0, 10.0),
|
||||
];
|
||||
|
||||
let team = team_rating(&players);
|
||||
|
||||
// Average mu = (25 + 30 + 20) / 3 = 25
|
||||
assert!((team.mu - 25.0).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
311
src/services/storage.rs
Normal file
311
src/services/storage.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
//! Storage Service - S3-compatible file storage
|
||||
//!
|
||||
//! Handles file uploads for team logos, player profiles, and ladder images.
|
||||
//! Supports both local development (filesystem) and production (S3).
|
||||
|
||||
use aws_config::BehaviorVersion;
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
/// Supported upload types/folders
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum UploadType {
|
||||
TeamLogo,
|
||||
PlayerProfile,
|
||||
LadderLogo,
|
||||
}
|
||||
|
||||
impl UploadType {
|
||||
pub fn folder(&self) -> &'static str {
|
||||
match self {
|
||||
UploadType::TeamLogo => "teams",
|
||||
UploadType::PlayerProfile => "profile",
|
||||
UploadType::LadderLogo => "ladders",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_size(&self) -> usize {
|
||||
match self {
|
||||
UploadType::TeamLogo => 5 * 1024 * 1024, // 5MB
|
||||
UploadType::PlayerProfile => 2 * 1024 * 1024, // 2MB
|
||||
UploadType::LadderLogo => 5 * 1024 * 1024, // 5MB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allowed content types for uploads
|
||||
const ALLOWED_CONTENT_TYPES: &[&str] = &[
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
|
||||
/// Storage service for file uploads
|
||||
#[derive(Clone)]
|
||||
pub struct StorageService {
|
||||
s3_client: Option<S3Client>,
|
||||
bucket_name: Option<String>,
|
||||
local_path: PathBuf,
|
||||
base_url: String,
|
||||
use_s3: bool,
|
||||
}
|
||||
|
||||
impl StorageService {
|
||||
/// Create a new storage service from config
|
||||
pub async fn new(config: &Config) -> Self {
|
||||
let use_s3 = config.s3_bucket.is_some()
|
||||
&& config.s3_region.is_some()
|
||||
&& !config.s3_bucket.as_ref().unwrap().is_empty();
|
||||
|
||||
let s3_client = if use_s3 {
|
||||
// Configure S3 client
|
||||
let sdk_config = aws_config::defaults(BehaviorVersion::latest())
|
||||
.region(aws_sdk_s3::config::Region::new(
|
||||
config.s3_region.clone().unwrap_or_else(|| "us-east-1".to_string())
|
||||
))
|
||||
.load()
|
||||
.await;
|
||||
|
||||
Some(S3Client::new(&sdk_config))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let bucket_name = config.s3_bucket.clone();
|
||||
let local_path = PathBuf::from(config.upload_path.clone().unwrap_or_else(|| "./uploads".to_string()));
|
||||
let base_url = config.base_url.clone().unwrap_or_else(|| "http://localhost:3000".to_string());
|
||||
|
||||
// Ensure local upload directories exist
|
||||
if !use_s3 {
|
||||
for upload_type in [UploadType::TeamLogo, UploadType::PlayerProfile, UploadType::LadderLogo] {
|
||||
let dir = local_path.join(upload_type.folder());
|
||||
if let Err(e) = fs::create_dir_all(&dir).await {
|
||||
tracing::warn!("Failed to create upload directory {:?}: {}", dir, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
s3_client,
|
||||
bucket_name,
|
||||
local_path,
|
||||
base_url,
|
||||
use_s3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate file before upload
|
||||
fn validate_file(&self, content_type: &str, size: usize, upload_type: UploadType) -> Result<()> {
|
||||
// Check content type
|
||||
if !ALLOWED_CONTENT_TYPES.contains(&content_type) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Invalid file type '{}'. Allowed types: {:?}",
|
||||
content_type, ALLOWED_CONTENT_TYPES
|
||||
)));
|
||||
}
|
||||
|
||||
// Check size
|
||||
let max_size = upload_type.max_size();
|
||||
if size > max_size {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"File too large. Maximum size is {} bytes",
|
||||
max_size
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a unique filename
|
||||
fn generate_filename(&self, original_name: &str, content_type: &str) -> String {
|
||||
let extension = match content_type {
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
"image/png" => "png",
|
||||
"image/gif" => "gif",
|
||||
"image/webp" => "webp",
|
||||
_ => {
|
||||
// Try to extract from original filename
|
||||
original_name
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or("jpg")
|
||||
}
|
||||
};
|
||||
|
||||
let timestamp = chrono::Utc::now().timestamp();
|
||||
let uuid = Uuid::new_v4().to_string()[..8].to_string();
|
||||
|
||||
format!("{}_{}.{}", timestamp, uuid, extension)
|
||||
}
|
||||
|
||||
/// Upload a file and return the URL
|
||||
pub async fn upload_file(
|
||||
&self,
|
||||
file_data: &[u8],
|
||||
original_name: &str,
|
||||
content_type: &str,
|
||||
upload_type: UploadType,
|
||||
) -> Result<String> {
|
||||
// Validate
|
||||
self.validate_file(content_type, file_data.len(), upload_type)?;
|
||||
|
||||
// Generate filename
|
||||
let filename = self.generate_filename(original_name, content_type);
|
||||
let folder = upload_type.folder();
|
||||
|
||||
if self.use_s3 {
|
||||
self.upload_to_s3(file_data, &filename, content_type, folder).await
|
||||
} else {
|
||||
self.upload_to_local(file_data, &filename, folder).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload to S3
|
||||
async fn upload_to_s3(
|
||||
&self,
|
||||
file_data: &[u8],
|
||||
filename: &str,
|
||||
content_type: &str,
|
||||
folder: &str,
|
||||
) -> Result<String> {
|
||||
let client = self.s3_client.as_ref().ok_or_else(|| {
|
||||
AppError::Internal("S3 client not configured".to_string())
|
||||
})?;
|
||||
|
||||
let bucket = self.bucket_name.as_ref().ok_or_else(|| {
|
||||
AppError::Internal("S3 bucket not configured".to_string())
|
||||
})?;
|
||||
|
||||
let key = format!("uploads/{}/{}", folder, filename);
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.body(ByteStream::from(file_data.to_vec()))
|
||||
.content_type(content_type)
|
||||
.acl(aws_sdk_s3::types::ObjectCannedAcl::PublicRead)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("S3 upload failed: {}", e)))?;
|
||||
|
||||
// Return S3 URL
|
||||
let url = format!("https://{}.s3.amazonaws.com/{}", bucket, key);
|
||||
|
||||
tracing::info!("Uploaded file to S3: {}", url);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// Upload to local filesystem
|
||||
async fn upload_to_local(
|
||||
&self,
|
||||
file_data: &[u8],
|
||||
filename: &str,
|
||||
folder: &str,
|
||||
) -> Result<String> {
|
||||
let dir = self.local_path.join(folder);
|
||||
|
||||
// Ensure directory exists
|
||||
fs::create_dir_all(&dir).await.map_err(|e| {
|
||||
AppError::Internal(format!("Failed to create directory: {}", e))
|
||||
})?;
|
||||
|
||||
let file_path = dir.join(filename);
|
||||
|
||||
// Write file
|
||||
let mut file = fs::File::create(&file_path).await.map_err(|e| {
|
||||
AppError::Internal(format!("Failed to create file: {}", e))
|
||||
})?;
|
||||
|
||||
file.write_all(file_data).await.map_err(|e| {
|
||||
AppError::Internal(format!("Failed to write file: {}", e))
|
||||
})?;
|
||||
|
||||
// Return local URL
|
||||
let url = format!("{}/uploads/{}/{}", self.base_url, folder, filename);
|
||||
|
||||
tracing::info!("Uploaded file locally: {}", url);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// Delete a file by URL
|
||||
pub async fn delete_file(&self, file_url: &str) -> Result<()> {
|
||||
if self.use_s3 {
|
||||
self.delete_from_s3(file_url).await
|
||||
} else {
|
||||
self.delete_from_local(file_url).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete from S3
|
||||
async fn delete_from_s3(&self, file_url: &str) -> Result<()> {
|
||||
let client = self.s3_client.as_ref().ok_or_else(|| {
|
||||
AppError::Internal("S3 client not configured".to_string())
|
||||
})?;
|
||||
|
||||
let bucket = self.bucket_name.as_ref().ok_or_else(|| {
|
||||
AppError::Internal("S3 bucket not configured".to_string())
|
||||
})?;
|
||||
|
||||
// Extract key from URL
|
||||
// URL format: https://bucket.s3.amazonaws.com/uploads/folder/filename
|
||||
let key = file_url
|
||||
.split(&format!("{}.s3.amazonaws.com/", bucket))
|
||||
.nth(1)
|
||||
.ok_or_else(|| AppError::BadRequest("Invalid S3 URL".to_string()))?;
|
||||
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("S3 delete failed: {}", e)))?;
|
||||
|
||||
tracing::info!("Deleted file from S3: {}", key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete from local filesystem
|
||||
async fn delete_from_local(&self, file_url: &str) -> Result<()> {
|
||||
// Extract path from URL
|
||||
// URL format: http://localhost:3000/uploads/folder/filename
|
||||
let path_part = file_url
|
||||
.split("/uploads/")
|
||||
.nth(1)
|
||||
.ok_or_else(|| AppError::BadRequest("Invalid file URL".to_string()))?;
|
||||
|
||||
let file_path = self.local_path.join(path_part);
|
||||
|
||||
if file_path.exists() {
|
||||
fs::remove_file(&file_path).await.map_err(|e| {
|
||||
AppError::Internal(format!("Failed to delete file: {}", e))
|
||||
})?;
|
||||
|
||||
tracing::info!("Deleted local file: {:?}", file_path);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if storage is using S3
|
||||
pub fn is_s3_enabled(&self) -> bool {
|
||||
self.use_s3
|
||||
}
|
||||
}
|
||||
|
||||
/// Default fallback image URLs
|
||||
pub mod defaults {
|
||||
pub const TEAM_LOGO: &str = "no-image.jpg";
|
||||
pub const PLAYER_PROFILE: &str = "no-image.jpg";
|
||||
pub const LADDER_LOGO: &str = "default_ladder_profile.png";
|
||||
}
|
||||
BIN
uploads/profile/1768884258_3fa8e1d5.png
Normal file
BIN
uploads/profile/1768884258_3fa8e1d5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
Reference in New Issue
Block a user