Initial commit: VRBattles API

This commit is contained in:
root
2026-01-20 05:41:25 +00:00
commit c26a1820d5
42 changed files with 6187 additions and 0 deletions

27
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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);

View 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));

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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(())
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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";
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B