From c26a1820d5491ee14f297667eeece4e3a63d1f1c Mon Sep 17 00:00:00 2001 From: root Date: Tue, 20 Jan 2026 05:41:25 +0000 Subject: [PATCH] Initial commit: VRBattles API --- .gitignore | 27 + Cargo.toml | 65 ++ DEPLOY.md | 138 +++ Dockerfile | 60 ++ docker-compose.prod.yml | 93 ++ docker-compose.yml | 40 + env.example | 55 ++ migrations/20260119000001_initial_schema.sql | 119 +++ .../20260119000003_seed_original_data.sql | 88 ++ .../20260120000001_add_openskill_fields.sql | 22 + src/auth/jwt.rs | 91 ++ src/auth/middleware.rs | 121 +++ src/auth/mod.rs | 7 + src/auth/password.rs | 100 ++ src/config.rs | 132 +++ src/db/mod.rs | 33 + src/error.rs | 135 +++ src/handlers/auth.rs | 182 ++++ src/handlers/health.rs | 35 + src/handlers/ladders.rs | 596 ++++++++++++ src/handlers/matches.rs | 891 ++++++++++++++++++ src/handlers/mod.rs | 30 + src/handlers/players.rs | 669 +++++++++++++ src/handlers/teams.rs | 751 +++++++++++++++ src/handlers/uploads.rs | 217 +++++ src/handlers/users.rs | 54 ++ src/lib.rs | 16 + src/main.rs | 146 +++ src/models/featured_player.rs | 24 + src/models/ladder.rs | 50 + src/models/ladder_team.rs | 18 + src/models/match_model.rs | 75 ++ src/models/match_round.rs | 42 + src/models/mod.rs | 8 + src/models/team.rs | 83 ++ src/models/team_player.rs | 71 ++ src/models/user.rs | 93 ++ src/services/email.rs | 37 + src/services/mod.rs | 8 + src/services/rating.rs | 454 +++++++++ src/services/storage.rs | 311 ++++++ uploads/profile/1768884258_3fa8e1d5.png | Bin 0 -> 70 bytes 42 files changed, 6187 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 DEPLOY.md create mode 100644 Dockerfile create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 env.example create mode 100644 migrations/20260119000001_initial_schema.sql create mode 100644 migrations/20260119000003_seed_original_data.sql create mode 100644 migrations/20260120000001_add_openskill_fields.sql create mode 100644 src/auth/jwt.rs create mode 100644 src/auth/middleware.rs create mode 100644 src/auth/mod.rs create mode 100644 src/auth/password.rs create mode 100644 src/config.rs create mode 100644 src/db/mod.rs create mode 100644 src/error.rs create mode 100644 src/handlers/auth.rs create mode 100644 src/handlers/health.rs create mode 100644 src/handlers/ladders.rs create mode 100644 src/handlers/matches.rs create mode 100644 src/handlers/mod.rs create mode 100644 src/handlers/players.rs create mode 100644 src/handlers/teams.rs create mode 100644 src/handlers/uploads.rs create mode 100644 src/handlers/users.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/models/featured_player.rs create mode 100644 src/models/ladder.rs create mode 100644 src/models/ladder_team.rs create mode 100644 src/models/match_model.rs create mode 100644 src/models/match_round.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/team.rs create mode 100644 src/models/team_player.rs create mode 100644 src/models/user.rs create mode 100644 src/services/email.rs create mode 100644 src/services/mod.rs create mode 100644 src/services/rating.rs create mode 100644 src/services/storage.rs create mode 100644 uploads/profile/1768884258_3fa8e1d5.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d261a09 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..909302c --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..54b89f3 --- /dev/null +++ b/DEPLOY.md @@ -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 +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2a1f510 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..7aafc4a --- /dev/null +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c09717f --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/env.example b/env.example new file mode 100644 index 0000000..f99fcee --- /dev/null +++ b/env.example @@ -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 diff --git a/migrations/20260119000001_initial_schema.sql b/migrations/20260119000001_initial_schema.sql new file mode 100644 index 0000000..5f66196 --- /dev/null +++ b/migrations/20260119000001_initial_schema.sql @@ -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); diff --git a/migrations/20260119000003_seed_original_data.sql b/migrations/20260119000003_seed_original_data.sql new file mode 100644 index 0000000..0eec18a --- /dev/null +++ b/migrations/20260119000003_seed_original_data.sql @@ -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)); diff --git a/migrations/20260120000001_add_openskill_fields.sql b/migrations/20260120000001_add_openskill_fields.sql new file mode 100644 index 0000000..38e6a84 --- /dev/null +++ b/migrations/20260120000001_add_openskill_fields.sql @@ -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); diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs new file mode 100644 index 0000000..d35d53d --- /dev/null +++ b/src/auth/jwt.rs @@ -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 { + 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 { + let token_data = decode::( + 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); + } +} diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs new file mode 100644 index 0000000..b2e2f2b --- /dev/null +++ b/src/auth/middleware.rs @@ -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 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 FromRequestParts for AuthUser +where + S: Send + Sync, +{ + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + // Get the JWT secret from extensions (set in app state) + let jwt_secret = parts + .extensions + .get::() + .ok_or_else(|| AppError::Internal("JWT secret not configured".to_string()))? + .0 + .clone(); + + // Extract the Authorization header + let TypedHeader(Authorization(bearer)) = parts + .extract::>>() + .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); + +#[axum::async_trait] +impl FromRequestParts for OptionalAuthUser +where + S: Send + Sync, +{ + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + // 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 FromRequestParts for AdminUser +where + S: Send + Sync, +{ + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + 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)) + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..8fe6778 --- /dev/null +++ b/src/auth/mod.rs @@ -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}; diff --git a/src/auth/password.rs b/src/auth/password.rs new file mode 100644 index 0000000..1376035 --- /dev/null +++ b/src/auth/password.rs @@ -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 { + 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 { + // 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)); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..bedcf9b --- /dev/null +++ b/src/config.rs @@ -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, + /// S3 region (e.g., us-east-1) + pub s3_region: Option, + /// S3 access key ID + pub s3_access_key: Option, + /// S3 secret access key + pub s3_secret_key: Option, + /// S3 endpoint URL (optional, for S3-compatible services) + pub s3_endpoint: Option, + + // Local Storage Configuration + /// Local upload path (when S3 is not configured) + pub upload_path: Option, + /// Base URL for serving uploaded files + pub base_url: Option, + + // Email Configuration + /// SMTP host for email + pub smtp_host: Option, + /// SMTP port + pub smtp_port: Option, + /// SMTP username + pub smtp_username: Option, + /// SMTP password + pub smtp_password: Option, + /// From email address + pub from_email: Option, + + /// Frontend URL for email links + pub frontend_url: String, +} + +impl Config { + /// Load configuration from environment variables + pub fn from_env() -> Result { + 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), +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..a977852 --- /dev/null +++ b/src/db/mod.rs @@ -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 { + 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(()) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..81aa789 --- /dev/null +++ b/src/error.rs @@ -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 = std::result::Result; + +/// 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, +} + +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 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 for AppError { + fn from(err: jsonwebtoken::errors::Error) -> Self { + AppError::Unauthorized(format!("Invalid token: {}", err)) + } +} + +impl From for AppError { + fn from(err: argon2::password_hash::Error) -> Self { + AppError::Internal(format!("Password hashing error: {}", err)) + } +} + +impl From for AppError { + fn from(err: validator::ValidationErrors) -> Self { + AppError::Validation(err.to_string()) + } +} diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs new file mode 100644 index 0000000..0dd6e4d --- /dev/null +++ b/src/handlers/auth.rs @@ -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, +} + +impl From 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, + Json(payload): Json, +) -> Result> { + 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, + Json(payload): Json, +) -> Result<(StatusCode, Json)> { + 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(), + }), + )) +} diff --git a/src/handlers/health.rs b/src/handlers/health.rs new file mode 100644 index 0000000..8fb4b41 --- /dev/null +++ b/src/handlers/health.rs @@ -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) -> (StatusCode, Json) { + // 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)) +} diff --git a/src/handlers/ladders.rs b/src/handlers/ladders.rs new file mode 100644 index 0000000..497d9b4 --- /dev/null +++ b/src/handlers/ladders.rs @@ -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, + pub status: Option, // 0=open, 1=closed + pub page: Option, + pub per_page: Option, +} + +#[derive(Debug, Serialize)] +pub struct LadderListResponse { + pub ladders: Vec, + 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, + pub status: i32, + pub date_start: Option, + pub date_expiration: Option, + pub date_created: chrono::DateTime, + pub created_by_id: i32, + pub team_count: Option, +} + +#[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, +} + +#[derive(Debug, Deserialize, Validate)] +pub struct UpdateLadderRequest { + #[validate(length(min = 2, max = 255, message = "Ladder name must be 2-255 characters"))] + pub name: Option, + pub date_start: Option, + pub date_expiration: Option, + pub status: Option, + pub logo: Option, +} + +#[derive(Debug, Serialize)] +pub struct LadderResponse { + pub id: i32, + pub name: String, + pub logo: Option, + pub status: i32, + pub date_start: Option, + pub date_expiration: Option, + pub date_created: chrono::DateTime, + pub created_by_id: i32, +} + +impl From 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, + pub is_enrolled: bool, + pub user_team_id: Option, +} + +#[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, +} + +#[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, + State(state): State, +) -> Result> { + 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) = 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, + Json(payload): Json, +) -> Result<(StatusCode, Json)> { + // 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, + Path(ladder_id): Path, +) -> Result> { + 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, + Path(ladder_id): Path, + Json(payload): Json, +) -> Result> { + // 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, + Path(ladder_id): Path, +) -> Result { + // 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, + Path(ladder_id): Path, +) -> Result<(StatusCode, Json)> { + // 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, + Path(ladder_id): Path, +) -> Result { + // 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, + Path(ladder_id): Path, +) -> Result>> { + // 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)) +} diff --git a/src/handlers/matches.rs b/src/handlers/matches.rs new file mode 100644 index 0000000..d6cce09 --- /dev/null +++ b/src/handlers/matches.rs @@ -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, + pub ladder_id: Option, + pub status: Option, + pub page: Option, + pub per_page: Option, +} + +#[derive(Debug, Serialize)] +pub struct MatchListResponse { + pub matches: Vec, + 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, + pub date_start: chrono::DateTime, + pub challenge_date: Option>, + 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, + pub date_start: chrono::DateTime, + pub challenge_date: Option>, + 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 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, + pub user_team_id: Option, + pub can_accept: bool, + pub can_report_score: bool, +} + +#[derive(Debug, Serialize, FromRow)] +pub struct MatchRoundResponse { + pub id: i32, + pub date_created: chrono::DateTime, + 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, + State(state): State, +) -> Result> { + 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) = 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, + State(state): State, + Path(match_id): Path, +) -> Result> { + // 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, + Json(payload): Json, +) -> Result<(StatusCode, Json)> { + 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, + Path(match_id): Path, +) -> Result> { + 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, + Path(match_id): Path, +) -> Result> { + 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, + Path(match_id): Path, +) -> Result> { + 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, + Path(match_id): Path, +) -> Result> { + 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, + Path(match_id): Path, + Json(payload): Json, +) -> Result<(StatusCode, Json)> { + 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, + Path((match_id, round_id)): Path<(i32, i32)>, +) -> Result> { + 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, + Path((match_id, round_id)): Path<(i32, i32)>, +) -> Result> { + 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, + State(state): State, +) -> Result> { + 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> { + 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 { + 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) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..a0e8853 --- /dev/null +++ b/src/handlers/mod.rs @@ -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, +}; \ No newline at end of file diff --git a/src/handlers/players.rs b/src/handlers/players.rs new file mode 100644 index 0000000..4aa163e --- /dev/null +++ b/src/handlers/players.rs @@ -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, + pub page: Option, + pub per_page: Option, + pub sort_by: Option, // "mmr", "username", "date_registered" + pub sort_order: Option, // "asc", "desc" +} + +#[derive(Debug, Serialize)] +pub struct PlayerListResponse { + pub players: Vec, + 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, + pub date_registered: chrono::DateTime, + pub team_id: Option, + pub team_name: Option, + pub team_position: Option, +} + +#[derive(Debug, Serialize)] +pub struct PlayerProfileResponse { + pub id: i32, + pub username: String, + pub firstname: String, + pub lastname: String, + pub profile: Option, + pub date_registered: chrono::DateTime, + pub is_admin: bool, + pub mu: f32, + pub sigma: f32, + pub ordinal: f32, + pub mmr: f32, + pub team: Option, + pub stats: PlayerStats, + pub recent_matches: Vec, +} + +#[derive(Debug, Serialize)] +pub struct PlayerTeamInfo { + pub id: i32, + pub name: String, + pub logo: String, + pub position: String, + pub date_joined: chrono::DateTime, +} + +#[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, + 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, +} + +#[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, + pub team_name: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SetFeaturedPlayerRequest { + pub player_id: i32, + pub rank: i32, +} + +#[derive(Debug, Serialize)] +pub struct LeaderboardResponse { + pub players: Vec, + 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, + pub team_name: Option, + pub matches_won: Option, + pub matches_played: Option, +} + +// ============================================================================ +// Handlers +// ============================================================================ + +/// GET /api/players +/// List all players with pagination and search +pub async fn list_players( + Query(query): Query, + State(state): State, +) -> Result> { + 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) = 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, + Path(player_id): Path, +) -> Result> { + // Get basic player info including OpenSkill ratings + let player = sqlx::query_as::<_, (i32, String, String, String, Option, chrono::DateTime, bool, Option, Option, Option, Option)>( + "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)>( + 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, +) -> Result> { + 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, + Json(payload): Json, +) -> Result<(StatusCode, Json)> { + 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, + Path(player_id): Path, +) -> Result { + 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, + State(state): State, +) -> Result> { + 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, +) -> Result { + // Get player's OpenSkill rating + let rating: (Option, Option, Option, Option) = 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> { + 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, + Path(player_id): Path, +) -> Result>> { + 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, + pub match_id: Option, + 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, +} diff --git a/src/handlers/teams.rs b/src/handlers/teams.rs new file mode 100644 index 0000000..fcc13f7 --- /dev/null +++ b/src/handlers/teams.rs @@ -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, + pub page: Option, + pub per_page: Option, +} + +#[derive(Debug, Serialize)] +pub struct TeamListResponse { + pub teams: Vec, + 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, + pub rank: i32, + pub mmr: f32, + pub date_created: chrono::DateTime, + pub member_count: Option, +} + +#[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, +} + +#[derive(Debug, Deserialize, Validate)] +pub struct UpdateTeamRequest { + #[validate(length(min = 2, max = 255, message = "Team name must be 2-255 characters"))] + pub name: Option, + pub bio: Option, +} + +#[derive(Debug, Serialize)] +pub struct TeamResponse { + pub id: i32, + pub name: String, + pub logo: String, + pub bio: Option, + pub rank: i32, + pub mmr: f32, + pub mu: f32, + pub sigma: f32, + pub ordinal: f32, + pub date_created: chrono::DateTime, +} + +impl From 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, + 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, + pub position: String, + pub status: i32, + pub date_joined: chrono::DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct ChangePositionRequest { + pub position: String, +} + +#[derive(Debug, Serialize)] +pub struct MyTeamResponse { + pub team: Option, + pub position: Option, + pub members: Vec, + pub pending_requests: Vec, +} + +// ============================================================================ +// Handlers +// ============================================================================ + +/// GET /api/teams +/// List all teams with pagination and search +pub async fn list_teams( + State(state): State, + Query(query): Query, +) -> Result> { + 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 = 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, + Json(payload): Json, +) -> Result<(StatusCode, Json)> { + 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, + Path(team_id): Path, +) -> Result> { + 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, + Path(team_id): Path, + Json(payload): Json, +) -> Result> { + 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, + Path(team_id): Path, +) -> Result { + // 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, + Path(team_id): Path, +) -> Result { + // 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, + Path(team_id): Path, +) -> Result { + 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, + Path((team_id, player_id)): Path<(i32, i32)>, +) -> Result { + // 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, + Path((team_id, player_id)): Path<(i32, i32)>, +) -> Result { + // 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, + Path((team_id, player_id)): Path<(i32, i32)>, +) -> Result { + 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, + Path((team_id, player_id)): Path<(i32, i32)>, + Json(payload): Json, +) -> Result { + // 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, +) -> Result> { + // 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> { + 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 { + 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 diff --git a/src/handlers/uploads.rs b/src/handlers/uploads.rs new file mode 100644 index 0000000..fb2e129 --- /dev/null +++ b/src/handlers/uploads.rs @@ -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, + Path(team_id): Path, + mut multipart: Multipart, +) -> Result> { + // 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, + mut multipart: Multipart, +) -> Result> { + // 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, + Path(ladder_id): Path, + mut multipart: Multipart, +) -> Result> { + // 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, + Path(team_id): Path, +) -> Result { + // 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 = 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, 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())) +} diff --git a/src/handlers/users.rs b/src/handlers/users.rs new file mode 100644 index 0000000..b48c96e --- /dev/null +++ b/src/handlers/users.rs @@ -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, + pub date_registered: chrono::DateTime, +} + +impl From 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, +) -> Result> { + 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())) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..0383529 --- /dev/null +++ b/src/lib.rs @@ -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}; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9a0409c --- /dev/null +++ b/src/main.rs @@ -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> { + // 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(()) +} diff --git a/src/models/featured_player.rs b/src/models/featured_player.rs new file mode 100644 index 0000000..61e8cd6 --- /dev/null +++ b/src/models/featured_player.rs @@ -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, + 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, +} diff --git a/src/models/ladder.rs b/src/models/ladder.rs new file mode 100644 index 0000000..c464290 --- /dev/null +++ b/src/models/ladder.rs @@ -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 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, + pub date_expiration: Option, + pub date_start: Option, + pub created_by_id: i32, + pub name: String, + pub status: i32, + pub logo: Option, +} + +/// 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, + pub date_start: Option, + pub date_expiration: Option, + pub status: Option, + pub logo: Option, +} diff --git a/src/models/ladder_team.rs b/src/models/ladder_team.rs new file mode 100644 index 0000000..0c97d20 --- /dev/null +++ b/src/models/ladder_team.rs @@ -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, + pub ladder_id: i32, + pub team_id: i32, +} + +/// Join ladder request +#[derive(Debug, Deserialize)] +pub struct JoinLadderRequest { + pub team_id: i32, +} diff --git a/src/models/match_model.rs b/src/models/match_model.rs new file mode 100644 index 0000000..4a3567c --- /dev/null +++ b/src/models/match_model.rs @@ -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 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 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, + pub date_start: DateTime, + pub created_by_id: i32, + pub challenge_date: Option>, + 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, +} diff --git a/src/models/match_round.rs b/src/models/match_round.rs new file mode 100644 index 0000000..d44bf7f --- /dev/null +++ b/src/models/match_round.rs @@ -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 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, + 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, +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..8f4f651 --- /dev/null +++ b/src/models/mod.rs @@ -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; diff --git a/src/models/team.rs b/src/models/team.rs new file mode 100644 index 0000000..5c0fe19 --- /dev/null +++ b/src/models/team.rs @@ -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, + pub name: String, + pub logo: String, // NOT NULL DEFAULT '' in DB + pub bio: Option, + pub rank: i32, + pub mmr: f32, + pub is_delete: bool, + // OpenSkill rating fields + pub mu: Option, + pub sigma: Option, + pub ordinal: Option, +} + +/// Team with full stats (for API responses) +#[derive(Debug, Clone, Serialize)] +pub struct TeamWithStats { + pub id: i32, + pub date_created: DateTime, + pub name: String, + pub logo: String, + pub bio: Option, + pub rank: i32, + pub mmr: f64, + pub mu: f64, + pub sigma: f64, + pub ordinal: f64, +} + +impl From 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, +} + +/// Update team request +#[derive(Debug, Deserialize)] +pub struct UpdateTeam { + pub name: Option, + pub bio: Option, + pub logo: Option, +} + +/// 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, + pub position: String, + pub status: i32, + pub mu: f64, + pub sigma: f64, + pub ordinal: f64, + pub mmr: f64, +} diff --git a/src/models/team_player.rs b/src/models/team_player.rs new file mode 100644 index 0000000..218f4ac --- /dev/null +++ b/src/models/team_player.rs @@ -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 { + 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 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, + 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, +} diff --git a/src/models/user.rs b/src/models/user.rs new file mode 100644 index 0000000..356dbd3 --- /dev/null +++ b/src/models/user.rs @@ -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, + 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, + #[serde(skip_serializing)] + pub password_reset_token: Option, + // OpenSkill rating fields + pub mu: Option, + pub sigma: Option, + pub ordinal: Option, + pub mmr: Option, +} + +/// User public profile (without sensitive data) +#[derive(Debug, Clone, Serialize)] +pub struct UserProfile { + pub id: i32, + pub date_registered: DateTime, + pub firstname: String, + pub lastname: String, + pub username: String, + pub profile: Option, + pub mu: f64, + pub sigma: f64, + pub ordinal: f64, + pub mmr: f64, +} + +impl From 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, + pub lastname: Option, + pub username: Option, + pub profile: Option, +} + +/// 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, + pub mu: f64, + pub sigma: f64, + pub ordinal: f64, + pub mmr: f64, + pub team_id: Option, + pub team_name: Option, + pub position: Option, +} diff --git a/src/services/email.rs b/src/services/email.rs new file mode 100644 index 0000000..e1f5656 --- /dev/null +++ b/src/services/email.rs @@ -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(()) + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..77652cf --- /dev/null +++ b/src/services/mod.rs @@ -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 diff --git a/src/services/rating.rs b/src/services/rating.rs new file mode 100644 index 0000000..39e2291 --- /dev/null +++ b/src/services/rating.rs @@ -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::>()); + let avg_winner = team_rating(&winner_players.iter().map(|(_, m, s)| Rating::new(*m, *s)).collect::>()); + + 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); + } +} diff --git a/src/services/storage.rs b/src/services/storage.rs new file mode 100644 index 0000000..b3510a1 --- /dev/null +++ b/src/services/storage.rs @@ -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, + bucket_name: Option, + 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 { + // 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 { + 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 { + 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"; +} diff --git a/uploads/profile/1768884258_3fa8e1d5.png b/uploads/profile/1768884258_3fa8e1d5.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001