diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2aad809..0bd82ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main, dev, develop ] + branches: [main, dev, develop] pull_request: - branches: [ main, dev, develop ] + branches: [main, dev, develop] jobs: # Frontend CI @@ -14,29 +14,29 @@ jobs: defaults: run: working-directory: ./frontend - + steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - cache: 'npm' - cache-dependency-path: frontend/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Lint code - run: npm run lint - - - name: Build - run: npm run build - - - name: Run tests - run: npm test + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint code + run: npm run lint + + - name: Build + run: npm run build + + - name: Run tests + run: npm test # Server (TypeScript) CI server: @@ -45,29 +45,29 @@ jobs: defaults: run: working-directory: ./server - + steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - cache: 'npm' - cache-dependency-path: server/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Generate Prisma Client - run: npm run prisma:generate - - - name: Type check (Lint) - run: npm run lint - - - name: Build - run: npm run build + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + cache: "npm" + cache-dependency-path: server/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma Client + run: npm run prisma:generate + + - name: Type check (Lint) + run: npm run lint + + - name: Build + run: npm run build # Contracts CI (Rust) contracts: @@ -76,38 +76,82 @@ jobs: defaults: run: working-directory: ./contracts - + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Install wasm target + run: rustup target add wasm32-unknown-unknown + + - name: Cache cargo + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + contracts/target + key: ${{ runner.os }}-cargo-contracts-${{ hashFiles('contracts/Cargo.lock') }} + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Build contracts + run: cargo build --target wasm32-unknown-unknown --release + + - name: Run tests + run: cargo test + + # Backend (Rust) CI + backend: + name: Backend + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./backend + steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - - name: Install wasm target - run: rustup target add wasm32-unknown-unknown - - - name: Cache cargo - uses: actions/cache@v3 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - contracts/target - key: ${{ runner.os }}-cargo-contracts-${{ hashFiles('contracts/Cargo.lock') }} - - - name: Check formatting - run: cargo fmt --all -- --check - - - name: Run clippy - run: cargo clippy --all-targets --all-features -- -D warnings - - - name: Build contracts - run: cargo build --target wasm32-unknown-unknown --release - - - name: Run tests - run: cargo test + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + backend/target + key: ${{ runner.os }}-cargo-backend-${{ hashFiles('backend/Cargo.lock') }} + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Build + run: cargo build --all-targets + env: + SQLX_OFFLINE: "true" + + - name: Run clippy + run: cargo clippy --all-targets -- -D warnings + env: + SQLX_OFFLINE: "true" + + - name: Run tests + run: cargo test + env: + SQLX_OFFLINE: "true" # Security scan diff --git a/backend/.gitignore b/backend/.gitignore index 2f551f0..aa2b0f7 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,4 +1,3 @@ -target # Backend .gitignore # Rust diff --git a/backend/src/api_error.rs b/backend/src/api_error.rs index 62bda4b..3387dc6 100644 --- a/backend/src/api_error.rs +++ b/backend/src/api_error.rs @@ -44,7 +44,7 @@ impl ApiError { ApiError::BadRequest(message.into()) } - pub fn internal_error(message: impl Into) -> Self { + pub fn internal_error(_message: impl Into) -> Self { ApiError::InternalServerError } @@ -52,15 +52,15 @@ impl ApiError { ApiError::DatabaseError(e.into()) } - pub fn not_found(message: impl Into) -> Self { + pub fn not_found(_message: impl Into) -> Self { ApiError::NotFound } - pub fn unauthorized(message: impl Into) -> Self { + pub fn unauthorized(_message: impl Into) -> Self { ApiError::Unauthorized } - pub fn forbidden(message: impl Into) -> Self { + pub fn forbidden(_message: impl Into) -> Self { ApiError::Forbidden } diff --git a/backend/src/auth/device_service.rs b/backend/src/auth/device_service.rs index 62b3c8d..b9f7d99 100644 --- a/backend/src/auth/device_service.rs +++ b/backend/src/auth/device_service.rs @@ -8,7 +8,7 @@ use sqlx::FromRow; use std::collections::HashMap; use std::sync::Arc; use thiserror::Error; -use tracing::{error, info, warn}; +use tracing::{info, warn}; use uuid::Uuid; /// Device information provided during registration @@ -147,7 +147,7 @@ impl SecurityMonitor { ) -> Result<(), DeviceError> { let mut conn = self .redis_client - .get_async_connection() + .get_multiplexed_async_connection() .await .map_err(|e| DeviceError::RedisError(e.to_string()))?; @@ -157,7 +157,7 @@ impl SecurityMonitor { redis::cmd("LPUSH") .arg(&key) .arg(value) - .query_async(&mut conn) + .query_async::<()>(&mut conn) .await .map_err(|e| DeviceError::RedisError(e.to_string()))?; @@ -166,7 +166,7 @@ impl SecurityMonitor { .arg(&key) .arg(0) .arg(99) - .query_async(&mut conn) + .query_async::<()>(&mut conn) .await .map_err(|e| DeviceError::RedisError(e.to_string()))?; @@ -174,7 +174,7 @@ impl SecurityMonitor { redis::cmd("EXPIRE") .arg(&key) .arg(2592000) - .query_async(&mut conn) + .query_async::<()>(&mut conn) .await .map_err(|e| DeviceError::RedisError(e.to_string()))?; @@ -188,13 +188,13 @@ impl SecurityMonitor { ) -> Result { let mut conn = self .redis_client - .get_async_connection() + .get_multiplexed_async_connection() .await .map_err(|e| DeviceError::RedisError(e.to_string()))?; let key = format!("device:login:{}", device_id); let now = chrono::Utc::now().timestamp() as u64; - let cutoff = now - (window_minutes * 60); + let _cutoff = now - (window_minutes * 60); let attempts: Vec = redis::cmd("LRANGE") .arg(&key) @@ -267,7 +267,7 @@ impl DeviceService { redis_client: Arc, config: Option, ) -> Self { - let config = config.unwrap_or_else(|| DeviceConfig::default()); + let config = config.unwrap_or_default(); let security_monitor = SecurityMonitor::new(redis_client.clone()); Self { @@ -374,13 +374,8 @@ impl DeviceService { // Create new device let device_id = Uuid::new_v4(); let now = Utc::now(); - let device_name = device_name.unwrap_or_else(|| { - format!( - "{} {}", - device_info.platform, - device_info.device_type.to_string() - ) - }); + let device_name = device_name + .unwrap_or_else(|| format!("{} {}", device_info.platform, device_info.device_type)); let metadata = serde_json::json!({ "screen_resolution": device_info.screen_resolution, @@ -487,14 +482,14 @@ impl DeviceService { // Clean up Redis cache let mut conn = self .redis_client - .get_async_connection() + .get_multiplexed_async_connection() .await .map_err(|e| DeviceError::RedisError(e.to_string()))?; let key = format!("device:login:{}", device_id); redis::cmd("DEL") .arg(&key) - .query_async(&mut conn) + .query_async::<()>(&mut conn) .await .map_err(|e| DeviceError::RedisError(e.to_string()))?; @@ -707,7 +702,7 @@ impl DeviceService { &self, user_id: Option, ) -> Result { - let query = if let Some(uid) = user_id { + let query = if let Some(_uid) = user_id { "SELECT * FROM devices WHERE user_id = $1" } else { "SELECT * FROM devices" @@ -753,7 +748,7 @@ impl DeviceService { devices_by_type, devices_by_platform, recent_logins, - failed_logins: failed_logins as i64, + failed_logins, }) } @@ -784,7 +779,7 @@ impl DeviceService { // Note: This assumes a device_security_alerts table exists // For now, we'll return alerts from memory or create a simplified version - let alerts: Vec<( + type AlertRow = ( Uuid, Uuid, String, @@ -792,7 +787,8 @@ impl DeviceService { String, Option, DateTime, - )> = sqlx::query_as( + ); + let alerts: Vec = sqlx::query_as( "SELECT device_id, user_id, alert_type, severity, message, details, created_at FROM device_security_alerts WHERE device_id = $1 @@ -877,13 +873,13 @@ impl DeviceService { } } -impl ToString for DeviceType { - fn to_string(&self) -> String { +impl std::fmt::Display for DeviceType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - DeviceType::Desktop => "Desktop".to_string(), - DeviceType::Mobile => "Mobile".to_string(), - DeviceType::Tablet => "Tablet".to_string(), - DeviceType::Unknown => "Unknown".to_string(), + DeviceType::Desktop => write!(f, "Desktop"), + DeviceType::Mobile => write!(f, "Mobile"), + DeviceType::Tablet => write!(f, "Tablet"), + DeviceType::Unknown => write!(f, "Unknown"), } } } diff --git a/backend/src/auth/jwt_service.rs b/backend/src/auth/jwt_service.rs index dbcd7d0..d8cf2c9 100644 --- a/backend/src/auth/jwt_service.rs +++ b/backend/src/auth/jwt_service.rs @@ -4,7 +4,7 @@ use redis::{aio::ConnectionManager, AsyncCommands}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use thiserror::Error; -use tracing::{debug, error, info, warn}; +use tracing::{debug, info, warn}; use uuid::Uuid; /// JWT-related errors @@ -158,6 +158,7 @@ impl KeyRotation { } /// Main JWT Service +#[derive(Clone)] pub struct JwtService { config: JwtConfig, redis: ConnectionManager, @@ -358,7 +359,7 @@ impl JwtService { let blacklist_key = format!("blacklist:{}", claims.jti); let mut conn = self.redis.clone(); - conn.set_ex(&blacklist_key, reason, exp_duration as u64) + conn.set_ex::<_, _, ()>(&blacklist_key, reason, exp_duration as u64) .await?; // Increment analytics @@ -407,7 +408,7 @@ impl JwtService { .map_err(|e| JwtError::RedisError(e.to_string()))?; let mut conn = self.redis.clone(); - conn.set_ex( + conn.set_ex::<_, _, ()>( &session_key, session_json, self.config.refresh_token_expiry.num_seconds() as u64, @@ -416,10 +417,11 @@ impl JwtService { // Add to user's active sessions let user_sessions_key = format!("user_sessions:{}", user_id); - conn.sadd(&user_sessions_key, session_id).await?; - conn.expire( + conn.sadd::<_, _, ()>(&user_sessions_key, session_id) + .await?; + conn.expire::<_, ()>( &user_sessions_key, - self.config.refresh_token_expiry.num_seconds() as i64, + self.config.refresh_token_expiry.num_seconds(), ) .await?; @@ -451,7 +453,7 @@ impl JwtService { let updated_json = serde_json::to_string(&session).map_err(|e| JwtError::RedisError(e.to_string()))?; - conn.set_ex( + conn.set_ex::<_, _, ()>( &session_key, updated_json, self.config.refresh_token_expiry.num_seconds() as u64, @@ -492,10 +494,10 @@ impl JwtService { for session_id in session_ids { let session_key = format!("session:{}", session_id); - conn.del(&session_key).await?; + conn.del::<_, ()>(&session_key).await?; } - conn.del(&user_sessions_key).await?; + conn.del::<_, ()>(&user_sessions_key).await?; info!(user_id = %user_id, count = count, "User sessions revoked"); @@ -506,7 +508,7 @@ impl JwtService { pub async fn revoke_session(&self, session_id: &str) -> Result<(), JwtError> { let session_key = format!("session:{}", session_id); let mut conn = self.redis.clone(); - conn.del(&session_key).await?; + conn.del::<_, ()>(&session_key).await?; info!(session_id = %session_id, "Session revoked"); @@ -517,7 +519,7 @@ impl JwtService { async fn increment_analytics(&self, metric: &str) -> Result<(), JwtError> { let analytics_key = format!("analytics:jwt:{}", metric); let mut conn = self.redis.clone(); - conn.incr(&analytics_key, 1).await?; + conn.incr::<_, _, ()>(&analytics_key, 1).await?; Ok(()) } @@ -553,7 +555,7 @@ impl JwtService { let ttl: i64 = conn.ttl(&key).await.unwrap_or(-2); if ttl == -2 { // Key doesn't exist or expired - conn.del(&key).await?; + conn.del::<_, ()>(&key).await?; cleaned += 1; } } @@ -589,6 +591,7 @@ impl JwtService { mod tests { use super::*; + #[allow(dead_code)] fn create_test_config() -> JwtConfig { JwtConfig { secret_key: "test_secret_key_for_testing".to_string(), diff --git a/backend/src/auth/jwt_service_test.rs b/backend/src/auth/jwt_service_test.rs index 1645ae6..6787c05 100644 --- a/backend/src/auth/jwt_service_test.rs +++ b/backend/src/auth/jwt_service_test.rs @@ -47,9 +47,7 @@ mod integration_tests { let user_id = Uuid::new_v4(); let roles = vec!["user".to_string()]; - let token = service - .generate_refresh_token(user_id, roles, None) - .await; + let token = service.generate_refresh_token(user_id, roles, None).await; assert!(token.is_ok()); } @@ -60,9 +58,7 @@ mod integration_tests { let user_id = Uuid::new_v4(); let roles = vec!["user".to_string()]; - let result = service - .generate_token_pair(user_id, roles, None) - .await; + let result = service.generate_token_pair(user_id, roles, None).await; assert!(result.is_ok()); @@ -199,11 +195,7 @@ mod integration_tests { // Create multiple sessions for i in 0..3 { service - .generate_access_token( - user_id, - roles.clone(), - Some(format!("device-{}", i)), - ) + .generate_access_token(user_id, roles.clone(), Some(format!("device-{}", i))) .await .unwrap(); } @@ -292,7 +284,7 @@ mod integration_tests { for device in &devices { assert!(sessions .iter() - .any(|s| s.device_id.as_ref().map(|d| d.as_str()) == Some(*device))); + .any(|s| s.device_id.as_deref() == Some(*device))); } } @@ -318,7 +310,11 @@ mod integration_tests { async fn test_claims_roles() { let service = create_test_service().await; let user_id = Uuid::new_v4(); - let roles = vec!["user".to_string(), "admin".to_string(), "premium".to_string()]; + let roles = vec![ + "user".to_string(), + "admin".to_string(), + "premium".to_string(), + ]; let token = service .generate_access_token(user_id, roles.clone(), None) diff --git a/backend/src/auth/middleware.rs b/backend/src/auth/middleware.rs index de6cbf5..fec3fe1 100644 --- a/backend/src/auth/middleware.rs +++ b/backend/src/auth/middleware.rs @@ -132,12 +132,12 @@ impl ClaimsExt for actix_web::HttpRequest { #[cfg(test)] mod tests { - use super::*; #[test] fn test_claims_ext_interface() { // This test just ensures the trait compiles // Real testing would require mocking HTTP request - assert!(true); + let compile_check: bool = true; + assert!(compile_check); } } diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index 58f525d..e8b7f1c 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -1,13 +1,5 @@ pub mod device_service; pub mod jwt_service; +#[cfg(test)] +mod jwt_service_test; pub mod middleware; - -pub use device_service::{ - AlertSeverity, AlertType, Device, DeviceAnalytics, DeviceConfig, DeviceError, DeviceInfo, - DeviceService, DeviceType, SecurityAlert, -}; -pub use jwt_service::{ - Claims, JwtConfig, JwtError, JwtService, KeyRotation, SessionData, TokenAnalytics, TokenPair, - TokenType, -}; -pub use middleware::AuthMiddleware; diff --git a/backend/src/db.rs b/backend/src/db.rs index 41f0e4b..954be0f 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -15,6 +15,6 @@ pub async fn health_check(pool: &DbPool) -> Result<(), ApiError> { sqlx::query("SELECT 1") .execute(pool) .await - .map_err(|e| ApiError::DatabaseError(e))?; + .map_err(ApiError::DatabaseError)?; Ok(()) } diff --git a/backend/src/http/auth_handler.rs b/backend/src/http/auth_handler.rs index c594128..bfa8de2 100644 --- a/backend/src/http/auth_handler.rs +++ b/backend/src/http/auth_handler.rs @@ -1,12 +1,10 @@ use crate::api_error::ApiError; -use crate::auth::jwt_service::{TokenAnalytics, TokenPair}; use crate::auth::middleware::ClaimsExt; -use crate::models::user::{AuthResponse, CreateUserRequest, LoginRequest}; +use crate::models::user::{CreateUserRequest, LoginRequest}; use crate::service::auth_service::AuthService; use actix_web::{web, HttpRequest, HttpResponse, Responder}; use serde::{Deserialize, Serialize}; use tracing::info; -use uuid::Uuid; /// Refresh token request #[derive(Debug, Deserialize)] @@ -50,7 +48,7 @@ pub async fn register( ) -> Result { info!( username = %request.username, - email = %request.email, + email = request.email.as_deref().unwrap_or("none"), "Registration request received" ); @@ -174,10 +172,10 @@ pub async fn revoke_all_sessions( /// GET /api/auth/sessions /// Get all active sessions for current user (requires authentication) pub async fn get_sessions( - auth_service: web::Data, + _auth_service: web::Data, req: HttpRequest, ) -> Result { - let user_id = req + let _user_id = req .user_id() .ok_or_else(|| ApiError::unauthorized("User not authenticated"))?; @@ -192,7 +190,7 @@ pub async fn get_sessions( /// GET /api/auth/analytics /// Get token analytics (admin only) pub async fn get_analytics( - auth_service: web::Data, + _auth_service: web::Data, req: HttpRequest, ) -> Result { // Check if user is admin diff --git a/backend/src/http/match_authority_handler.rs b/backend/src/http/match_authority_handler.rs index 3e4a675..3bb109b 100644 --- a/backend/src/http/match_authority_handler.rs +++ b/backend/src/http/match_authority_handler.rs @@ -4,7 +4,7 @@ use crate::service::match_authority_service::MatchAuthorityService; use actix_web::{web, HttpResponse, Responder}; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use tracing::{error, info}; +use tracing::info; use uuid::Uuid; /// Application state containing the Match Authority Service diff --git a/backend/src/http/match_ws_handler.rs b/backend/src/http/match_ws_handler.rs index 8333947..f09dc79 100644 --- a/backend/src/http/match_ws_handler.rs +++ b/backend/src/http/match_ws_handler.rs @@ -73,6 +73,12 @@ pub struct MatchWebSocket { subscriptions: Vec, } +impl Default for MatchWebSocket { + fn default() -> Self { + Self::new() + } +} + impl MatchWebSocket { pub fn new() -> Self { Self { @@ -98,7 +104,7 @@ impl MatchWebSocket { } /// Handle subscription request - fn handle_subscribe(&mut self, match_id: Uuid, ctx: &mut ::Context) { + fn handle_subscribe(&mut self, match_id: Uuid, _ctx: &mut ::Context) { if !self.subscriptions.contains(&match_id) { self.subscriptions.push(match_id); info!( @@ -262,48 +268,3 @@ pub fn configure_ws_routes(cfg: &mut web::ServiceConfig) { cfg.route("/ws/matches/{id}", web::get().to(match_websocket)); } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_ws_message_serialization() { - let msg = WsMessage::Subscribe { - match_id: Uuid::new_v4(), - }; - let json = serde_json::to_string(&msg).unwrap(); - assert!(json.contains("subscribe")); - - let deserialized: WsMessage = serde_json::from_str(&json).unwrap(); - match deserialized { - WsMessage::Subscribe { .. } => {} - _ => panic!("Expected Subscribe message"), - } - } - - #[test] - fn test_match_state_changed_serialization() { - let msg = WsMessage::MatchStateChanged { - match_id: Uuid::new_v4(), - from_state: "CREATED".to_string(), - to_state: "STARTED".to_string(), - timestamp: "2024-01-01T00:00:00Z".to_string(), - }; - - let json = serde_json::to_string(&msg).unwrap(); - assert!(json.contains("match_state_changed")); - assert!(json.contains("CREATED")); - assert!(json.contains("STARTED")); - } - - #[test] - fn test_error_message() { - let msg = WsMessage::Error { - message: "Test error".to_string(), - }; - - let json = serde_json::to_string(&msg).unwrap(); - assert!(json.contains("error")); - assert!(json.contains("Test error")); - } -} diff --git a/backend/src/http/matchmaking.rs b/backend/src/http/matchmaking.rs index a4666ec..46fc442 100644 --- a/backend/src/http/matchmaking.rs +++ b/backend/src/http/matchmaking.rs @@ -1,10 +1,12 @@ use crate::api_error::ApiError; -use crate::auth::Claims; +use crate::auth::jwt_service::Claims; use crate::db::DbPool; use crate::models::matchmaker::*; -use crate::service::matchmaker::{MatchmakerService, EloEngine, MatchmakingConfig}; +use crate::service::matchmaker::MatchmakerService; use actix_web::{web, HttpResponse, Result}; +use chrono::Utc; use serde::{Deserialize, Serialize}; +use sqlx::Row; use uuid::Uuid; #[derive(Debug, Serialize, Deserialize)] @@ -36,7 +38,7 @@ pub struct LeaveQueueResponse { #[derive(Debug, Serialize, Deserialize)] pub struct QueueStatusResponse { pub in_queue: bool, - pub queue_entry: Option, + pub queue_entry: Option, pub queue_size: usize, pub estimated_wait_time: Option, pub wait_time_so_far: Option, @@ -71,13 +73,17 @@ pub async fn join_queue( claims: web::ReqData, request: web::Json, ) -> Result { - let user_id = claims.user_id; + let user_id = Uuid::parse_str(&claims.sub) + .map_err(|e| ApiError::bad_request(format!("Invalid user ID: {}", e)))?; let game = request.game.clone(); let game_mode = request.game_mode.clone(); // Check if user is already in queue - match matchmaker.is_user_in_queue(user_id, &game, &game_mode).await { - Ok(Some(entry)) => { + match matchmaker + .is_user_in_queue(user_id, &game, &game_mode) + .await + { + Ok(Some(_entry)) => { return Ok(HttpResponse::BadRequest().json(JoinQueueResponse { success: false, queue_position: None, @@ -90,8 +96,8 @@ pub async fn join_queue( } // Get user's current ELO rating - let current_elo = match get_user_elo(&db_pool, user_id, &game).await { - Ok(elo) => elo, + let current_elo: i32 = match get_user_elo(&db_pool, user_id, &game).await { + Ok(elo) => elo.current_rating, Err(_) => { // Create default ELO record if not exists create_default_elo(&db_pool, user_id, &game).await?; @@ -100,13 +106,22 @@ pub async fn join_queue( }; // Add to queue - if let Err(e) = matchmaker.add_to_queue(user_id, game.clone(), game_mode.clone(), current_elo).await { + if let Err(e) = matchmaker + .add_to_queue(user_id, game.clone(), game_mode.clone(), current_elo) + .await + { return Err(e.into()); } // Get queue stats - let queue_size = matchmaker.get_queue_size(&game, &game_mode).await.unwrap_or(0); - let estimated_wait_time = matchmaker.get_estimated_wait_time(user_id, &game, &game_mode).await.ok(); + let queue_size = matchmaker + .get_queue_size(&game, &game_mode) + .await + .unwrap_or(0); + let estimated_wait_time = matchmaker + .get_estimated_wait_time(user_id, &game, &game_mode) + .await + .ok(); // Add to database queue for persistence add_to_database_queue(&db_pool, user_id, &game, &game_mode, current_elo).await?; @@ -126,12 +141,16 @@ pub async fn leave_queue( claims: web::ReqData, request: web::Json, ) -> Result { - let user_id = claims.user_id; + let user_id = Uuid::parse_str(&claims.sub) + .map_err(|e| ApiError::bad_request(format!("Invalid user ID: {}", e)))?; let game = request.game.clone(); let game_mode = request.game_mode.clone(); // Check if user is in queue - match matchmaker.is_user_in_queue(user_id, &game, &game_mode).await { + match matchmaker + .is_user_in_queue(user_id, &game, &game_mode) + .await + { Ok(Some(_)) => {} // Continue Ok(None) => { return Ok(HttpResponse::BadRequest().json(LeaveQueueResponse { @@ -142,11 +161,8 @@ pub async fn leave_queue( Err(e) => return Err(e.into()), } - // Remove from Redis queue - let mut conn = matchmaker.redis_client.get_multiplexed_async_connection().await - .map_err(|e| ApiError::internal_error(&format!("Redis connection error: {}", e)))?; - - if let Err(e) = matchmaker.remove_from_queue(&mut conn, &user_id, &game, &game_mode).await { + // Remove from queue via the service's public API + if let Err(e) = matchmaker.leave_queue(user_id, &game, &game_mode).await { return Err(e.into()); } @@ -166,18 +182,31 @@ pub async fn get_queue_status( claims: web::ReqData, path: web::Path<(String, String)>, // (game, game_mode) ) -> Result { - let user_id = claims.user_id; + let user_id = Uuid::parse_str(&claims.sub) + .map_err(|e| ApiError::bad_request(format!("Invalid user ID: {}", e)))?; let (game, game_mode) = path.into_inner(); // Check if user is in queue - let queue_entry = matchmaker.is_user_in_queue(user_id, &game, &game_mode).await?; + let queue_entry = matchmaker + .is_user_in_queue(user_id, &game, &game_mode) + .await?; let in_queue = queue_entry.is_some(); - let queue_size = matchmaker.get_queue_size(&game, &game_mode).await.unwrap_or(0); - let estimated_wait_time = matchmaker.get_estimated_wait_time(user_id, &game, &game_mode).await.ok(); + let queue_size = matchmaker + .get_queue_size(&game, &game_mode) + .await + .unwrap_or(0); + let estimated_wait_time = matchmaker + .get_estimated_wait_time(user_id, &game, &game_mode) + .await + .ok(); let wait_time_so_far = if let Some(ref entry) = queue_entry { - Some(Utc::now().signed_duration_since(entry.joined_at).num_seconds()) + Some( + Utc::now() + .signed_duration_since(entry.joined_at) + .num_seconds(), + ) } else { None }; @@ -194,21 +223,20 @@ pub async fn get_queue_status( /// Get matchmaking statistics pub async fn get_matchmaking_stats( db_pool: web::Data, - matchmaker: web::Data, + _matchmaker: web::Data, ) -> Result { // Get total players in queue from database - let total_query = sqlx::query!( - "SELECT COUNT(*) as count FROM matchmaking_queue WHERE status = $1", - QueueStatus::Waiting as _ - ) - .fetch_one(db_pool.as_ref()) - .await - .map_err(|e| ApiError::database_error(e))?; + let total_row = + sqlx::query("SELECT COUNT(*) as count FROM matchmaking_queue WHERE status = $1") + .bind(format!("{:?}", QueueStatus::Waiting)) + .fetch_one(db_pool.as_ref()) + .await + .map_err(ApiError::database_error)?; - let total_players_in_queue = total_query.count.unwrap_or(0) as usize; + let total_players_in_queue: i64 = total_row.try_get("count").unwrap_or(0); // Get stats by game and game mode - let game_stats_query = sqlx::query!( + let game_stats_rows = sqlx::query( r#" SELECT game, game_mode, COUNT(*) as player_count FROM matchmaking_queue @@ -216,27 +244,36 @@ pub async fn get_matchmaking_stats( GROUP BY game, game_mode ORDER BY game, game_mode "#, - QueueStatus::Waiting as _ ) + .bind(format!("{:?}", QueueStatus::Waiting)) .fetch_all(db_pool.as_ref()) .await - .map_err(|e| ApiError::database_error(e))?; - - let mut games_map: std::collections::HashMap> = std::collections::HashMap::new(); - - for row in game_stats_query { - games_map - .entry(row.game) - .or_insert_with(Vec::new) - .push((row.game_mode, row.player_count.unwrap_or(0) as usize)); + .map_err(ApiError::database_error)?; + + let mut games_map: std::collections::HashMap> = + std::collections::HashMap::new(); + + for row in game_stats_rows { + let game: String = row.try_get("game").unwrap_or_default(); + let game_mode: String = row.try_get("game_mode").unwrap_or_default(); + let player_count: i64 = row.try_get("player_count").unwrap_or(0); + games_map + .entry(game) + .or_default() + .push((game_mode, player_count as usize)); } let mut games = Vec::new(); for (game, modes) in games_map { let mut game_mode_stats = Vec::new(); for (game_mode, player_count) in modes { - let matches_per_hour = get_matches_per_hour(&db_pool, &game, &game_mode).await.unwrap_or(0); - let average_wait_time = get_average_wait_time(&db_pool, &game, &game_mode).await.ok(); + let matches_per_hour = get_matches_per_hour(&db_pool, &game, &game_mode) + .await + .unwrap_or(0); + let average_wait_time = get_average_wait_time(&db_pool, &game, &game_mode) + .await + .ok() + .flatten(); game_mode_stats.push(GameModeStats { game_mode, @@ -257,7 +294,7 @@ pub async fn get_matchmaking_stats( let average_wait_time = get_overall_average_wait_time(&db_pool).await.unwrap_or(0.0); Ok(HttpResponse::Ok().json(MatchmakingStatsResponse { - total_players_in_queue, + total_players_in_queue: total_players_in_queue as usize, games, matches_created_last_hour, average_wait_time, @@ -270,18 +307,17 @@ pub async fn get_elo( claims: web::ReqData, path: web::Path, // game ) -> Result { - let user_id = claims.user_id; + let user_id = Uuid::parse_str(&claims.sub) + .map_err(|e| ApiError::bad_request(format!("Invalid user ID: {}", e)))?; let game = path.into_inner(); - let elo_record = sqlx::query_as!( - UserElo, - "SELECT * FROM user_elo WHERE user_id = $1 AND game = $2", - user_id, - game - ) - .fetch_optional(db_pool.as_ref()) - .await - .map_err(|e| ApiError::database_error(e))?; + let elo_record = + sqlx::query_as::<_, UserElo>("SELECT * FROM user_elo WHERE user_id = $1 AND game = $2") + .bind(user_id) + .bind(&game) + .fetch_optional(db_pool.as_ref()) + .await + .map_err(ApiError::database_error)?; match elo_record { Some(elo) => Ok(HttpResponse::Ok().json(elo)), @@ -300,26 +336,26 @@ pub async fn get_elo_history( claims: web::ReqData, path: web::Path<(String, i32, i32)>, // (game, page, limit) ) -> Result { - let user_id = claims.user_id; + let user_id = Uuid::parse_str(&claims.sub) + .map_err(|e| ApiError::bad_request(format!("Invalid user ID: {}", e)))?; let (game, page, limit) = path.into_inner(); let offset = (page - 1) * limit; - let history = sqlx::query_as!( - EloHistory, + let history = sqlx::query_as::<_, EloHistory>( r#" SELECT * FROM elo_history WHERE user_id = $1 AND game = $2 ORDER BY created_at DESC LIMIT $3 OFFSET $4 "#, - user_id, - game, - limit, - offset ) + .bind(user_id) + .bind(game) + .bind(limit as i64) + .bind(offset as i64) .fetch_all(db_pool.as_ref()) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; Ok(HttpResponse::Ok().json(history)) } @@ -327,34 +363,32 @@ pub async fn get_elo_history( // Helper functions async fn get_user_elo(db_pool: &DbPool, user_id: Uuid, game: &str) -> Result { - let elo_record = sqlx::query_as!( - UserElo, - "SELECT * FROM user_elo WHERE user_id = $1 AND game = $2", - user_id, - game - ) - .fetch_one(db_pool) - .await - .map_err(|e| ApiError::database_error(e))?; + let elo_record = + sqlx::query_as::<_, UserElo>("SELECT * FROM user_elo WHERE user_id = $1 AND game = $2") + .bind(user_id) + .bind(game) + .fetch_one(db_pool) + .await + .map_err(ApiError::database_error)?; Ok(elo_record) } async fn create_default_elo(db_pool: &DbPool, user_id: Uuid, game: &str) -> Result<(), ApiError> { - sqlx::query!( + sqlx::query( "INSERT INTO user_elo (user_id, game, current_rating, wins, losses, draws, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", - user_id, - game, - 1200i32, // Default ELO - 0i32, - 0i32, - 0i32, - Utc::now(), - Utc::now() ) + .bind(user_id) + .bind(game) + .bind(1200i32) + .bind(0i32) + .bind(0i32) + .bind(0i32) + .bind(Utc::now()) + .bind(Utc::now()) .execute(db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; Ok(()) } @@ -366,7 +400,7 @@ async fn add_to_database_queue( game_mode: &str, current_elo: i32, ) -> Result<(), ApiError> { - sqlx::query!( + sqlx::query( r#" INSERT INTO matchmaking_queue ( id, user_id, game, game_mode, current_elo, min_elo, max_elo, @@ -375,20 +409,20 @@ async fn add_to_database_queue( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 ) "#, - Uuid::new_v4(), - user_id, - game, - game_mode, - current_elo, - current_elo - 100, // Initial range - current_elo + 100, - Utc::now(), - Utc::now() + chrono::Duration::minutes(10), // 10 minute expiry - QueueStatus::Waiting as _ ) + .bind(Uuid::new_v4()) + .bind(user_id) + .bind(game) + .bind(game_mode) + .bind(current_elo) + .bind(current_elo - 100) + .bind(current_elo + 100) + .bind(Utc::now()) + .bind(Utc::now() + chrono::Duration::minutes(10)) + .bind(format!("{:?}", QueueStatus::Waiting)) .execute(db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; Ok(()) } @@ -399,37 +433,45 @@ async fn update_database_queue_status( game: &str, game_mode: &str, ) -> Result<(), ApiError> { - sqlx::query!( + sqlx::query( "UPDATE matchmaking_queue SET status = $1 WHERE user_id = $2 AND game = $3 AND game_mode = $4", - QueueStatus::Left as _, - user_id, - game, - game_mode ) + .bind(format!("{:?}", QueueStatus::Left)) + .bind(user_id) + .bind(game) + .bind(game_mode) .execute(db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; Ok(()) } -async fn get_matches_per_hour(db_pool: &DbPool, game: &str, game_mode: &str) -> Result { +async fn get_matches_per_hour( + db_pool: &DbPool, + game: &str, + game_mode: &str, +) -> Result { let one_hour_ago = Utc::now() - chrono::Duration::hours(1); - - let result = sqlx::query!( + + let result = sqlx::query( "SELECT COUNT(*) as count FROM matches WHERE game_mode = $1 AND created_at >= $2", - format!("{}:{}", game, game_mode), - one_hour_ago ) + .bind(format!("{}:{}", game, game_mode)) + .bind(one_hour_ago) .fetch_one(db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - Ok(result.count.unwrap_or(0)) + Ok(result.try_get::("count").unwrap_or(0)) } -async fn get_average_wait_time(db_pool: &DbPool, game: &str, game_mode: &str) -> Result, ApiError> { - let result = sqlx::query!( +async fn get_average_wait_time( + db_pool: &DbPool, + game: &str, + game_mode: &str, +) -> Result, ApiError> { + let result = sqlx::query( r#" SELECT AVG(EXTRACT(EPOCH FROM (matched_at - joined_at))::INTEGER) as avg_wait_seconds FROM matchmaking_queue @@ -438,34 +480,34 @@ async fn get_average_wait_time(db_pool: &DbPool, game: &str, game_mode: &str) -> AND matched_at IS NOT NULL AND created_at >= $4 "#, - game, - game_mode, - QueueStatus::Matched as _, - Utc::now() - chrono::Duration::hours(1) ) + .bind(game) + .bind(game_mode) + .bind(format!("{:?}", QueueStatus::Matched)) + .bind(Utc::now() - chrono::Duration::hours(1)) .fetch_one(db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - Ok(result.avg_wait_seconds.map(|x| x as i32)) + Ok(result + .try_get::, _>("avg_wait_seconds") + .unwrap_or(None)) } async fn get_matches_created_last_hour(db_pool: &DbPool) -> Result { let one_hour_ago = Utc::now() - chrono::Duration::hours(1); - - let result = sqlx::query!( - "SELECT COUNT(*) as count FROM matches WHERE created_at >= $1", - one_hour_ago - ) - .fetch_one(db_pool) - .await - .map_err(|e| ApiError::database_error(e))?; - Ok(result.count.unwrap_or(0)) + let result = sqlx::query("SELECT COUNT(*) as count FROM matches WHERE created_at >= $1") + .bind(one_hour_ago) + .fetch_one(db_pool) + .await + .map_err(ApiError::database_error)?; + + Ok(result.try_get::("count").unwrap_or(0)) } async fn get_overall_average_wait_time(db_pool: &DbPool) -> Result { - let result = sqlx::query!( + let result = sqlx::query( r#" SELECT AVG(EXTRACT(EPOCH FROM (matched_at - joined_at))::INTEGER) as avg_wait_seconds FROM matchmaking_queue @@ -473,36 +515,12 @@ async fn get_overall_average_wait_time(db_pool: &DbPool) -> Result= $2 "#, - QueueStatus::Matched as _, - Utc::now() - chrono::Duration::hours(1) ) + .bind(format!("{:?}", QueueStatus::Matched)) + .bind(Utc::now() - chrono::Duration::hours(1)) .fetch_one(db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - Ok(result.avg_wait_seconds.unwrap_or(0.0)) -} - -// Helper functions for calculating ELO ranges and wait times -fn calculate_elo_range(current_elo: i32, wait_time_minutes: i64) -> (i32, i32) { - let base_range = 100; - let expansion = (wait_time_minutes as f64 / 30.0).floor() as i32 * 50; // Expand by 50 every 30 minutes - let max_range = 500; - - let range = (base_range + expansion).min(max_range); - (current_elo - range, current_elo + range) -} - -fn estimate_wait_time(queue_size: usize, player_elo: i32) -> i32 { - // Base wait time: 2 minutes per person in queue - let base_wait = queue_size as i32 * 120; - - // Adjust based on ELO (extreme ELOs wait longer) - let elo_adjustment = if player_elo < 1000 || player_elo > 2000 { - 300 // 5 extra minutes - } else { - 0 - }; - - base_wait + elo_adjustment + Ok(result.try_get::("avg_wait_seconds").unwrap_or(0.0)) } diff --git a/backend/src/http/mod.rs b/backend/src/http/mod.rs index 08d30e9..d9fd246 100644 --- a/backend/src/http/mod.rs +++ b/backend/src/http/mod.rs @@ -1,14 +1,10 @@ +pub mod auth_handler; pub mod health; pub mod idempotency; pub mod idempotency_examples; pub mod match_authority_handler; -pub mod matchmaking; #[deprecated(note = "Use realtime::user_ws instead for authenticated WebSocket connections")] pub mod match_ws_handler; +pub mod matchmaking; pub mod notification_handler; pub mod reputation_handler; - -// TODO: Add more HTTP modules as implemented: -// pub mod auth; -// pub mod matches; -// pub mod tournaments; diff --git a/backend/src/http/notification_handler.rs b/backend/src/http/notification_handler.rs index 0d65d0e..691690c 100644 --- a/backend/src/http/notification_handler.rs +++ b/backend/src/http/notification_handler.rs @@ -30,7 +30,7 @@ struct NotificationRow { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct CreateNotificationRequest { +pub struct CreateNotificationRequest { #[serde(rename = "type")] typ: Option, title: String, diff --git a/backend/src/http/reputation_handler.rs b/backend/src/http/reputation_handler.rs index 3159ffa..77bede0 100644 --- a/backend/src/http/reputation_handler.rs +++ b/backend/src/http/reputation_handler.rs @@ -6,12 +6,27 @@ //! - Admin functions for managing bad actors use crate::api_error::ApiError; -use crate::models::ApiResponse; -use crate::service::reputation_service::{PlayerReputation, ReputationService}; +use crate::service::reputation_service::{PlayerReputation, ReputationTier}; use actix_web::{web, HttpResponse}; use serde::{Deserialize, Serialize}; +use sqlx::Row; use uuid::Uuid; +#[derive(Debug, Serialize)] +struct ApiResponse { + success: bool, + data: T, +} + +impl ApiResponse { + fn success(data: T) -> Self { + Self { + success: true, + data, + } + } +} + /// Player reputation response #[derive(Debug, Serialize)] pub struct ReputationResponse { @@ -25,8 +40,6 @@ pub struct ReputationResponse { impl From for ReputationResponse { fn from(rep: PlayerReputation) -> Self { - use crate::service::reputation_service::ReputationTier; - let tier_str = match rep.get_tier() { ReputationTier::Elite => "elite", ReputationTier::Good => "good", @@ -58,15 +71,23 @@ pub struct ReputationEventResponse { pub created_at: String, } +#[derive(Debug, Serialize, sqlx::FromRow)] +struct PlayerReputationData { + user_id: Uuid, + skill_score: i32, + fair_play_score: i32, + reputation_last_updated: Option>, + is_bad_actor: bool, +} + /// Get player reputation pub async fn get_player_reputation( pool: web::Data, path: web::Path, ) -> Result { let user_id = path.into_inner(); - - let reputation = sqlx::query_as!( - PlayerReputationData, + + let reputation = sqlx::query_as::<_, PlayerReputationData>( r#" SELECT id as user_id, @@ -77,14 +98,14 @@ pub async fn get_player_reputation( FROM users WHERE id = $1 "#, - user_id ) + .bind(user_id) .fetch_optional(pool.get_ref()) .await - .map_err(|e| ApiError::database_error(e))? + .map_err(ApiError::database_error)? .ok_or_else(|| ApiError::not_found("User not found"))?; - let rep = PlayerReputation { + let response = ReputationResponse::from(PlayerReputation { user_id: reputation.user_id, skill_score: reputation.skill_score, fair_play_score: reputation.fair_play_score, @@ -93,18 +114,9 @@ pub async fn get_player_reputation( .map(|t| t.timestamp() as u64) .unwrap_or(0), is_bad_actor: reputation.is_bad_actor, - }; - - Ok(HttpResponse::Ok().json(ApiResponse::success(reputation))) -} + }); -#[derive(sqlx::FromRow)] -struct PlayerReputationData { - user_id: Uuid, - skill_score: i32, - fair_play_score: i32, - reputation_last_updated: Option>, - is_bad_actor: bool, + Ok(HttpResponse::Ok().json(ApiResponse::success(response))) } /// Get current user's reputation (authenticated endpoint) @@ -113,9 +125,8 @@ pub async fn get_my_reputation( user_id: web::ReqData, ) -> Result { let uid = user_id.into_inner(); - - let reputation = sqlx::query_as!( - PlayerReputationData, + + let reputation = sqlx::query_as::<_, PlayerReputationData>( r#" SELECT id as user_id, @@ -126,13 +137,13 @@ pub async fn get_my_reputation( FROM users WHERE id = $1 "#, - uid ) + .bind(uid) .fetch_one(pool.get_ref()) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - let rep = PlayerReputation { + let rep = ReputationResponse::from(PlayerReputation { user_id: reputation.user_id, skill_score: reputation.skill_score, fair_play_score: reputation.fair_play_score, @@ -141,7 +152,7 @@ pub async fn get_my_reputation( .map(|t| t.timestamp() as u64) .unwrap_or(0), is_bad_actor: reputation.is_bad_actor, - }; + }); Ok(HttpResponse::Ok().json(ApiResponse::success(rep))) } @@ -156,8 +167,7 @@ pub async fn get_reputation_history( let limit = query.limit.unwrap_or(20); let offset = query.offset.unwrap_or(0); - let history = sqlx::query_as!( - ReputationEventResponse, + let history = sqlx::query_as::<_, ReputationEventResponse>( r#" SELECT id, @@ -167,19 +177,19 @@ pub async fn get_reputation_history( fair_play_delta, match_id, transaction_hash, - created_at::text + created_at::text as created_at FROM reputation_events WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 "#, - user_id, - limit as i64, - offset as i64 ) + .bind(user_id) + .bind(limit) + .bind(offset) .fetch_all(pool.get_ref()) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; Ok(HttpResponse::Ok().json(ApiResponse::success(history))) } @@ -192,7 +202,7 @@ pub async fn get_bad_actors( let limit = query.limit.unwrap_or(50); let offset = query.offset.unwrap_or(0); - let bad_actors = sqlx::query!( + let rows = sqlx::query( r#" SELECT id, @@ -207,12 +217,26 @@ pub async fn get_bad_actors( ORDER BY fair_play_score ASC, created_at DESC LIMIT $1 OFFSET $2 "#, - limit as i64, - offset as i64 ) + .bind(limit) + .bind(offset) .fetch_all(pool.get_ref()) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; + + let bad_actors: Vec = rows + .iter() + .map(|row| { + serde_json::json!({ + "id": row.try_get::("id").unwrap_or_default(), + "username": row.try_get::("username").unwrap_or_default(), + "email": row.try_get::, _>("email").unwrap_or(None), + "skill_score": row.try_get::, _>("skill_score").unwrap_or(None), + "fair_play_score": row.try_get::, _>("fair_play_score").unwrap_or(None), + "anticheat_flags_count": row.try_get::, _>("anticheat_flags_count").unwrap_or(None), + }) + }) + .collect(); Ok(HttpResponse::Ok().json(ApiResponse::success(bad_actors))) } @@ -224,29 +248,29 @@ pub async fn remove_bad_actor_flag( ) -> Result { let user_id = path.into_inner(); - sqlx::query!( + sqlx::query( r#" UPDATE users SET is_bad_actor = false, updated_at = NOW() WHERE id = $1 "#, - user_id ) + .bind(user_id) .execute(pool.get_ref()) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - Ok(HttpResponse::Ok().json(ApiResponse::success(serde_json::json!({ - "message": "Bad actor flag removed", - "user_id": user_id - })))) + Ok( + HttpResponse::Ok().json(ApiResponse::success(serde_json::json!({ + "message": "Bad actor flag removed", + "user_id": user_id + }))), + ) } /// Get reputation statistics (admin only) -pub async fn get_reputation_stats( - pool: web::Data, -) -> Result { - let stats = sqlx::query!( +pub async fn get_reputation_stats(pool: web::Data) -> Result { + let row = sqlx::query( r#" SELECT COUNT(*) FILTER (WHERE is_bad_actor = true) as bad_actors_count, @@ -256,18 +280,18 @@ pub async fn get_reputation_stats( AVG(COALESCE(fair_play_score, 100)) as avg_fair_play FROM users WHERE is_active = true - "# + "#, ) .fetch_one(pool.get_ref()) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; let response = serde_json::json!({ - "bad_actors_count": stats.bad_actors_count.unwrap_or(0), - "low_fair_play_count": stats.low_fair_play_count.unwrap_or(0), - "high_skill_count": stats.high_skill_count.unwrap_or(0), - "avg_skill": stats.avg_skill.unwrap_or(1000.0), - "avg_fair_play": stats.avg_fair_play.unwrap_or(100.0) + "bad_actors_count": row.try_get::("bad_actors_count").unwrap_or(0), + "low_fair_play_count": row.try_get::("low_fair_play_count").unwrap_or(0), + "high_skill_count": row.try_get::("high_skill_count").unwrap_or(0), + "avg_skill": row.try_get::("avg_skill").unwrap_or(1000.0), + "avg_fair_play": row.try_get::("avg_fair_play").unwrap_or(100.0) }); Ok(HttpResponse::Ok().json(ApiResponse::success(response))) @@ -290,7 +314,10 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) { .route("/history/{user_id}", web::get().to(get_reputation_history)) // Admin endpoints (should be protected by admin middleware) .route("/bad-actors", web::get().to(get_bad_actors)) - .route("/bad-actors/{user_id}/remove", web::post().to(remove_bad_actor_flag)) + .route( + "/bad-actors/{user_id}/remove", + web::post().to(remove_bad_actor_flag), + ) .route("/stats", web::get().to(get_reputation_stats)), ); } diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 2738df2..627adfa 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + pub mod api_error; pub mod auth; pub mod config; @@ -5,6 +7,7 @@ pub mod db; pub mod http; pub mod middleware; pub mod models; +pub mod orchestrator; pub mod realtime; pub mod service; pub mod telemetry; diff --git a/backend/src/main.rs b/backend/src/main.rs index 41df819..0be8578 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use actix_web::{web, App, HttpServer}; use std::io; use std::sync::Arc; @@ -10,9 +12,9 @@ mod db; mod http; mod middleware; mod models; +mod orchestrator; mod realtime; mod service; -mod orchestrator; mod telemetry; use crate::config::Config; @@ -23,7 +25,8 @@ use crate::service::ReaperService; use crate::realtime::event_bus::EventBus; use crate::realtime::session_registry::SessionRegistry; use crate::realtime::ws_broadcaster::{WsAddressBook, WsBroadcaster}; -use crate::service::matchmaker::{MatchmakerService, MatchmakingConfig, EloEngine}; +use crate::service::matchmaker::{EloEngine, MatchmakerService, MatchmakingConfig}; +use crate::service::ReaperService; use crate::telemetry::init_telemetry; #[tokio::main] @@ -46,15 +49,13 @@ async fn main() -> io::Result<()> { // Create Redis client (placeholder) // let redis_client = redis::Client::open(config.redis.url.clone()).unwrap(); // Spawn tournament orchestrator polling worker - let _orchestrator_handle = crate::orchestrator::TournamentOrchestrator::spawn_polling_worker( - db_pool.clone(), - 60, - ); + let _orchestrator_handle = + crate::orchestrator::TournamentOrchestrator::spawn_polling_worker(db_pool.clone(), 60); tracing::info!("Tournament orchestrator polling worker started"); // Create Redis connection manager - let redis_client = redis::Client::open(config.redis.url.clone()) - .expect("Failed to create Redis client"); + let redis_client = + redis::Client::open(config.redis.url.clone()).expect("Failed to create Redis client"); let redis_conn = redis::aio::ConnectionManager::new(redis_client.clone()) .await .expect("Failed to create Redis connection manager"); @@ -66,7 +67,7 @@ async fn main() -> io::Result<()> { redis_client.clone(), matchmaking_config, )); - + // Start background matchmaker worker let matchmaker_worker = matchmaker_service.clone(); tokio::spawn(async move { diff --git a/backend/src/middleware.rs b/backend/src/middleware.rs deleted file mode 100644 index dd3bd16..0000000 --- a/backend/src/middleware.rs +++ /dev/null @@ -1,15 +0,0 @@ -use actix_cors::Cors; - -pub fn cors_middleware() -> Cors { - Cors::default() - .allow_any_origin() - .allow_any_method() - .allow_any_header() - .max_age(3600) -} - -// Placeholder for authentication middleware -// pub fn auth_middleware() -> impl Middleware<...> { ... } - -// Placeholder for rate limiting middleware -// pub fn rate_limit_middleware() -> impl Middleware<...> { ... } diff --git a/backend/src/middleware/mod.rs b/backend/src/middleware/mod.rs index 826c90f..0496045 100644 --- a/backend/src/middleware/mod.rs +++ b/backend/src/middleware/mod.rs @@ -2,3 +2,13 @@ pub mod idempotency_middleware; pub use idempotency_middleware::IdempotencyMiddleware; + +use actix_cors::Cors; + +pub fn cors_middleware() -> Cors { + Cors::default() + .allow_any_origin() + .allow_any_method() + .allow_any_header() + .max_age(3600) +} diff --git a/backend/src/models/match_model.rs b/backend/src/models/match_model.rs deleted file mode 100644 index d39c3bd..0000000 --- a/backend/src/models/match_model.rs +++ /dev/null @@ -1,67 +0,0 @@ -#![allow(dead_code)] - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Match { - pub id: Uuid, - pub tournament_id: Option, - pub player1_id: Uuid, - pub player2_id: Uuid, - pub game_type: String, - pub status: String, - pub winner_id: Option, - pub score_player1: Option, - pub score_player2: Option, - pub started_at: Option>, - pub completed_at: Option>, - pub created_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CreateMatchRequest { - pub tournament_id: Option, - pub player1_id: Uuid, - pub player2_id: Uuid, - pub game_type: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MatchResult { - pub match_id: Uuid, - pub winner_id: Uuid, - pub score_player1: i32, - pub score_player2: i32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MatchResponse { - #[serde(flatten)] - pub match_data: Match, - pub player1_username: String, - pub player2_username: String, - pub tournament_name: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum MatchStatus { - Pending, - InProgress, - Completed, - Disputed, - Cancelled, -} - -impl std::fmt::Display for MatchStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - MatchStatus::Pending => write!(f, "pending"), - MatchStatus::InProgress => write!(f, "in_progress"), - MatchStatus::Completed => write!(f, "completed"), - MatchStatus::Disputed => write!(f, "disputed"), - MatchStatus::Cancelled => write!(f, "cancelled"), - } - } -} diff --git a/backend/src/models/matchmaker.rs b/backend/src/models/matchmaker.rs index cb5143b..e2a1410 100644 --- a/backend/src/models/matchmaker.rs +++ b/backend/src/models/matchmaker.rs @@ -95,7 +95,7 @@ pub struct MatchmakingConfig { pub elo_bucket_size: i32, pub max_elo_gap: i32, pub expansion_intervals: Vec, // in seconds - pub max_wait_time: i64, // in seconds + pub max_wait_time: i64, // in seconds pub min_players_per_match: usize, pub max_players_per_match: usize, } @@ -106,7 +106,7 @@ impl Default for MatchmakingConfig { elo_bucket_size: 100, max_elo_gap: 500, expansion_intervals: vec![30, 60, 120, 300], // 30s, 1m, 2m, 5m - max_wait_time: 600, // 10 minutes + max_wait_time: 600, // 10 minutes min_players_per_match: 2, max_players_per_match: 2, } diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 1a00d6e..f2cf3b7 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -20,11 +20,9 @@ pub use match_models::{ ReportScoreRequest, UserElo, }; pub use matchmaker::{ - DisputeStatus, EloHistory, EloResponse, GameModeStats, GameQueueStats, JoinQueueRequest, - JoinQueueResponse, LeaveQueueRequest, LeaveQueueResponse, Match, MatchCandidate, MatchDispute, - MatchHistoryResponse, MatchmakingConfig, MatchmakingQueue, MatchmakingQueueResponse, - MatchmakingStats, MatchmakingStatsResponse, MatchmakingStatusResponse, MatchResult, MatchScore, - MatchStatus, MatchType, PlayerInfo, QueueEntry, QueueStatus, ReportScoreRequest, UserElo, + GameModeStats, GameQueueStats, JoinQueueRequest, JoinQueueResponse, LeaveQueueRequest, + LeaveQueueResponse, MatchCandidate, MatchHistoryResponse, MatchmakingConfig, + MatchmakingQueueResponse, MatchmakingStats, QueueEntry, }; pub use reward_settlement::*; pub use stellar_account::{ diff --git a/backend/src/models/wallet.rs b/backend/src/models/wallet.rs index 821d338..6c527b2 100644 --- a/backend/src/models/wallet.rs +++ b/backend/src/models/wallet.rs @@ -106,7 +106,8 @@ impl std::fmt::Display for TransactionStatus { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "text", rename_all = "snake_case")] pub enum PaymentProvider { Paystack, Flutterwave, @@ -210,7 +211,6 @@ pub struct TransactionResponse { #[derive(Debug, Serialize, Deserialize, Validate)] pub struct DepositRequest { - #[validate(range(min = 1))] pub amount: Decimal, #[validate(length(min = 3, max = 10))] pub currency: String, // "NGN", "XLM", "ARENAX_TOKEN" @@ -219,7 +219,6 @@ pub struct DepositRequest { #[derive(Debug, Serialize, Deserialize, Validate)] pub struct WithdrawalRequest { - #[validate(range(min = 1))] pub amount: Decimal, #[validate(length(min = 3, max = 10))] pub currency: String, diff --git a/backend/src/orchestrator/payout_settler.rs b/backend/src/orchestrator/payout_settler.rs index ed25dab..d28ba55 100644 --- a/backend/src/orchestrator/payout_settler.rs +++ b/backend/src/orchestrator/payout_settler.rs @@ -17,14 +17,12 @@ impl PayoutSettler { /// Called when a tournament completes. Computes rankings and distributes prizes idempotently. pub async fn finalize_tournament(&self, tournament_id: Uuid) -> Result<(), ApiError> { // Step 1: Verify tournament status is "completed" (case-insensitive). - let tournament_row = sqlx::query( - "SELECT status FROM tournaments WHERE id = $1", - ) - .bind(tournament_id) - .fetch_optional(&self.db_pool) - .await - .map_err(ApiError::database_error)? - .ok_or_else(|| ApiError::not_found("Tournament not found"))?; + let tournament_row = sqlx::query("SELECT status FROM tournaments WHERE id = $1") + .bind(tournament_id) + .fetch_optional(&self.db_pool) + .await + .map_err(ApiError::database_error)? + .ok_or_else(|| ApiError::not_found("Tournament not found"))?; let status: String = tournament_row .try_get("status") @@ -37,7 +35,11 @@ impl PayoutSettler { } // Step 2: Begin transaction and lock the tournament row to prevent concurrent payout races. - let mut tx = self.db_pool.begin().await.map_err(ApiError::database_error)?; + let mut tx = self + .db_pool + .begin() + .await + .map_err(ApiError::database_error)?; sqlx::query("SELECT id FROM tournaments WHERE id = $1 FOR UPDATE") .bind(tournament_id) @@ -96,11 +98,15 @@ impl PayoutSettler { .map_err(ApiError::database_error)?; // Step 6: Parse distribution_percentages from JSON string (e.g., "[50, 30, 20]"). - let percentages: Vec = serde_json::from_str(&distribution_percentages_str) - .map_err(|e| ApiError::bad_request(format!("Invalid distribution_percentages JSON: {}", e)))?; + let percentages: Vec = + serde_json::from_str(&distribution_percentages_str).map_err(|e| { + ApiError::bad_request(format!("Invalid distribution_percentages JSON: {}", e)) + })?; if percentages.is_empty() { - return Err(ApiError::bad_request("distribution_percentages must not be empty")); + return Err(ApiError::bad_request( + "distribution_percentages must not be empty", + )); } // Step 7: Get ranked participants ordered by final_rank ASC. @@ -124,7 +130,9 @@ impl PayoutSettler { for i in 0..num_recipients { let row = &participant_rows[i]; let user_id: Uuid = row.try_get("user_id").map_err(ApiError::database_error)?; - let rank: i32 = row.try_get("final_rank").map_err(ApiError::database_error)?; + let rank: i32 = row + .try_get("final_rank") + .map_err(ApiError::database_error)?; let percentage = percentages[i]; let total_dec = rust_decimal::Decimal::from(total_amount); @@ -246,9 +254,12 @@ impl PayoutSettler { let mut loser_id: Option = None; for m in &match_rows { - let w: Option = m.try_get("winner_id").map_err(ApiError::database_error)?; - let p1: Option = m.try_get("player1_id").map_err(ApiError::database_error)?; - let p2: Option = m.try_get("player2_id").map_err(ApiError::database_error)?; + let w: Option = + m.try_get("winner_id").map_err(ApiError::database_error)?; + let p1: Option = + m.try_get("player1_id").map_err(ApiError::database_error)?; + let p2: Option = + m.try_get("player2_id").map_err(ApiError::database_error)?; if let Some(w_id) = w { winner_id = Some(w_id); @@ -289,9 +300,12 @@ impl PayoutSettler { let mut loser_count = 0i32; for m in &match_rows { - let w: Option = m.try_get("winner_id").map_err(ApiError::database_error)?; - let p1: Option = m.try_get("player1_id").map_err(ApiError::database_error)?; - let p2: Option = m.try_get("player2_id").map_err(ApiError::database_error)?; + let w: Option = + m.try_get("winner_id").map_err(ApiError::database_error)?; + let p1: Option = + m.try_get("player1_id").map_err(ApiError::database_error)?; + let p2: Option = + m.try_get("player2_id").map_err(ApiError::database_error)?; // Only real matches (both players present) produce a loser. if p2.is_none() { diff --git a/backend/src/orchestrator/round_advancement.rs b/backend/src/orchestrator/round_advancement.rs index e7da14a..ca4238b 100644 --- a/backend/src/orchestrator/round_advancement.rs +++ b/backend/src/orchestrator/round_advancement.rs @@ -45,14 +45,12 @@ impl RoundAdvancementWorker { } // Step 2: Fetch the current round to get the round_number. - let round_row = sqlx::query( - "SELECT round_number FROM tournament_rounds WHERE id = $1", - ) - .bind(round_id) - .fetch_optional(&self.db_pool) - .await - .map_err(ApiError::database_error)? - .ok_or_else(|| ApiError::not_found("Round not found"))?; + let round_row = sqlx::query("SELECT round_number FROM tournament_rounds WHERE id = $1") + .bind(round_id) + .fetch_optional(&self.db_pool) + .await + .map_err(ApiError::database_error)? + .ok_or_else(|| ApiError::not_found("Round not found"))?; let current_round_number: i32 = round_row .try_get("round_number") @@ -110,10 +108,12 @@ impl RoundAdvancementWorker { for row in &match_rows { let status: String = row.try_get("status").map_err(ApiError::database_error)?; - let player1_id: Option = - row.try_get("player1_id").map_err(ApiError::database_error)?; - let player2_id: Option = - row.try_get("player2_id").map_err(ApiError::database_error)?; + let player1_id: Option = row + .try_get("player1_id") + .map_err(ApiError::database_error)?; + let player2_id: Option = row + .try_get("player2_id") + .map_err(ApiError::database_error)?; let winner_id: Option = row.try_get("winner_id").map_err(ApiError::database_error)?; @@ -200,7 +200,7 @@ impl RoundAdvancementWorker { // Pairing then proceeds from index 1 onwards: idx 1 vs 2, idx 3 vs 4, etc. let now = Utc::now(); let num_players = advancing_players.len(); - let has_bye = num_players % 2 != 0; + let has_bye = !num_players.is_multiple_of(2); // The bye player is always advancing_players[0] when the count is odd. // Paired players start at index 1 (odd count) or 0 (even count). diff --git a/backend/src/orchestrator/seeding_engine.rs b/backend/src/orchestrator/seeding_engine.rs index 1f64348..c31112f 100644 --- a/backend/src/orchestrator/seeding_engine.rs +++ b/backend/src/orchestrator/seeding_engine.rs @@ -15,19 +15,14 @@ impl SeedingEngine { /// Seeds participants by Elo and generates the initial single-elimination bracket. /// Tournament must be in RegistrationClosed status with 4-64 participants. - pub async fn seed_and_generate_bracket( - &self, - tournament_id: Uuid, - ) -> Result<(), ApiError> { + pub async fn seed_and_generate_bracket(&self, tournament_id: Uuid) -> Result<(), ApiError> { // Validate tournament status - let row = sqlx::query( - "SELECT status, game, bracket_type FROM tournaments WHERE id = $1", - ) - .bind(tournament_id) - .fetch_optional(&self.db_pool) - .await - .map_err(ApiError::database_error)? - .ok_or_else(|| ApiError::not_found("Tournament not found"))?; + let row = sqlx::query("SELECT status, game, bracket_type FROM tournaments WHERE id = $1") + .bind(tournament_id) + .fetch_optional(&self.db_pool) + .await + .map_err(ApiError::database_error)? + .ok_or_else(|| ApiError::not_found("Tournament not found"))?; let status: String = row.try_get("status").map_err(ApiError::database_error)?; if status != "registration_closed" && status != "in_progress" { @@ -37,8 +32,12 @@ impl SeedingEngine { } // Only single elimination is supported - let bracket_type: String = row.try_get("bracket_type").map_err(ApiError::database_error)?; - if bracket_type.to_lowercase() != "singleelimination" && bracket_type.to_lowercase() != "single_elimination" { + let bracket_type: String = row + .try_get("bracket_type") + .map_err(ApiError::database_error)?; + if bracket_type.to_lowercase() != "singleelimination" + && bracket_type.to_lowercase() != "single_elimination" + { return Err(ApiError::bad_request( "Only SingleElimination bracket type is currently supported for automated seeding", )); @@ -71,9 +70,7 @@ impl SeedingEngine { )); } if n > 64 { - return Err(ApiError::bad_request( - "Maximum 64 participants allowed", - )); + return Err(ApiError::bad_request("Maximum 64 participants allowed")); } // Assign seed numbers (1 = highest Elo) @@ -96,7 +93,11 @@ impl SeedingEngine { let seeding_order = generate_bracket_order(bracket_size); // Create round 1 - let round_type = if bracket_size == 2 { "final" } else { "elimination" }; + let round_type = if bracket_size == 2 { + "final" + } else { + "elimination" + }; let round_row = sqlx::query( r#" INSERT INTO tournament_rounds ( @@ -118,7 +119,7 @@ impl SeedingEngine { let num_matches = bracket_size / 2; for match_idx in 0..num_matches { - let seed_a = seeding_order[match_idx * 2]; // 1-indexed seed + let seed_a = seeding_order[match_idx * 2]; // 1-indexed seed let seed_b = seeding_order[match_idx * 2 + 1]; // 1-indexed seed // Seeds beyond participant count are byes @@ -144,14 +145,12 @@ impl SeedingEngine { let match_number = (match_idx + 1) as i32; // For byes, ensure the real player is player1 - let (p1, p2): (Uuid, Option) = - if player_a_id.is_some() && player_b_id.is_some() { - (player_a_id.unwrap(), player_b_id) - } else if player_a_id.is_some() { - (player_a_id.unwrap(), None) - } else { - (player_b_id.unwrap(), None) - }; + let (p1, p2): (Uuid, Option) = match (player_a_id, player_b_id) { + (Some(a), Some(b)) => (a, Some(b)), + (Some(a), None) => (a, None), + (None, Some(b)) => (b, None), + (None, None) => panic!("Both player_a_id and player_b_id are None"), + }; sqlx::query( r#" @@ -176,14 +175,12 @@ impl SeedingEngine { } // Update tournament status to InProgress - sqlx::query( - "UPDATE tournaments SET status = 'in_progress', updated_at = $2 WHERE id = $1", - ) - .bind(tournament_id) - .bind(Utc::now()) - .execute(&self.db_pool) - .await - .map_err(ApiError::database_error)?; + sqlx::query("UPDATE tournaments SET status = 'in_progress', updated_at = $2 WHERE id = $1") + .bind(tournament_id) + .bind(Utc::now()) + .execute(&self.db_pool) + .await + .map_err(ApiError::database_error)?; Ok(()) } diff --git a/backend/src/orchestrator/tournament_cleanup.rs b/backend/src/orchestrator/tournament_cleanup.rs index 5e61d56..b0fea8b 100644 --- a/backend/src/orchestrator/tournament_cleanup.rs +++ b/backend/src/orchestrator/tournament_cleanup.rs @@ -49,8 +49,11 @@ impl TournamentCleanup { .bind(tournament_id) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e)) - .and_then(|row| row.try_get::("cleaned").map_err(|e| ApiError::database_error(e))) + .map_err(ApiError::database_error) + .and_then(|row| { + row.try_get::("cleaned") + .map_err(ApiError::database_error) + }) .unwrap_or(false); if already_cleaned { @@ -96,7 +99,11 @@ impl TournamentCleanup { if payout_count == 0 { let payout = crate::orchestrator::PayoutSettler::new(self.db_pool.clone()); if let Err(e) = payout.finalize_tournament(tournament_id).await { - tracing::error!("Payout re-trigger failed for tournament {}: {}", tournament_id, e); + tracing::error!( + "Payout re-trigger failed for tournament {}: {}", + tournament_id, + e + ); } } @@ -229,8 +236,7 @@ impl TournamentCleanup { } // Step 2: Count stale tournaments (in_progress longer than stale_tournament_days). - let stale_cutoff = - Utc::now() - chrono::Duration::days(self.config.stale_tournament_days); + let stale_cutoff = Utc::now() - chrono::Duration::days(self.config.stale_tournament_days); let stale_row = sqlx::query( r#" diff --git a/backend/src/orchestrator/tournament_orchestrator.rs b/backend/src/orchestrator/tournament_orchestrator.rs index 47bc229..a54e3fd 100644 --- a/backend/src/orchestrator/tournament_orchestrator.rs +++ b/backend/src/orchestrator/tournament_orchestrator.rs @@ -23,7 +23,10 @@ impl TournamentOrchestrator { } } - pub fn spawn_polling_worker(db_pool: DbPool, interval_secs: u64) -> tokio::task::JoinHandle<()> { + pub fn spawn_polling_worker( + db_pool: DbPool, + interval_secs: u64, + ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { let advancement = RoundAdvancementWorker::new(db_pool.clone()); let payout = PayoutSettler::new(db_pool.clone()); @@ -62,9 +65,11 @@ impl TournamentOrchestrator { tracing::error!("Polling: cleanup error: {}", e); } - let _ = sqlx::query("SELECT pg_advisory_unlock(hashtext('tournament_orchestrator_poll'))") - .execute(&db_pool) - .await; + let _ = sqlx::query( + "SELECT pg_advisory_unlock(hashtext('tournament_orchestrator_poll'))", + ) + .execute(&db_pool) + .await; } }) } diff --git a/backend/src/realtime/event_bus.rs b/backend/src/realtime/event_bus.rs index bfaad01..f321cab 100644 --- a/backend/src/realtime/event_bus.rs +++ b/backend/src/realtime/event_bus.rs @@ -1,7 +1,7 @@ use crate::realtime::events::{channels, RealtimeEvent}; use redis::aio::ConnectionManager; use redis::AsyncCommands; -use tracing::{error, debug}; +use tracing::{debug, error}; use uuid::Uuid; /// Publishes domain events to Redis Pub/Sub channels. diff --git a/backend/src/realtime/mod.rs b/backend/src/realtime/mod.rs index 90747be..0e9447e 100644 --- a/backend/src/realtime/mod.rs +++ b/backend/src/realtime/mod.rs @@ -1,10 +1,10 @@ -pub mod events; pub mod event_bus; -pub mod ws_broadcaster; -pub mod user_ws; +pub mod events; pub mod session_registry; pub mod auth; +pub mod user_ws; +pub mod ws_broadcaster; -pub use events::*; pub use event_bus::EventBus; +pub use events::*; pub use session_registry::SessionRegistry; diff --git a/backend/src/realtime/session_registry.rs b/backend/src/realtime/session_registry.rs index d674364..11aee04 100644 --- a/backend/src/realtime/session_registry.rs +++ b/backend/src/realtime/session_registry.rs @@ -10,6 +10,12 @@ pub struct SessionRegistry { session_to_channels: RwLock>>, } +impl Default for SessionRegistry { + fn default() -> Self { + Self::new() + } +} + impl SessionRegistry { pub fn new() -> Self { Self { @@ -82,7 +88,7 @@ impl SessionRegistry { pub fn get_sessions(&self, user_id: &Uuid) -> Vec { let map = self.user_to_sessions.read().unwrap(); map.get(user_id) - .map(|s| s.iter().copied().collect()) + .map(|s: &HashSet| s.iter().copied().collect::>()) .unwrap_or_default() } diff --git a/backend/src/realtime/ws_broadcaster.rs b/backend/src/realtime/ws_broadcaster.rs index 1fda483..9f01f5d 100644 --- a/backend/src/realtime/ws_broadcaster.rs +++ b/backend/src/realtime/ws_broadcaster.rs @@ -16,6 +16,12 @@ pub struct WsAddressBook { inner: RwLock>>, } +impl Default for WsAddressBook { + fn default() -> Self { + Self::new() + } +} + impl WsAddressBook { pub fn new() -> Self { Self { diff --git a/backend/src/service/auth_service.rs b/backend/src/service/auth_service.rs index c711542..fe4f89c 100644 --- a/backend/src/service/auth_service.rs +++ b/backend/src/service/auth_service.rs @@ -1,33 +1,271 @@ #![allow(dead_code)] use crate::api_error::ApiError; +use crate::auth::jwt_service::{JwtService, TokenPair}; use crate::db::DbPool; -use crate::models::user::{User, CreateUserRequest, LoginRequest, AuthResponse}; +use crate::models::user::{AuthResponse, CreateUserRequest, LoginRequest, User, UserProfile}; +use bcrypt::{hash, verify, DEFAULT_COST}; +use chrono::Utc; +use tracing::info; use uuid::Uuid; +/// Authentication Service with JWT integration #[derive(Clone)] pub struct AuthService { - #[allow(dead_code)] pool: DbPool, + jwt_service: JwtService, } impl AuthService { - pub fn new(pool: DbPool) -> Self { - Self { pool } + pub fn new(pool: DbPool, jwt_service: JwtService) -> Self { + Self { pool, jwt_service } } - pub async fn register(&self, _request: CreateUserRequest) -> Result { - // TODO: Implement user registration with database and JWT - Err(ApiError::internal_error("Auth service not yet implemented")) + /// Register a new user + pub async fn register(&self, request: CreateUserRequest) -> Result { + if request.username.is_empty() || request.password.is_empty() { + return Err(ApiError::bad_request("Username and password are required")); + } + + if request.password.len() < 8 { + return Err(ApiError::bad_request( + "Password must be at least 8 characters", + )); + } + + // Check if user already exists + let existing = sqlx::query("SELECT id FROM users WHERE email = $1 OR username = $2") + .bind(&request.email) + .bind(&request.username) + .fetch_optional(&self.pool) + .await + .map_err(ApiError::database_error)?; + + if existing.is_some() { + return Err(ApiError::bad_request( + "User with this email or username already exists", + )); + } + + // Hash password + let password_hash = hash(&request.password, DEFAULT_COST) + .map_err(|e| ApiError::internal_error(format!("Password hashing failed: {}", e)))?; + + // Create user + let user_id = Uuid::new_v4(); + let now = Utc::now(); + + let user = sqlx::query_as::<_, User>( + r#" + INSERT INTO users ( + id, username, email, phone_number, password_hash, is_active, is_verified, + role, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, true, false, 'user', $6, $7) + RETURNING * + "#, + ) + .bind(user_id) + .bind(&request.username) + .bind(&request.email) + .bind(&request.phone_number) + .bind(&password_hash) + .bind(now) + .bind(now) + .fetch_one(&self.pool) + .await + .map_err(ApiError::database_error)?; + + // Generate JWT tokens + let roles = vec!["user".to_string()]; + let token_pair = self + .jwt_service + .generate_token_pair(user.id, roles, None) + .await + .map_err(|e| ApiError::internal_error(format!("Token generation failed: {}", e)))?; + + info!(user_id = %user.id, username = %user.username, "User registered successfully"); + + Ok(AuthResponse { + token: token_pair.access_token, + refresh_token: token_pair.refresh_token, + user: UserProfile { + id: user.id, + username: user.username.clone(), + email: user.email.clone(), + display_name: user.display_name.clone(), + avatar_url: user.avatar_url.clone(), + is_verified: user.is_verified, + created_at: user.created_at, + skill_score: None, + fair_play_score: None, + is_bad_actor: None, + }, + }) + } + + /// Login user and return JWT tokens + pub async fn login(&self, request: LoginRequest) -> Result { + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1") + .bind(&request.email) + .fetch_optional(&self.pool) + .await + .map_err(ApiError::database_error)? + .ok_or_else(|| ApiError::unauthorized("Invalid email or password"))?; + + if !user.is_active { + return Err(ApiError::forbidden("Account is deactivated")); + } + + // Verify password + let pw_hash = user + .password_hash + .as_deref() + .ok_or_else(|| ApiError::internal_error("User has no password set"))?; + + let valid = verify(&request.password, pw_hash).map_err(|e| { + ApiError::internal_error(format!("Password verification failed: {}", e)) + })?; + + if !valid { + return Err(ApiError::unauthorized("Invalid email or password")); + } + + // Update last login + sqlx::query("UPDATE users SET last_login_at = $1 WHERE id = $2") + .bind(Utc::now()) + .bind(user.id) + .execute(&self.pool) + .await + .map_err(ApiError::database_error)?; + + // Generate JWT tokens + let roles = vec![user.role.clone()]; + let token_pair = self + .jwt_service + .generate_token_pair(user.id, roles, None) + .await + .map_err(|e| ApiError::internal_error(format!("Token generation failed: {}", e)))?; + + info!(user_id = %user.id, username = %user.username, "User logged in successfully"); + + Ok(AuthResponse { + token: token_pair.access_token, + refresh_token: token_pair.refresh_token, + user: UserProfile { + id: user.id, + username: user.username.clone(), + email: user.email.clone(), + display_name: user.display_name.clone(), + avatar_url: user.avatar_url.clone(), + is_verified: user.is_verified, + created_at: user.created_at, + skill_score: None, + fair_play_score: None, + is_bad_actor: None, + }, + }) + } + + /// Verify JWT token and return user ID + pub async fn verify_token(&self, token: &str) -> Result { + let claims = self + .jwt_service + .validate_token(token) + .await + .map_err(|e| ApiError::unauthorized(format!("Token validation failed: {}", e)))?; + + let user_id = Uuid::parse_str(&claims.sub) + .map_err(|e| ApiError::internal_error(format!("Invalid user ID in token: {}", e)))?; + + Ok(user_id) } - pub async fn login(&self, _request: LoginRequest) -> Result { - // TODO: Implement user login with password verification - Err(ApiError::internal_error("Auth service not yet implemented")) + /// Refresh access token + pub async fn refresh_token(&self, refresh_token: &str) -> Result { + self.jwt_service + .refresh_token(refresh_token) + .await + .map_err(|e| ApiError::unauthorized(format!("Token refresh failed: {}", e))) } - pub fn verify_token(&self, _token: &str) -> Result { - // TODO: Implement JWT token verification - Err(ApiError::internal_error("Token verification not yet implemented")) + /// Logout user (blacklist token) + pub async fn logout(&self, token: &str) -> Result<(), ApiError> { + self.jwt_service + .blacklist_token(token, "User logout") + .await + .map_err(|e| ApiError::internal_error(format!("Logout failed: {}", e)))?; + + Ok(()) } -} \ No newline at end of file + + /// Revoke all user sessions + pub async fn revoke_all_sessions(&self, user_id: Uuid) -> Result { + let count = self + .jwt_service + .revoke_user_sessions(user_id) + .await + .map_err(|e| ApiError::internal_error(format!("Session revocation failed: {}", e)))?; + + info!(user_id = %user_id, count = count, "All user sessions revoked"); + + Ok(count) + } + + /// Get user by ID + pub async fn get_user(&self, user_id: Uuid) -> Result { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(user_id) + .fetch_optional(&self.pool) + .await + .map_err(ApiError::database_error)? + .ok_or_else(|| ApiError::not_found("User not found")) + } + + /// Change user password + pub async fn change_password( + &self, + user_id: Uuid, + old_password: &str, + new_password: &str, + ) -> Result<(), ApiError> { + if new_password.len() < 8 { + return Err(ApiError::bad_request( + "Password must be at least 8 characters", + )); + } + + let user = self.get_user(user_id).await?; + + let pw_hash = user + .password_hash + .as_deref() + .ok_or_else(|| ApiError::internal_error("User has no password set"))?; + + let valid = verify(old_password, pw_hash).map_err(|e| { + ApiError::internal_error(format!("Password verification failed: {}", e)) + })?; + + if !valid { + return Err(ApiError::unauthorized("Current password is incorrect")); + } + + let new_hash = hash(new_password, DEFAULT_COST) + .map_err(|e| ApiError::internal_error(format!("Password hashing failed: {}", e)))?; + + sqlx::query("UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3") + .bind(&new_hash) + .bind(Utc::now()) + .bind(user_id) + .execute(&self.pool) + .await + .map_err(ApiError::database_error)?; + + // Revoke all existing sessions for security + self.revoke_all_sessions(user_id).await?; + + info!(user_id = %user_id, "Password changed successfully"); + + Ok(()) + } +} diff --git a/backend/src/service/auth_service_updated.rs b/backend/src/service/auth_service_updated.rs deleted file mode 100644 index 02f6ce2..0000000 --- a/backend/src/service/auth_service_updated.rs +++ /dev/null @@ -1,298 +0,0 @@ -use crate::api_error::ApiError; -use crate::auth::jwt_service::{JwtService, TokenPair}; -use crate::db::DbPool; -use crate::models::user::{AuthResponse, CreateUserRequest, LoginRequest, User, UserProfile}; -use bcrypt::{hash, verify, DEFAULT_COST}; -use chrono::Utc; -use sqlx::Row; -use tracing::{error, info}; -use uuid::Uuid; - -/// Enhanced Authentication Service with JWT integration -#[derive(Clone)] -pub struct AuthService { - pool: DbPool, - jwt_service: JwtService, -} - -impl AuthService { - pub fn new(pool: DbPool, jwt_service: JwtService) -> Self { - Self { pool, jwt_service } - } - - /// Register a new user - pub async fn register(&self, request: CreateUserRequest) -> Result { - // Validate input - if request.username.is_empty() || request.email.is_empty() || request.password.is_empty() - { - return Err(ApiError::bad_request("All fields are required")); - } - - if request.password.len() < 8 { - return Err(ApiError::bad_request( - "Password must be at least 8 characters", - )); - } - - // Check if user already exists - let existing = sqlx::query!( - "SELECT id FROM users WHERE email = $1 OR username = $2", - request.email, - request.username - ) - .fetch_optional(&self.pool) - .await - .map_err(|e| ApiError::database_error(e))?; - - if existing.is_some() { - return Err(ApiError::bad_request( - "User with this email or username already exists", - )); - } - - // Hash password - let password_hash = hash(&request.password, DEFAULT_COST) - .map_err(|e| ApiError::internal_error(format!("Password hashing failed: {}", e)))?; - - // Create user - let user_id = Uuid::new_v4(); - let now = Utc::now(); - - let user = sqlx::query_as!( - User, - r#" - INSERT INTO users ( - id, username, email, password_hash, is_active, is_verified, created_at, updated_at - ) - VALUES ($1, $2, $3, $4, true, false, $5, $6) - RETURNING id, username, email, password_hash, is_active, is_verified, created_at, updated_at - "#, - user_id, - request.username, - request.email, - password_hash, - now, - now - ) - .fetch_one(&self.pool) - .await - .map_err(|e| ApiError::database_error(e))?; - - // Generate JWT tokens - let roles = vec!["user".to_string()]; - let token_pair = self - .jwt_service - .generate_token_pair(user.id, roles, None) - .await - .map_err(|e| ApiError::internal_error(format!("Token generation failed: {}", e)))?; - - info!(user_id = %user.id, username = %user.username, "User registered successfully"); - - Ok(AuthResponse { - token: token_pair.access_token, - refresh_token: token_pair.refresh_token, - user: UserProfile { - id: user.id, - username: user.username, - email: user.email, - is_verified: user.is_verified, - created_at: user.created_at, - skill_score: None, - fair_play_score: None, - is_bad_actor: None, - }, - }) - } - - /// Login user and return JWT tokens - pub async fn login(&self, request: LoginRequest) -> Result { - // Find user by email - let user = sqlx::query_as!( - User, - "SELECT id, username, email, password_hash, is_active, is_verified, created_at, updated_at, last_login_at, display_name, avatar_url, country_code, role, bio, phone_number, is_banned, device_fingerprint, stellar_account_id, stellar_public_key, total_earnings, banned_until, profile_image_url, reputation_score, skill_score, fair_play_score, is_bad_actor FROM users WHERE email = $1", - request.email - ) - .fetch_optional(&self.pool) - .await - .map_err(|e| ApiError::database_error(e))? - .ok_or_else(|| ApiError::unauthorized("Invalid email or password"))?; - - // Check if user is active - if !user.is_active { - return Err(ApiError::forbidden("Account is deactivated")); - } - - // Verify password - let valid = verify(&request.password, &user.password_hash) - .map_err(|e| ApiError::internal_error(format!("Password verification failed: {}", e)))?; - - if !valid { - return Err(ApiError::unauthorized("Invalid email or password")); - } - - // Update last login - sqlx::query!( - "UPDATE users SET last_login_at = $1 WHERE id = $2", - Utc::now(), - user.id - ) - .execute(&self.pool) - .await - .map_err(|e| ApiError::database_error(e))?; - - // Generate JWT tokens - let roles = vec!["user".to_string()]; // Could fetch from database - let token_pair = self - .jwt_service - .generate_token_pair(user.id, roles, None) - .await - .map_err(|e| ApiError::internal_error(format!("Token generation failed: {}", e)))?; - - info!(user_id = %user.id, username = %user.username, "User logged in successfully"); - - Ok(AuthResponse { - token: token_pair.access_token, - refresh_token: token_pair.refresh_token, - user: UserProfile { - id: user.id, - username: user.username, - email: user.email, - is_verified: user.is_verified, - created_at: user.created_at, - skill_score: None, - fair_play_score: None, - is_bad_actor: None, - }, - }) - } - - /// Verify JWT token and return user ID - pub async fn verify_token(&self, token: &str) -> Result { - let claims = self - .jwt_service - .validate_token(token) - .await - .map_err(|e| ApiError::unauthorized(format!("Token validation failed: {}", e)))?; - - let user_id = Uuid::parse_str(&claims.sub) - .map_err(|e| ApiError::internal_error(format!("Invalid user ID in token: {}", e)))?; - - Ok(user_id) - } - - /// Refresh access token - pub async fn refresh_token(&self, refresh_token: &str) -> Result { - self.jwt_service - .refresh_token(refresh_token) - .await - .map_err(|e| ApiError::unauthorized(format!("Token refresh failed: {}", e))) - } - - /// Logout user (blacklist token) - pub async fn logout(&self, token: &str) -> Result<(), ApiError> { - self.jwt_service - .blacklist_token(token, "User logout") - .await - .map_err(|e| ApiError::internal_error(format!("Logout failed: {}", e)))?; - - info!("User logged out successfully"); - - Ok(()) - } - - /// Revoke all user sessions - pub async fn revoke_all_sessions(&self, user_id: Uuid) -> Result { - let count = self - .jwt_service - .revoke_user_sessions(user_id) - .await - .map_err(|e| ApiError::internal_error(format!("Session revocation failed: {}", e)))?; - - info!(user_id = %user_id, count = count, "All user sessions revoked"); - - Ok(count) - } - - /// Get user by ID - pub async fn get_user(&self, user_id: Uuid) -> Result { - sqlx::query_as!( - User, - "SELECT id, username, email, password_hash, is_active, is_verified, created_at, updated_at FROM users WHERE id = $1", - user_id - ) - .fetch_optional(&self.pool) - .await - .map_err(|e| ApiError::database_error(e))? - .ok_or_else(|| ApiError::not_found("User not found")) - } - - /// Change user password - pub async fn change_password( - &self, - user_id: Uuid, - old_password: &str, - new_password: &str, - ) -> Result<(), ApiError> { - if new_password.len() < 8 { - return Err(ApiError::bad_request( - "Password must be at least 8 characters", - )); - } - - // Get user - let user = self.get_user(user_id).await?; - - // Verify old password - let valid = verify(old_password, &user.password_hash) - .map_err(|e| ApiError::internal_error(format!("Password verification failed: {}", e)))?; - - if !valid { - return Err(ApiError::unauthorized("Current password is incorrect")); - } - - // Hash new password - let new_hash = hash(new_password, DEFAULT_COST) - .map_err(|e| ApiError::internal_error(format!("Password hashing failed: {}", e)))?; - - // Update password - sqlx::query!( - "UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3", - new_hash, - Utc::now(), - user_id - ) - .execute(&self.pool) - .await - .map_err(|e| ApiError::database_error(e))?; - - // Revoke all existing sessions for security - self.revoke_all_sessions(user_id).await?; - - info!(user_id = %user_id, "Password changed successfully"); - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_password_validation() { - let short_password = "short"; - assert!(short_password.len() < 8); - - let valid_password = "long_enough_password"; - assert!(valid_password.len() >= 8); - } - - #[test] - fn test_bcrypt_hashing() { - let password = "test_password"; - let hashed = hash(password, DEFAULT_COST).unwrap(); - - assert!(verify(password, &hashed).unwrap()); - assert!(!verify("wrong_password", &hashed).unwrap()); - } -} diff --git a/backend/src/service/governance_service.rs b/backend/src/service/governance_service.rs index 31adf17..de63e01 100644 --- a/backend/src/service/governance_service.rs +++ b/backend/src/service/governance_service.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use sqlx::PgPool; use thiserror::Error; -use tracing::{debug, error, info, warn}; +use tracing::{debug, info, warn}; use uuid::Uuid; use super::soroban_service::{SorobanService, SorobanTxResult, TxStatus}; @@ -73,7 +73,7 @@ pub struct CreateProposalDto { } /// DTO for a proposal record -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct ProposalRecord { pub id: Uuid, pub proposal_id: String, @@ -90,7 +90,7 @@ pub struct ProposalRecord { } /// DTO for an approval record -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct ApprovalRecord { pub id: Uuid, pub proposal_id: String, @@ -489,8 +489,7 @@ impl GovernanceService { &self, proposal_id: &str, ) -> Result, GovernanceServiceError> { - let record = sqlx::query_as!( - ProposalRecord, + let record = sqlx::query_as::<_, ProposalRecord>( r#" SELECT id, @@ -499,7 +498,7 @@ impl GovernanceService { function, args, description, - status as "status: _", + status, proposer, created_at, execute_after, @@ -508,8 +507,8 @@ impl GovernanceService { FROM governance_proposals WHERE proposal_id = $1 "#, - proposal_id ) + .bind(proposal_id) .fetch_optional(&self.pool) .await?; @@ -525,8 +524,7 @@ impl GovernanceService { ) -> Result, GovernanceServiceError> { let records = match status { Some(s) => { - sqlx::query_as!( - ProposalRecord, + sqlx::query_as::<_, ProposalRecord>( r#" SELECT id, @@ -535,7 +533,7 @@ impl GovernanceService { function, args, description, - status as "status: _", + status, proposer, created_at, execute_after, @@ -546,16 +544,15 @@ impl GovernanceService { ORDER BY created_at DESC LIMIT $2 OFFSET $3 "#, - s.to_string(), - limit, - offset ) + .bind(s.to_string()) + .bind(limit) + .bind(offset) .fetch_all(&self.pool) .await? } None => { - sqlx::query_as!( - ProposalRecord, + sqlx::query_as::<_, ProposalRecord>( r#" SELECT id, @@ -564,7 +561,7 @@ impl GovernanceService { function, args, description, - status as "status: _", + status, proposer, created_at, execute_after, @@ -574,9 +571,9 @@ impl GovernanceService { ORDER BY created_at DESC LIMIT $1 OFFSET $2 "#, - limit, - offset ) + .bind(limit) + .bind(offset) .fetch_all(&self.pool) .await? } @@ -590,16 +587,15 @@ impl GovernanceService { &self, proposal_id: &str, ) -> Result, GovernanceServiceError> { - let records = sqlx::query_as!( - ApprovalRecord, + let records = sqlx::query_as::<_, ApprovalRecord>( r#" SELECT id, proposal_id, signer, chain_tx, approved_at FROM governance_approvals WHERE proposal_id = $1 ORDER BY approved_at ASC "#, - proposal_id ) + .bind(proposal_id) .fetch_all(&self.pool) .await?; @@ -608,7 +604,7 @@ impl GovernanceService { /// Get current signers from the contract pub async fn get_signers(&self) -> Result, GovernanceServiceError> { - let args = serde_json::json!({}); + let _args = serde_json::json!({}); // Note: This is a read-only call, we don't need to submit a transaction // In production, this would use a read-only RPC method diff --git a/backend/src/service/match_authority_service.rs b/backend/src/service/match_authority_service.rs index f1768d5..a1c8b61 100644 --- a/backend/src/service/match_authority_service.rs +++ b/backend/src/service/match_authority_service.rs @@ -3,11 +3,9 @@ use crate::db::DbPool; use crate::models::match_authority::*; use crate::service::soroban_service::{SorobanService, SorobanTxResult}; use chrono::Utc; -use sqlx::Row; use std::sync::Arc; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use validator::Validate; /// Match Authority Service - FSM enforcer and blockchain coordinator pub struct MatchAuthorityService { @@ -74,8 +72,7 @@ impl MatchAuthorityService { // Step 2: Create match entity in database let match_id = Uuid::new_v4(); - let match_entity = sqlx::query_as!( - MatchAuthorityEntity, + let _match_entity = sqlx::query_as::<_, MatchAuthorityEntity>( r#" INSERT INTO match_authority ( id, on_chain_match_id, player_a, player_b, state, @@ -85,21 +82,20 @@ impl MatchAuthorityService { ) RETURNING id, on_chain_match_id, player_a, player_b, winner, - state as "state: MatchAuthorityState", - created_at, started_at, ended_at, last_chain_tx, + state, created_at, started_at, ended_at, last_chain_tx, idempotency_key, metadata "#, - match_id, - chain_result.hash, - dto.player_a, - dto.player_b, - chain_result.hash.clone(), - dto.idempotency_key, - serde_json::json!({}) ) + .bind(match_id) + .bind(&chain_result.hash) + .bind(&dto.player_a) + .bind(&dto.player_b) + .bind(&chain_result.hash) + .bind(&dto.idempotency_key) + .bind(serde_json::json!({})) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; // Step 3: Record blockchain sync self.record_chain_sync( @@ -163,7 +159,7 @@ impl MatchAuthorityService { })?; // Step 2: Update match state - sqlx::query!( + sqlx::query( r#" UPDATE match_authority SET state = 'STARTED'::match_authority_state, @@ -171,12 +167,12 @@ impl MatchAuthorityService { started_at = NOW() WHERE id = $2 "#, - chain_result.hash, - match_id ) + .bind(&chain_result.hash) + .bind(match_id) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; // Step 3: Record blockchain sync self.record_chain_sync(match_id, "start_match", &chain_result.hash, "pending", None) @@ -242,7 +238,7 @@ impl MatchAuthorityService { })?; // Step 2: Update match state - sqlx::query!( + sqlx::query( r#" UPDATE match_authority SET state = 'COMPLETED'::match_authority_state, @@ -251,13 +247,13 @@ impl MatchAuthorityService { ended_at = NOW() WHERE id = $3 "#, - dto.winner, - chain_result.hash, - match_id ) + .bind(&dto.winner) + .bind(&chain_result.hash) + .bind(match_id) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; // Step 3: Record blockchain sync self.record_chain_sync( @@ -324,19 +320,19 @@ impl MatchAuthorityService { })?; // Step 2: Update match state - sqlx::query!( + sqlx::query( r#" UPDATE match_authority SET state = 'DISPUTED'::match_authority_state, last_chain_tx = $1 WHERE id = $2 "#, - chain_result.hash, - match_id ) + .bind(&chain_result.hash) + .bind(match_id) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; // Step 3: Record blockchain sync self.record_chain_sync( @@ -397,19 +393,19 @@ impl MatchAuthorityService { })?; // Step 2: Update match state - sqlx::query!( + sqlx::query( r#" UPDATE match_authority SET state = 'FINALIZED'::match_authority_state, last_chain_tx = $1 WHERE id = $2 "#, - chain_result.hash, - match_id ) + .bind(&chain_result.hash) + .bind(match_id) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; // Step 3: Record blockchain sync self.record_chain_sync( @@ -448,22 +444,20 @@ impl MatchAuthorityService { /// Get match entity (internal) async fn get_match_entity(&self, match_id: Uuid) -> Result { - sqlx::query_as!( - MatchAuthorityEntity, + sqlx::query_as::<_, MatchAuthorityEntity>( r#" SELECT id, on_chain_match_id, player_a, player_b, winner, - state as "state: MatchAuthorityState", - created_at, started_at, ended_at, last_chain_tx, + state, created_at, started_at, ended_at, last_chain_tx, idempotency_key, metadata FROM match_authority WHERE id = $1 "#, - match_id ) + .bind(match_id) .fetch_optional(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? + .map_err(ApiError::database_error)? .ok_or_else(|| ApiError::not_found("Match not found")) } @@ -487,23 +481,21 @@ impl MatchAuthorityService { &self, match_id: Uuid, ) -> Result, ApiError> { - sqlx::query_as!( - MatchTransition, + sqlx::query_as::<_, MatchTransition>( r#" SELECT id, match_id, - from_state as "from_state: MatchAuthorityState", - to_state as "to_state: MatchAuthorityState", + from_state, to_state, actor, timestamp, chain_tx, metadata, error FROM match_transitions WHERE match_id = $1 ORDER BY timestamp ASC "#, - match_id ) + .bind(match_id) .fetch_all(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e)) + .map_err(ApiError::database_error) } /// Get match by idempotency key @@ -511,22 +503,20 @@ impl MatchAuthorityService { &self, key: &str, ) -> Result, ApiError> { - sqlx::query_as!( - MatchAuthorityEntity, + sqlx::query_as::<_, MatchAuthorityEntity>( r#" SELECT id, on_chain_match_id, player_a, player_b, winner, - state as "state: MatchAuthorityState", - created_at, started_at, ended_at, last_chain_tx, + state, created_at, started_at, ended_at, last_chain_tx, idempotency_key, metadata FROM match_authority WHERE idempotency_key = $1 "#, - key ) + .bind(key) .fetch_optional(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e)) + .map_err(ApiError::database_error) } // ============================================================================= @@ -557,7 +547,7 @@ impl MatchAuthorityService { let is_divergent = on_chain_state != off_chain_state_str; // Log reconciliation - sqlx::query!( + sqlx::query( r#" INSERT INTO match_reconciliation_log ( id, match_id, off_chain_state, on_chain_state, is_divergent, metadata @@ -565,16 +555,16 @@ impl MatchAuthorityService { $1, $2, $3, $4, $5, $6 ) "#, - Uuid::new_v4(), - match_id, - match_entity.state as MatchAuthorityState, - on_chain_state, - is_divergent, - serde_json::json!({ "checked_at": Utc::now() }) ) + .bind(Uuid::new_v4()) + .bind(match_id) + .bind(&match_entity.state as &MatchAuthorityState) + .bind(&on_chain_state) + .bind(is_divergent) + .bind(serde_json::json!({ "checked_at": Utc::now() })) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; if is_divergent { warn!( @@ -595,7 +585,7 @@ impl MatchAuthorityService { // ============================================================================= /// Validate state transition according to FSM rules - fn validate_transition( + pub(crate) fn validate_transition( &self, from: &MatchAuthorityState, to: &MatchAuthorityState, @@ -619,7 +609,7 @@ impl MatchAuthorityService { chain_tx: Option<&str>, metadata: Option, ) -> Result<(), ApiError> { - sqlx::query!( + sqlx::query( r#" INSERT INTO match_transitions ( id, match_id, from_state, to_state, actor, chain_tx, metadata @@ -627,17 +617,17 @@ impl MatchAuthorityService { $1, $2, $3, $4, $5, $6, $7 ) "#, - Uuid::new_v4(), - match_id, - from as MatchAuthorityState, - to as MatchAuthorityState, - actor, - chain_tx, - metadata.unwrap_or_else(|| serde_json::json!({})) ) + .bind(Uuid::new_v4()) + .bind(match_id) + .bind(&from as &MatchAuthorityState) + .bind(&to as &MatchAuthorityState) + .bind(actor) + .bind(chain_tx) + .bind(metadata.unwrap_or_else(|| serde_json::json!({}))) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; Ok(()) } @@ -651,7 +641,7 @@ impl MatchAuthorityService { status: &str, error: Option<&str>, ) -> Result<(), ApiError> { - sqlx::query!( + sqlx::query( r#" INSERT INTO match_chain_sync ( id, match_id, operation_type, tx_hash, tx_status, error_message, metadata @@ -659,17 +649,17 @@ impl MatchAuthorityService { $1, $2, $3, $4, $5, $6, $7 ) "#, - Uuid::new_v4(), - match_id, - operation_type, - tx_hash, - status, - error, - serde_json::json!({}) ) + .bind(Uuid::new_v4()) + .bind(match_id) + .bind(operation_type) + .bind(tx_hash) + .bind(status) + .bind(error) + .bind(serde_json::json!({})) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; Ok(()) } @@ -811,8 +801,12 @@ mod tests { #[test] fn test_validate_transition() { + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_lazy("postgres://localhost/test") + .expect("failed to create lazy pool"); let service = MatchAuthorityService { - db_pool: DbPool::default(), + db_pool: pool, soroban_service: Arc::new(SorobanService::new( crate::service::soroban_service::NetworkConfig::testnet(), )), diff --git a/backend/src/service/match_authority_service_test.rs b/backend/src/service/match_authority_service_test.rs index fa88aea..32e7a6f 100644 --- a/backend/src/service/match_authority_service_test.rs +++ b/backend/src/service/match_authority_service_test.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod tests { - use crate::db::DbPool; use crate::models::match_authority::*; use crate::service::match_authority_service::MatchAuthorityService; use crate::service::soroban_service::{NetworkConfig, SorobanService}; @@ -9,7 +8,10 @@ mod tests { /// Helper to create a test service instance fn create_test_service() -> MatchAuthorityService { - let db_pool = DbPool::default(); // Mock pool for unit tests + let db_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_lazy("postgres://localhost/test") + .expect("failed to create lazy pool"); let soroban_service = Arc::new(SorobanService::new(NetworkConfig::testnet())); let contract_id = "CTEST123".to_string(); @@ -22,10 +24,7 @@ mod tests { // Valid transitions assert!(service - .validate_transition( - &MatchAuthorityState::Created, - &MatchAuthorityState::Started - ) + .validate_transition(&MatchAuthorityState::Created, &MatchAuthorityState::Started) .is_ok()); assert!(service @@ -195,7 +194,7 @@ mod tests { let match_id = Uuid::new_v4(); let now = chrono::Utc::now(); - let transitions = vec![ + let transitions = [ MatchTransition { id: Uuid::new_v4(), match_id, diff --git a/backend/src/service/match_service.rs b/backend/src/service/match_service.rs index ae7be1e..c510bb5 100644 --- a/backend/src/service/match_service.rs +++ b/backend/src/service/match_service.rs @@ -1,13 +1,15 @@ use crate::api_error::ApiError; -use crate::config::Config; use crate::db::DbPool; -use crate::models::*; +use crate::models::match_models::*; +use crate::models::user::User; use crate::service::reputation_service::ReputationService; use chrono::{DateTime, Utc}; +use redis::Client as RedisClient; use serde::{Deserialize, Serialize}; use sqlx::Row; use std::cmp::Ordering; -use std::collections::HashMap; +use std::sync::Arc; +use tracing::error; use uuid::Uuid; pub struct MatchService { @@ -37,6 +39,11 @@ impl MatchService { self } + pub fn with_redis(mut self, redis_client: Arc) -> Self { + self.redis_client = Some(redis_client); + self + } + /// Create a new match pub async fn create_match( &self, @@ -57,8 +64,7 @@ impl MatchService { None }; - let match_record = sqlx::query_as!( - Match, + let match_record = sqlx::query_as::<_, Match>( r#" INSERT INTO matches ( id, tournament_id, round_id, match_type, status, player1_id, player2_id, @@ -67,22 +73,22 @@ impl MatchService { $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 ) RETURNING * "#, - match_id, - tournament_id, - round_id, - match_type as _, - MatchStatus::Pending as _, - player1_id, - player2_id, - player1_elo, - player2_elo, - game_mode, - Utc::now(), - Utc::now() ) + .bind(match_id) + .bind(tournament_id) + .bind(round_id) + .bind(match_type) + .bind(MatchStatus::Pending) + .bind(player1_id) + .bind(player2_id) + .bind(player1_elo) + .bind(player2_elo) + .bind(game_mode) + .bind(Utc::now()) + .bind(Utc::now()) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; Ok(match_record) } @@ -93,7 +99,8 @@ impl MatchService { match_id: Uuid, user_id: Option, ) -> Result { - let match_record = sqlx::query_as!(Match, "SELECT * FROM matches WHERE id = $1", match_id) + let match_record = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1") + .bind(match_id) .fetch_optional(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))? @@ -115,8 +122,8 @@ impl MatchService { Ok(MatchResponse { id: match_record.id, tournament_id: match_record.tournament_id, - match_type: match_record.match_type.into(), - status: match_record.status.into(), + match_type: match_record.match_type, + status: match_record.status, player1, player2, winner_id: match_record.winner_id, @@ -151,8 +158,7 @@ impl MatchService { self.validate_score_report(&match_record, user_id).await?; // Create score record — includes opponent_score for conflict detection - let score_record = sqlx::query_as!( - MatchScore, + let score_record = sqlx::query_as::<_, MatchScore>( r#" INSERT INTO match_scores ( id, match_id, player_id, score, opponent_score, proof_url, @@ -161,19 +167,19 @@ impl MatchService { $1, $2, $3, $4, $5, $6, $7, $8, $9 ) RETURNING * "#, - Uuid::new_v4(), - match_id, - user_id, - request.score, - request.opponent_score, - request.proof_url, - request.telemetry_data, - Utc::now(), - false ) + .bind(Uuid::new_v4()) + .bind(match_id) + .bind(user_id) + .bind(request.score) + .bind(request.opponent_score) + .bind(request.proof_url) + .bind(request.telemetry_data) + .bind(Utc::now()) + .bind(false) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; // Mark this attempt as accepted self.mark_last_attempt_accepted(match_id, user_id).await?; @@ -219,8 +225,7 @@ impl MatchService { .await?; // Create dispute record - let dispute = sqlx::query_as!( - MatchDispute, + let dispute = sqlx::query_as::<_, MatchDispute>( r#" INSERT INTO match_disputes ( id, match_id, disputing_player_id, reason, evidence_urls, @@ -229,19 +234,21 @@ impl MatchService { $1, $2, $3, $4, $5, $6, $7 ) RETURNING * "#, - Uuid::new_v4(), - match_id, - user_id, - request.reason, + ) + .bind(Uuid::new_v4()) + .bind(match_id) + .bind(user_id) + .bind(request.reason.clone()) + .bind( request .evidence_urls .map(|urls| serde_json::to_string(&urls).unwrap_or_default()), - DisputeStatus::Pending as _, - Utc::now() ) + .bind(DisputeStatus::Pending) + .bind(Utc::now()) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; // Update match status to disputed self.update_match_status(match_id, MatchStatus::Disputed) @@ -275,9 +282,11 @@ impl MatchService { // Check player reputation (filter bad actors) if let Some(rep_service) = &self.reputation_service { - let reputation = rep_service.get_player_reputation(user_id).await - .map_err(|e| ApiError::internal_server_error(&format!("Reputation check failed: {}", e)))?; - + let reputation = rep_service + .get_player_reputation(user_id) + .await + .map_err(|e| ApiError::internal_error(format!("Reputation check failed: {}", e)))?; + // Filter players with very low fair play score if reputation.should_filter(30) { return Err(ApiError::bad_request( @@ -297,8 +306,7 @@ impl MatchService { Utc::now() + chrono::Duration::minutes(request.max_wait_time.unwrap_or(10) as i64); // Add to queue - let queue_entry = sqlx::query_as!( - MatchmakingQueue, + let queue_entry = sqlx::query_as::<_, MatchmakingQueue>( r#" INSERT INTO matchmaking_queue ( id, user_id, game, game_mode, current_elo, min_elo, max_elo, @@ -307,20 +315,20 @@ impl MatchService { $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 ) RETURNING * "#, - Uuid::new_v4(), - user_id, - request.game, - request.game_mode, - current_elo, - min_elo, - max_elo, - Utc::now(), - expires_at, - QueueStatus::Waiting as _ ) + .bind(Uuid::new_v4()) + .bind(user_id) + .bind(&request.game) + .bind(&request.game_mode) + .bind(current_elo) + .bind(min_elo) + .bind(max_elo) + .bind(Utc::now()) + .bind(expires_at) + .bind(QueueStatus::Waiting) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; // Try to find a match immediately self.try_matchmaking(&request.game, &request.game_mode) @@ -334,15 +342,14 @@ impl MatchService { &self, user_id: Uuid, ) -> Result { - let queue_entry = sqlx::query_as!( - MatchmakingQueue, + let queue_entry = sqlx::query_as::<_, MatchmakingQueue>( "SELECT * FROM matchmaking_queue WHERE user_id = $1 AND status = $2", - user_id, - QueueStatus::Waiting as _ ) + .bind(user_id) + .bind(QueueStatus::Waiting) .fetch_optional(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; if let Some(entry) = queue_entry { // Calculate queue position @@ -378,16 +385,14 @@ impl MatchService { user_id: Uuid, game: &str, ) -> Result { - let elo_record = sqlx::query_as!( - UserElo, - "SELECT * FROM user_elo WHERE user_id = $1 AND game = $2", - user_id, - game - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))? - .ok_or(ApiError::not_found("Elo rating not found"))?; + let elo_record = + sqlx::query_as::<_, UserElo>("SELECT * FROM user_elo WHERE user_id = $1 AND game = $2") + .bind(user_id) + .bind(game) + .fetch_optional(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))? + .ok_or(ApiError::not_found("Elo rating not found"))?; // Calculate win rate let total_games = elo_record.games_played; @@ -419,21 +424,20 @@ impl MatchService { // Private helper methods async fn get_user_elo(&self, user_id: Uuid, game: &str) -> Result { - let elo_record = sqlx::query_as!( - UserElo, - "SELECT * FROM user_elo WHERE user_id = $1 AND game = $2", - user_id, - game - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))?; + let elo_record = + sqlx::query_as::<_, UserElo>("SELECT * FROM user_elo WHERE user_id = $1 AND game = $2") + .bind(user_id) + .bind(game) + .fetch_optional(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))?; Ok(elo_record.map(|r| r.current_rating).unwrap_or(1200)) // Default Elo rating } async fn get_player_info(&self, user_id: Uuid) -> Result { - let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", user_id) + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(user_id) .fetch_optional(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))? @@ -477,14 +481,13 @@ impl MatchService { } // Check if user has already reported score - let existing_score = sqlx::query!( - "SELECT id FROM match_scores WHERE match_id = $1 AND player_id = $2", - match_record.id, - user_id - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))?; + let existing_score = + sqlx::query("SELECT id FROM match_scores WHERE match_id = $1 AND player_id = $2") + .bind(match_record.id) + .bind(user_id) + .fetch_optional(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))?; Ok(existing_score.is_none()) } @@ -516,14 +519,13 @@ impl MatchService { } // Check if there's already a pending dispute - let existing_dispute = sqlx::query!( - "SELECT id FROM match_disputes WHERE match_id = $1 AND status = $2", - match_record.id, - DisputeStatus::Pending as _ - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))?; + let existing_dispute = + sqlx::query("SELECT id FROM match_disputes WHERE match_id = $1 AND status = $2") + .bind(match_record.id) + .bind(DisputeStatus::Pending) + .fetch_optional(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))?; Ok(existing_dispute.is_none()) } @@ -532,19 +534,27 @@ impl MatchService { &self, match_id: Uuid, ) -> Result, ApiError> { - let dispute = sqlx::query!( - "SELECT status FROM match_disputes WHERE match_id = $1 ORDER BY created_at DESC LIMIT 1", - match_id + let dispute = sqlx::query( + "SELECT status FROM match_disputes WHERE match_id = $1 ORDER BY created_at DESC LIMIT 1" ) + .bind(match_id) .fetch_optional(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; - - Ok(dispute.map(|d| d.status.into())) + .map_err(ApiError::database_error)?; + + Ok(dispute + .and_then(|d| d.try_get::("status").ok()) + .map(|s| match s.as_str() { + "pending" => DisputeStatus::Pending, + "resolved" => DisputeStatus::Resolved, + "rejected" => DisputeStatus::Rejected, + _ => DisputeStatus::Pending, + })) } async fn get_match_by_id(&self, match_id: Uuid) -> Result { - sqlx::query_as!(Match, "SELECT * FROM matches WHERE id = $1", match_id) + sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1") + .bind(match_id) .fetch_optional(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))? @@ -581,21 +591,18 @@ impl MatchService { ))); } _ => { - return Err(ApiError::bad_request( - "Match has not started yet", - )); + return Err(ApiError::bad_request("Match has not started yet")); } } // Check if user has already reported score for this match - let existing_score = sqlx::query!( - "SELECT id FROM match_scores WHERE match_id = $1 AND player_id = $2", - match_record.id, - user_id - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))?; + let existing_score = + sqlx::query("SELECT id FROM match_scores WHERE match_id = $1 AND player_id = $2") + .bind(match_record.id) + .bind(user_id) + .fetch_optional(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))?; if existing_score.is_some() { return Err(ApiError::bad_request( @@ -615,29 +622,25 @@ impl MatchService { let match_record = self.get_match_by_id(match_id).await?; if user_id == match_record.player1_id { - sqlx::query!( - "UPDATE matches SET player1_score = $1, updated_at = $2 WHERE id = $3", - score, - Utc::now(), - match_id - ) - .execute(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))?; + sqlx::query("UPDATE matches SET player1_score = $1, updated_at = $2 WHERE id = $3") + .bind(score) + .bind(Utc::now()) + .bind(match_id) + .execute(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))?; } else if match_record .player2_id .map(|p2| p2 == user_id) .unwrap_or(false) { - sqlx::query!( - "UPDATE matches SET player2_score = $1, updated_at = $2 WHERE id = $3", - score, - Utc::now(), - match_id - ) - .execute(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))?; + sqlx::query("UPDATE matches SET player2_score = $1, updated_at = $2 WHERE id = $3") + .bind(score) + .bind(Utc::now()) + .bind(match_id) + .execute(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))?; } Ok(()) @@ -646,26 +649,23 @@ impl MatchService { async fn both_players_reported_scores(&self, match_id: Uuid) -> Result { let match_record = self.get_match_by_id(match_id).await?; - let player1_reported = sqlx::query!( - "SELECT id FROM match_scores WHERE match_id = $1 AND player_id = $2", - match_id, - match_record.player1_id - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))? - .is_some(); + let player1_reported = + sqlx::query("SELECT id FROM match_scores WHERE match_id = $1 AND player_id = $2") + .bind(match_id) + .bind(match_record.player1_id) + .fetch_optional(&self.db_pool) + .await + .map_err(ApiError::database_error)? + .is_some(); let player2_reported = if let Some(p2_id) = match_record.player2_id { - sqlx::query!( - "SELECT id FROM match_scores WHERE match_id = $1 AND player_id = $2", - match_id, - p2_id - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))? - .is_some() + sqlx::query("SELECT id FROM match_scores WHERE match_id = $1 AND player_id = $2") + .bind(match_id) + .bind(p2_id) + .fetch_optional(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))? + .is_some() } else { true // Bye match - no player2 needed }; @@ -680,18 +680,18 @@ impl MatchService { let winner_id = self.determine_winner(&match_record).await?; // Update match with winner and completion time - sqlx::query!( + sqlx::query( r#" UPDATE matches SET winner_id = $1, status = $2, completed_at = $3, updated_at = $4 WHERE id = $5 "#, - winner_id, - MatchStatus::Completed as _, - Utc::now(), - Utc::now(), - match_id ) + .bind(winner_id) + .bind(MatchStatus::Completed) + .bind(Utc::now()) + .bind(Utc::now()) + .bind(match_id) .execute(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -706,12 +706,11 @@ impl MatchService { if let Some(rep_service) = &self.reputation_service { let players = vec![match_record.player1_id]; let skill_deltas = vec![25]; // Example: +25 for win, would be calculated based on outcome - - if let Err(e) = rep_service.update_reputation_on_match( - match_id, - &players, - &skill_deltas, - ).await { + + if let Err(e) = rep_service + .update_reputation_on_match(match_id, &players, &skill_deltas) + .await + { error!("Failed to update reputation for match {}: {}", match_id, e); // Don't fail the match completion, just log the error } @@ -744,8 +743,12 @@ impl MatchService { // If this was a tournament match, trigger round advancement if let Some(tournament_id) = match_record.tournament_id { if let Some(round_id) = match_record.round_id { - let advancement = crate::orchestrator::RoundAdvancementWorker::new(self.db_pool.clone()); - if let Err(e) = advancement.on_match_completed(tournament_id, round_id).await { + let advancement = + crate::orchestrator::RoundAdvancementWorker::new(self.db_pool.clone()); + if let Err(e) = advancement + .on_match_completed(tournament_id, round_id) + .await + { tracing::error!( "Round advancement failed for tournament {} round {}: {}", tournament_id, @@ -827,17 +830,17 @@ impl MatchService { .await?; // Update match record with new Elo ratings - sqlx::query!( + sqlx::query( r#" UPDATE matches SET player1_elo_after = $1, player2_elo_after = $2, updated_at = $3 WHERE id = $4 "#, - new_player1_elo, - new_player2_elo, - Utc::now(), - match_record.id ) + .bind(new_player1_elo) + .bind(new_player2_elo) + .bind(Utc::now()) + .bind(match_record.id) .execute(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -889,15 +892,13 @@ impl MatchService { result: MatchResult, ) -> Result<(), ApiError> { // Get current Elo record - let current_elo = sqlx::query_as!( - UserElo, - "SELECT * FROM user_elo WHERE user_id = $1 AND game = $2", - user_id, - game - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))?; + let current_elo = + sqlx::query_as::<_, UserElo>("SELECT * FROM user_elo WHERE user_id = $1 AND game = $2") + .bind(user_id) + .bind(game) + .fetch_optional(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))?; if let Some(elo_record) = current_elo { // Update existing record @@ -916,7 +917,7 @@ impl MatchService { MatchResult::Draw => (0, 0), // Draws reset both streaks }; - sqlx::query!( + sqlx::query( r#" UPDATE user_elo SET current_rating = $1, peak_rating = $2, games_played = games_played + 1, @@ -924,17 +925,17 @@ impl MatchService { last_updated = $8 WHERE user_id = $9 AND game = $10 "#, - new_elo, - peak_rating, - wins, - losses, - draws, - win_streak, - loss_streak, - Utc::now(), - user_id, - game ) + .bind(new_elo) + .bind(peak_rating) + .bind(wins) + .bind(losses) + .bind(draws) + .bind(win_streak) + .bind(loss_streak) + .bind(Utc::now()) + .bind(user_id) + .bind(game) .execute(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -952,7 +953,7 @@ impl MatchService { MatchResult::Draw => (0, 0), }; - sqlx::query!( + sqlx::query( r#" INSERT INTO user_elo ( id, user_id, game, current_rating, peak_rating, games_played, @@ -961,19 +962,19 @@ impl MatchService { $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 ) "#, - Uuid::new_v4(), - user_id, - game, - new_elo, - new_elo, - 1, - wins, - losses, - draws, - win_streak, - loss_streak, - Utc::now() ) + .bind(Uuid::new_v4()) + .bind(user_id) + .bind(game) + .bind(new_elo) + .bind(new_elo) + .bind(1) + .bind(wins) + .bind(losses) + .bind(draws) + .bind(win_streak) + .bind(loss_streak) + .bind(Utc::now()) .execute(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -1005,7 +1006,7 @@ impl MatchService { MatchResult::Draw }; - sqlx::query!( + sqlx::query( r#" INSERT INTO elo_history ( id, user_id, game, match_id, rating_before, rating_after, rating_change, @@ -1014,18 +1015,18 @@ impl MatchService { $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 ) "#, - Uuid::new_v4(), - match_record.player1_id, - match_record.game_mode, - match_record.id, - player1_elo_before, - player1_elo_after, - player1_elo_after - player1_elo_before, - match_record.player2_id.unwrap(), - player2_elo_before, - player1_result as _, - Utc::now() ) + .bind(Uuid::new_v4()) + .bind(match_record.player1_id) + .bind(match_record.game_mode.clone()) + .bind(match_record.id) + .bind(player1_elo_before) + .bind(player1_elo_after) + .bind(player1_elo_after - player1_elo_before) + .bind(match_record.player2_id.unwrap()) + .bind(player2_elo_before) + .bind(player1_result) + .bind(Utc::now()) .execute(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -1039,7 +1040,7 @@ impl MatchService { MatchResult::Draw }; - sqlx::query!( + sqlx::query( r#" INSERT INTO elo_history ( id, user_id, game, match_id, rating_before, rating_after, rating_change, @@ -1048,18 +1049,18 @@ impl MatchService { $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 ) "#, - Uuid::new_v4(), - match_record.player2_id.unwrap(), - match_record.game_mode, - match_record.id, - player2_elo_before, - player2_elo_after, - player2_elo_after - player2_elo_before, - match_record.player1_id, - player1_elo_before, - player2_result as _, - Utc::now() ) + .bind(Uuid::new_v4()) + .bind(match_record.player2_id.unwrap()) + .bind(match_record.game_mode.clone()) + .bind(match_record.id) + .bind(player2_elo_before) + .bind(player2_elo_after) + .bind(player2_elo_after - player2_elo_before) + .bind(match_record.player1_id) + .bind(player1_elo_before) + .bind(player2_result) + .bind(Utc::now()) .execute(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -1088,14 +1089,13 @@ impl MatchService { } // Check if there's already a pending dispute - let existing_dispute = sqlx::query!( - "SELECT id FROM match_disputes WHERE match_id = $1 AND status = $2", - match_record.id, - DisputeStatus::Pending as _ - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))?; + let existing_dispute = + sqlx::query("SELECT id FROM match_disputes WHERE match_id = $1 AND status = $2") + .bind(match_record.id) + .bind(DisputeStatus::Pending) + .fetch_optional(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))?; if existing_dispute.is_some() { return Err(ApiError::bad_request( @@ -1115,71 +1115,67 @@ impl MatchService { // Record when the match started and set the reporting deadline. // The Reaper will forfeit non-reporters after this deadline passes. let deadline = Utc::now() + chrono::Duration::hours(24); - sqlx::query!( + sqlx::query( r#" UPDATE matches SET status = $1, started_at = $2, report_deadline = $3, updated_at = $4 WHERE id = $5 "#, - status as _, - Utc::now(), - deadline, - Utc::now(), - match_id ) + .bind(&status) + .bind(Utc::now()) + .bind(deadline) + .bind(Utc::now()) + .bind(match_id) .execute(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; } else { - sqlx::query!( - "UPDATE matches SET status = $1, updated_at = $2 WHERE id = $3", - status as _, - Utc::now(), - match_id - ) - .execute(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))?; + sqlx::query("UPDATE matches SET status = $1, updated_at = $2 WHERE id = $3") + .bind(&status) + .bind(Utc::now()) + .bind(match_id) + .execute(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))?; } Ok(()) } async fn is_user_in_queue(&self, user_id: Uuid, game: &str) -> Result { - let count = sqlx::query!( - "SELECT COUNT(*) as count FROM matchmaking_queue WHERE user_id = $1 AND game = $2 AND status = $3", - user_id, - game, - QueueStatus::Waiting as _ + let count_row = sqlx::query( + "SELECT COUNT(*) as count FROM matchmaking_queue WHERE user_id = $1 AND game = $2 AND status = $3" ) + .bind(user_id) + .bind(game) + .bind(QueueStatus::Waiting) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? - .count - .unwrap_or(0); + .map_err(|e| ApiError::database_error(e))?; + let count: i64 = count_row.try_get("count").unwrap_or(0); Ok(count > 0) } fn calculate_elo_range(&self, current_elo: i32) -> (i32, i32) { - const ELO_RANGE: i32 = 200; // ±200 Elo points + const ELO_RANGE: i32 = 200; // ┬▒200 Elo points (current_elo - ELO_RANGE, current_elo + ELO_RANGE) } async fn try_matchmaking(&self, game: &str, game_mode: &str) -> Result<(), ApiError> { // Find potential matches - let candidates = sqlx::query_as!( - MatchmakingQueue, + let candidates = sqlx::query_as::<_, MatchmakingQueue>( r#" SELECT * FROM matchmaking_queue WHERE game = $1 AND game_mode = $2 AND status = $3 ORDER BY joined_at ASC LIMIT 10 "#, - game, - game_mode, - QueueStatus::Waiting as _ ) + .bind(game) + .bind(game_mode) + .bind(QueueStatus::Waiting) .fetch_all(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -1187,10 +1183,13 @@ impl MatchService { // Filter out bad actors if reputation service is available let filtered_candidates = if let Some(rep_service) = &self.reputation_service { let candidate_ids: Vec = candidates.iter().map(|c| c.user_id).collect(); - let filtered_ids = rep_service.filter_bad_actors(&candidate_ids, 50).await + let filtered_ids = rep_service + .filter_bad_actors(&candidate_ids, 50) + .await .unwrap_or(candidate_ids); // If filtering fails, use original list - - candidates.into_iter() + + candidates + .into_iter() .filter(|c| filtered_ids.contains(&c.user_id)) .collect::>() } else { @@ -1241,25 +1240,25 @@ impl MatchService { match_id: Uuid, ) -> Result<(), ApiError> { // Update player 1 - sqlx::query!( - "UPDATE matchmaking_queue SET status = $1, matched_at = $2, match_id = $3 WHERE id = $4", - QueueStatus::Matched as _, - Utc::now(), - match_id, - player1_queue_id + sqlx::query( + "UPDATE matchmaking_queue SET status = $1, matched_at = $2, match_id = $3 WHERE id = $4" ) + .bind(QueueStatus::Matched) + .bind(Utc::now()) + .bind(match_id) + .bind(player1_queue_id) .execute(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; // Update player 2 - sqlx::query!( - "UPDATE matchmaking_queue SET status = $1, matched_at = $2, match_id = $3 WHERE id = $4", - QueueStatus::Matched as _, - Utc::now(), - match_id, - player2_queue_id + sqlx::query( + "UPDATE matchmaking_queue SET status = $1, matched_at = $2, match_id = $3 WHERE id = $4" ) + .bind(QueueStatus::Matched) + .bind(Utc::now()) + .bind(match_id) + .bind(player2_queue_id) .execute(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -1268,39 +1267,37 @@ impl MatchService { } async fn get_queue_position(&self, user_id: Uuid, game: &str) -> Result { - let position = sqlx::query!( + let position_row = sqlx::query( r#" SELECT COUNT(*) as position FROM matchmaking_queue WHERE game = $1 AND status = $2 AND joined_at < ( SELECT joined_at FROM matchmaking_queue WHERE user_id = $3 AND game = $1 AND status = $2 ) - "#, - game, - QueueStatus::Waiting as _, - user_id + "# ) + .bind(game) + .bind(QueueStatus::Waiting) + .bind(user_id) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? - .position - .unwrap_or(0); + .map_err(|e| ApiError::database_error(e))?; + let position: i64 = position_row.try_get("position").unwrap_or(0); Ok(position as i32 + 1) // 1-indexed position } async fn estimate_wait_time(&self, game: &str, game_mode: &str) -> Result { // Simple estimation based on queue size and average match duration - let queue_size = sqlx::query!( - "SELECT COUNT(*) as count FROM matchmaking_queue WHERE game = $1 AND game_mode = $2 AND status = $3", - game, - game_mode, - QueueStatus::Waiting as _ + let queue_size_row = sqlx::query( + "SELECT COUNT(*) as count FROM matchmaking_queue WHERE game = $1 AND game_mode = $2 AND status = $3" ) + .bind(game) + .bind(game_mode) + .bind(QueueStatus::Waiting) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? - .count - .unwrap_or(0); + .map_err(|e| ApiError::database_error(e))?; + let queue_size: i64 = queue_size_row.try_get("count").unwrap_or(0); // Estimate 2 minutes per person in queue (rough approximation) Ok((queue_size as i32) * 120) @@ -1310,8 +1307,7 @@ impl MatchService { &self, user_id: Uuid, ) -> Result, ApiError> { - let match_record = sqlx::query_as!( - Match, + let match_record = sqlx::query_as::<_, Match>( r#" SELECT m.* FROM matches m WHERE (m.player1_id = $1 OR m.player2_id = $1) @@ -1319,10 +1315,10 @@ impl MatchService { ORDER BY m.created_at DESC LIMIT 1 "#, - user_id, - MatchStatus::Pending as _, - MatchStatus::InProgress as _ ) + .bind(user_id) + .bind(MatchStatus::Pending) + .bind(MatchStatus::InProgress) .fetch_optional(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -1343,27 +1339,24 @@ impl MatchService { let user_rating = self.get_user_elo(user_id, game).await?; // Count players with higher ratings - let higher_rated_count = sqlx::query!( + let higher_rated_count_row = sqlx::query( "SELECT COUNT(*) as count FROM user_elo WHERE game = $1 AND current_rating > $2", - game, - user_rating ) + .bind(game) + .bind(user_rating) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? - .count - .unwrap_or(0); + .map_err(|e| ApiError::database_error(e))?; + let higher_rated_count: i64 = higher_rated_count_row.try_get("count").unwrap_or(0); // Get total player count - let total_players = sqlx::query!( - "SELECT COUNT(*) as count FROM user_elo WHERE game = $1", - game - ) - .fetch_one(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))? - .count - .unwrap_or(0); + let total_players_row = + sqlx::query("SELECT COUNT(*) as count FROM user_elo WHERE game = $1") + .bind(game) + .fetch_one(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))?; + let total_players: i64 = total_players_row.try_get("count").unwrap_or(0); if total_players == 0 { return Ok((None, None)); @@ -1378,17 +1371,40 @@ impl MatchService { /// Leave matchmaking queue pub async fn leave_matchmaking(&self, user_id: Uuid) -> Result<(), ApiError> { - sqlx::query!( - "UPDATE matchmaking_queue SET status = $1 WHERE user_id = $2 AND status = $3", - QueueStatus::Cancelled as _, - user_id, - QueueStatus::Waiting as _ + sqlx::query("UPDATE matchmaking_queue SET status = $1 WHERE user_id = $2 AND status = $3") + .bind(QueueStatus::Cancelled) + .bind(user_id) + .bind(QueueStatus::Waiting) + .execute(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))?; + + Ok(()) + } + + /// Get ELO history for a user + pub async fn get_elo_history( + &self, + user_id: Uuid, + game: &str, + limit: i32, + ) -> Result, ApiError> { + let history = sqlx::query_as::<_, EloHistory>( + r#" + SELECT * FROM elo_history + WHERE user_id = $1 AND game = $2 + ORDER BY created_at DESC + LIMIT $3 + "#, ) - .execute(&self.db_pool) + .bind(user_id) + .bind(game) + .bind(limit as i64) + .fetch_all(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; - Ok(()) + Ok(history) } /// Get user's match history @@ -1401,8 +1417,7 @@ impl MatchService { ) -> Result { let offset = (page - 1) * per_page; - let matches = sqlx::query_as!( - Match, + let matches = sqlx::query_as::<_, Match>( r#" SELECT * FROM matches WHERE (player1_id = $1 OR player2_id = $1) @@ -1411,33 +1426,32 @@ impl MatchService { ORDER BY completed_at DESC LIMIT $4 OFFSET $5 "#, - user_id, - game, - MatchStatus::Completed as _, - per_page, - offset ) + .bind(user_id) + .bind(&game) + .bind(MatchStatus::Completed) + .bind(per_page) + .bind(offset) .fetch_all(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; // Get total count - let total = sqlx::query!( + let total_row = sqlx::query( r#" SELECT COUNT(*) as count FROM matches WHERE (player1_id = $1 OR player2_id = $1) AND ($2::text IS NULL OR game_mode = $2) AND status = $3 "#, - user_id, - game, - MatchStatus::Completed as _ ) + .bind(user_id) + .bind(&game) + .bind(MatchStatus::Completed) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? - .count - .unwrap_or(0); + .map_err(|e| ApiError::database_error(e))?; + let total: i64 = total_row.try_get("count").unwrap_or(0); // Convert to response format let mut match_responses = Vec::new(); @@ -1488,7 +1502,7 @@ impl MatchService { ) -> Result { let offset = (page - 1) * per_page; - let rankings = sqlx::query!( + let rankings = sqlx::query( r#" SELECT ue.*, u.username, u.avatar_url FROM user_elo ue @@ -1497,51 +1511,52 @@ impl MatchService { ORDER BY ue.current_rating DESC, ue.games_played DESC LIMIT $2 OFFSET $3 "#, - game, - per_page, - offset ) + .bind(&game) + .bind(per_page) + .bind(offset) .fetch_all(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; // Get total count - let total = sqlx::query!( - "SELECT COUNT(*) as count FROM user_elo WHERE game = $1", - game - ) - .fetch_one(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))? - .count - .unwrap_or(0); + let total_row = sqlx::query("SELECT COUNT(*) as count FROM user_elo WHERE game = $1") + .bind(&game) + .fetch_one(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))?; + let total: i64 = total_row.try_get("count").unwrap_or(0); // Convert to response format let mut leaderboard_entries = Vec::new(); for (index, ranking) in rankings.iter().enumerate() { let rank = offset as usize + index + 1; - let win_rate = if ranking.games_played > 0 { - (ranking.wins as f64 / ranking.games_played as f64) * 100.0 + let games_played: i32 = ranking.try_get("games_played").unwrap_or(0); + let wins: i32 = ranking.try_get("wins").unwrap_or(0); + let losses: i32 = ranking.try_get("losses").unwrap_or(0); + let draws: i32 = ranking.try_get("draws").unwrap_or(0); + let win_rate = if games_played > 0 { + (wins as f64 / games_played as f64) * 100.0 } else { 0.0 }; leaderboard_entries.push(LeaderboardEntry { rank: rank as i32, - user_id: ranking.user_id, + user_id: ranking.try_get("user_id").unwrap_or_default(), username: ranking - .username - .clone() + .try_get::, _>("username") + .unwrap_or(None) .unwrap_or_else(|| "Unknown".to_string()), - avatar_url: ranking.avatar_url.clone(), - current_rating: ranking.current_rating, - peak_rating: ranking.peak_rating, - games_played: ranking.games_played, - wins: ranking.wins, - losses: ranking.losses, - draws: ranking.draws, + avatar_url: ranking.try_get("avatar_url").unwrap_or(None), + current_rating: ranking.try_get("current_rating").unwrap_or(1200), + peak_rating: ranking.try_get("peak_rating").unwrap_or(1200), + games_played, + wins, + losses, + draws, win_rate, - win_streak: ranking.win_streak, + win_streak: ranking.try_get("win_streak").unwrap_or(0), }); } @@ -1572,22 +1587,20 @@ impl MatchService { None => return Ok(false), // bye match — no second report, no conflict }; - let p1_report = sqlx::query_as!( - MatchScore, - "SELECT * FROM match_scores WHERE match_id = $1 AND player_id = $2", - match_id, - match_record.player1_id + let p1_report = sqlx::query_as::<_, MatchScore>( + "SELECT * FROM match_scores WHERE match_id = $1 AND player_id = $2" ) + .bind(match_id) + .bind(match_record.player1_id) .fetch_optional(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; - let p2_report = sqlx::query_as!( - MatchScore, - "SELECT * FROM match_scores WHERE match_id = $1 AND player_id = $2", - match_id, - p2_id + let p2_report = sqlx::query_as::<_, MatchScore>( + "SELECT * FROM match_scores WHERE match_id = $1 AND player_id = $2" ) + .bind(match_id) + .bind(p2_id) .fetch_optional(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -1622,22 +1635,20 @@ impl MatchService { .player2_id .ok_or_else(|| ApiError::bad_request("Match has no second player"))?; - let p1_report = sqlx::query_as!( - MatchScore, + let p1_report = sqlx::query_as::<_, MatchScore>( "SELECT * FROM match_scores WHERE match_id = $1 AND player_id = $2", - match_id, - match_record.player1_id ) + .bind(match_id) + .bind(match_record.player1_id) .fetch_optional(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; - let p2_report = sqlx::query_as!( - MatchScore, + let p2_report = sqlx::query_as::<_, MatchScore>( "SELECT * FROM match_scores WHERE match_id = $1 AND player_id = $2", - match_id, - p2_id ) + .bind(match_id) + .bind(p2_id) .fetch_optional(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -1856,35 +1867,32 @@ impl MatchService { winner_id: Option, ) -> Result { // Update dispute record - let dispute = sqlx::query_as!( - MatchDispute, + let dispute = sqlx::query_as::<_, MatchDispute>( r#" UPDATE match_disputes SET status = $1, admin_reviewer_id = $2, resolution = $3, resolved_at = $4 WHERE id = $5 RETURNING * "#, - DisputeStatus::Resolved as _, - admin_id, - resolution, - Utc::now(), - dispute_id ) + .bind(DisputeStatus::Resolved) + .bind(admin_id) + .bind(resolution) + .bind(Utc::now()) + .bind(dispute_id) .fetch_one(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; // Update match record if winner needs to be reassigned if let Some(new_winner) = winner_id { - sqlx::query!( - "UPDATE matches SET winner_id = $1, updated_at = $2 WHERE id = $3", - new_winner, - Utc::now(), - dispute.match_id - ) - .execute(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))?; + sqlx::query("UPDATE matches SET winner_id = $1, updated_at = $2 WHERE id = $3") + .bind(new_winner) + .bind(Utc::now()) + .bind(dispute.match_id) + .execute(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))?; // Recalculate Elo ratings with new winner let match_record = self.get_match_by_id(dispute.match_id).await?; @@ -1903,20 +1911,19 @@ impl MatchService { admin_id: Uuid, reason: String, ) -> Result { - let dispute = sqlx::query_as!( - MatchDispute, + let dispute = sqlx::query_as::<_, MatchDispute>( r#" UPDATE match_disputes SET status = $1, admin_reviewer_id = $2, admin_notes = $3, resolved_at = $4 WHERE id = $5 RETURNING * "#, - DisputeStatus::Rejected as _, - admin_id, - reason, - Utc::now(), - dispute_id ) + .bind(DisputeStatus::Rejected) + .bind(admin_id) + .bind(reason) + .bind(Utc::now()) + .bind(dispute_id) .fetch_one(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -1935,19 +1942,18 @@ impl MatchService { )); } - let updated_match = sqlx::query_as!( - Match, + let updated_match = sqlx::query_as::<_, Match>( r#" UPDATE matches SET status = $1, started_at = $2, updated_at = $3 WHERE id = $4 RETURNING * "#, - MatchStatus::InProgress as _, - Utc::now(), - Utc::now(), - match_id ) + .bind(MatchStatus::InProgress) + .bind(Utc::now()) + .bind(Utc::now()) + .bind(match_id) .fetch_one(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -1983,19 +1989,18 @@ impl MatchService { )); } - let updated_match = sqlx::query_as!( - Match, + let updated_match = sqlx::query_as::<_, Match>( r#" UPDATE matches SET status = $1, scheduled_time = $2, updated_at = $3 WHERE id = $4 RETURNING * "#, - MatchStatus::Scheduled as _, - scheduled_time, - Utc::now(), - match_id ) + .bind(MatchStatus::Scheduled) + .bind(scheduled_time) + .bind(Utc::now()) + .bind(match_id) .fetch_one(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -2028,18 +2033,17 @@ impl MatchService { )); } - let updated_match = sqlx::query_as!( - Match, + let updated_match = sqlx::query_as::<_, Match>( r#" UPDATE matches SET status = $1, updated_at = $2 WHERE id = $3 RETURNING * "#, - MatchStatus::Cancelled as _, - Utc::now(), - match_id ) + .bind(MatchStatus::Cancelled) + .bind(Utc::now()) + .bind(match_id) .fetch_one(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -2050,16 +2054,16 @@ impl MatchService { /// Clean up expired matchmaking queue entries pub async fn cleanup_expired_queue_entries(&self) -> Result { - let result = sqlx::query!( + let result = sqlx::query( r#" UPDATE matchmaking_queue SET status = $1 WHERE status = $2 AND expires_at < $3 "#, - QueueStatus::Expired as _, - QueueStatus::Waiting as _, - Utc::now() ) + .bind(QueueStatus::Expired) + .bind(QueueStatus::Waiting) + .bind(Utc::now()) .execute(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -2074,24 +2078,20 @@ impl MatchService { /// Get dispute details pub async fn get_dispute(&self, dispute_id: Uuid) -> Result { - sqlx::query_as!( - MatchDispute, - "SELECT * FROM match_disputes WHERE id = $1", - dispute_id - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))? - .ok_or(ApiError::not_found("Dispute not found".to_string())) + sqlx::query_as::<_, MatchDispute>("SELECT * FROM match_disputes WHERE id = $1") + .bind(dispute_id) + .fetch_optional(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))? + .ok_or(ApiError::not_found("Dispute not found".to_string())) } /// Get all disputes for a match pub async fn get_match_disputes(&self, match_id: Uuid) -> Result, ApiError> { - sqlx::query_as!( - MatchDispute, + sqlx::query_as::<_, MatchDispute>( "SELECT * FROM match_disputes WHERE match_id = $1 ORDER BY created_at DESC", - match_id ) + .bind(match_id) .fetch_all(&self.db_pool) .await .map_err(|e| ApiError::database_error(e)) @@ -2105,31 +2105,28 @@ impl MatchService { ) -> Result { let offset = (page - 1) * per_page; - let disputes = sqlx::query_as!( - MatchDispute, + let disputes = sqlx::query_as::<_, MatchDispute>( r#" SELECT * FROM match_disputes WHERE status = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3 "#, - DisputeStatus::Pending as _, - per_page, - offset ) + .bind(DisputeStatus::Pending) + .bind(per_page) + .bind(offset) .fetch_all(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; - let total = sqlx::query!( - "SELECT COUNT(*) as count FROM match_disputes WHERE status = $1", - DisputeStatus::Pending as _ - ) - .fetch_one(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))? - .count - .unwrap_or(0); + let total_row = + sqlx::query("SELECT COUNT(*) as count FROM match_disputes WHERE status = $1") + .bind(DisputeStatus::Pending) + .fetch_one(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))?; + let total: i64 = total_row.try_get("count").unwrap_or(0); Ok(DisputeListResponse { disputes, diff --git a/backend/src/service/matchmaker.rs b/backend/src/service/matchmaker.rs index 255c1aa..cafa89d 100644 --- a/backend/src/service/matchmaker.rs +++ b/backend/src/service/matchmaker.rs @@ -1,10 +1,10 @@ use crate::api_error::ApiError; use crate::db::DbPool; -use crate::models::{Match, MatchType, MatchStatus, QueueStatus, UserElo}; +use crate::models::{Match, MatchStatus, MatchType, QueueStatus}; use chrono::{DateTime, Utc}; use redis::AsyncCommands; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use sqlx::Row; use tokio::time::{interval, Duration}; use uuid::Uuid; @@ -82,10 +82,10 @@ impl MatchmakerService { /// Start the background matchmaker worker pub async fn start_matchmaker_worker(&self) -> Result<(), ApiError> { let mut interval = interval(MATCHMAKER_INTERVAL); - + loop { interval.tick().await; - + if let Err(e) = self.process_matchmaking().await { tracing::error!("Matchmaker processing error: {:?}", e); } @@ -94,19 +94,23 @@ impl MatchmakerService { /// Main matchmaking processing loop async fn process_matchmaking(&self) -> Result<(), ApiError> { - let mut conn = self.redis_client.get_multiplexed_async_connection().await + let mut conn = self + .redis_client + .get_multiplexed_async_connection() + .await .map_err(|e| ApiError::internal_error(&format!("Redis connection error: {}", e)))?; // Get all active games let active_games = self.get_active_games().await?; - + for game in active_games { // Process each game mode let game_modes = self.get_active_game_modes(&game).await?; - + for game_mode in game_modes { // Find matches for this game/mode combination - self.find_matches_for_game_mode(&mut conn, &game, &game_mode).await?; + self.find_matches_for_game_mode(&mut conn, &game, &game_mode) + .await?; } } @@ -122,7 +126,7 @@ impl MatchmakerService { ) -> Result<(), ApiError> { // Get all queue entries for this game/mode let queue_entries = self.get_queue_entries(conn, game, game_mode).await?; - + if queue_entries.len() < self.config.min_players_per_match { return Ok(()); } @@ -141,20 +145,25 @@ impl MatchmakerService { } // Find best match for this player - if let Some(candidate) = self.find_best_match_for_player(entry, &sorted_entries[i..], &processed_players).await { - matches_found.push(candidate); + if let Some(candidate) = self + .find_best_match_for_player(entry, &sorted_entries[i..], &processed_players) + .await + { processed_players.insert(candidate.player1.user_id); processed_players.insert(candidate.player2.user_id); + matches_found.push(candidate); } } // Create matches in database - for candidate in matches_found { + for candidate in &matches_found { self.create_match_from_candidate(candidate).await?; - + // Remove players from queue - self.remove_from_queue(conn, &candidate.player1.user_id, game, game_mode).await?; - self.remove_from_queue(conn, &candidate.player2.user_id, game, game_mode).await?; + self.remove_from_queue(conn, &candidate.player1.user_id, game, game_mode) + .await?; + self.remove_from_queue(conn, &candidate.player2.user_id, game, game_mode) + .await?; } Ok(()) @@ -171,13 +180,14 @@ impl MatchmakerService { let mut best_score = -1.0; for candidate in candidates { - if candidate.user_id == player.user_id || processed_players.contains(&candidate.user_id) { + if candidate.user_id == player.user_id || processed_players.contains(&candidate.user_id) + { continue; } // Calculate ELO gap let elo_gap = (player.current_elo - candidate.current_elo).abs(); - + // Skip if ELO gap exceeds maximum for current wait time let max_gap = self.calculate_max_elo_gap(player); if elo_gap > max_gap { @@ -186,7 +196,7 @@ impl MatchmakerService { // Calculate match quality score (0.0 to 1.0) let match_quality = self.calculate_match_quality(player, candidate, elo_gap); - + // Update best candidate if this is better if match_quality > best_score { best_score = match_quality; @@ -208,7 +218,7 @@ impl MatchmakerService { let wait_seconds = wait_time.num_seconds() as u64; let mut max_gap = self.config.elo_bucket_size; - + for interval in &self.config.expansion_intervals { if wait_seconds > interval.as_secs() { max_gap = (max_gap * 2).min(self.config.max_elo_gap); @@ -219,13 +229,22 @@ impl MatchmakerService { } /// Calculate match quality score based on ELO gap and wait time - fn calculate_match_quality(&self, player1: &QueueEntry, player2: &QueueEntry, elo_gap: i32) -> f64 { + fn calculate_match_quality( + &self, + player1: &QueueEntry, + player2: &QueueEntry, + elo_gap: i32, + ) -> f64 { // ELO compatibility (0.0 to 1.0, higher is better) let elo_score = 1.0 - (elo_gap as f64 / self.config.max_elo_gap as f64); - + // Wait time bonus (0.0 to 0.5, longer wait gets higher bonus) - let wait_time1 = Utc::now().signed_duration_since(player1.joined_at).num_seconds() as f64; - let wait_time2 = Utc::now().signed_duration_since(player2.joined_at).num_seconds() as f64; + let wait_time1 = Utc::now() + .signed_duration_since(player1.joined_at) + .num_seconds() as f64; + let wait_time2 = Utc::now() + .signed_duration_since(player2.joined_at) + .num_seconds() as f64; let avg_wait_time = (wait_time1 + wait_time2) / 2.0; let wait_bonus = (avg_wait_time / 600.0).min(0.5); // Max 0.5 bonus after 10 minutes @@ -239,16 +258,20 @@ impl MatchmakerService { game: &str, game_mode: &str, ) -> Result, ApiError> { - let pattern = format!("{}:{}:*", QUEUE_ENTRY_PREFIX, game, game_mode); - let keys: Vec = conn.keys(&pattern).await + let pattern = format!("{}:{}:{}:*", QUEUE_ENTRY_PREFIX, game, game_mode); + let keys: Vec = conn + .keys(&pattern) + .await .map_err(|e| ApiError::internal_error(&format!("Redis keys error: {}", e)))?; let mut entries = Vec::new(); - + for key in keys { - let entry_json: String = conn.get(&key).await + let entry_json: String = conn + .get(&key) + .await .map_err(|e| ApiError::internal_error(&format!("Redis get error: {}", e)))?; - + if let Ok(entry) = serde_json::from_str::(&entry_json) { entries.push(entry); } @@ -265,7 +288,10 @@ impl MatchmakerService { game_mode: String, current_elo: i32, ) -> Result<(), ApiError> { - let mut conn = self.redis_client.get_multiplexed_async_connection().await + let mut conn = self + .redis_client + .get_multiplexed_async_connection() + .await .map_err(|e| ApiError::internal_error(&format!("Redis connection error: {}", e)))?; let now = Utc::now(); @@ -287,19 +313,37 @@ impl MatchmakerService { let entry_json = serde_json::to_string(&entry) .map_err(|e| ApiError::internal_error(&format!("JSON serialization error: {}", e)))?; - conn.set_ex(&key, entry_json, 600).await // 10 minute TTL + conn.set_ex::<_, _, ()>(&key, entry_json, 600) + .await // 10 minute TTL .map_err(|e| ApiError::internal_error(&format!("Redis set error: {}", e)))?; // Add to sorted set for efficient ELO-based queries let elo_bucket = (current_elo / self.config.elo_bucket_size) * self.config.elo_bucket_size; let queue_key = format!("{}:{}:{}:{}", QUEUE_KEY_PREFIX, game, game_mode, elo_bucket); - - conn.zadd(&queue_key, &user_id.to_string(), now.timestamp()).await + + conn.zadd::<_, _, _, ()>(&queue_key, &user_id.to_string(), now.timestamp()) + .await .map_err(|e| ApiError::internal_error(&format!("Redis zadd error: {}", e)))?; Ok(()) } + /// Public method to leave the queue + pub async fn leave_queue( + &self, + user_id: Uuid, + game: &str, + game_mode: &str, + ) -> Result<(), ApiError> { + let mut conn = self + .redis_client + .get_multiplexed_async_connection() + .await + .map_err(|e| ApiError::internal_error(&format!("Redis connection error: {}", e)))?; + self.remove_from_queue(&mut conn, &user_id, game, game_mode) + .await + } + /// Remove player from matchmaking queue async fn remove_from_queue( &self, @@ -310,16 +354,20 @@ impl MatchmakerService { ) -> Result<(), ApiError> { // Remove from entry storage let key = format!("{}:{}:{}:{}", QUEUE_ENTRY_PREFIX, game, game_mode, user_id); - conn.del(&key).await + conn.del::<_, ()>(&key) + .await .map_err(|e| ApiError::internal_error(&format!("Redis del error: {}", e)))?; // Remove from all ELO buckets let pattern = format!("{}:{}:{}:*", QUEUE_KEY_PREFIX, game, game_mode); - let keys: Vec = conn.keys(&pattern).await + let keys: Vec = conn + .keys(&pattern) + .await .map_err(|e| ApiError::internal_error(&format!("Redis keys error: {}", e)))?; for key in keys { - conn.zrem(&key, &user_id.to_string()).await + conn.zrem::<_, _, ()>(&key, &user_id.to_string()) + .await .map_err(|e| ApiError::internal_error(&format!("Redis zrem error: {}", e)))?; } @@ -329,19 +377,18 @@ impl MatchmakerService { /// Calculate initial ELO range for a player fn calculate_initial_elo_range(&self, current_elo: i32) -> (i32, i32) { let range = self.config.elo_bucket_size; - ( - (current_elo - range).max(0), - current_elo + range, - ) + ((current_elo - range).max(0), current_elo + range) } /// Create match in database from candidate - async fn create_match_from_candidate(&self, candidate: MatchCandidate) -> Result { + async fn create_match_from_candidate( + &self, + candidate: &MatchCandidate, + ) -> Result { let match_id = Uuid::new_v4(); let now = Utc::now(); - let match_record: Match = sqlx::query_as!( - Match, + let match_record: Match = sqlx::query_as::<_, Match>( r#" INSERT INTO matches ( id, tournament_id, round_id, match_type, status, player1_id, player2_id, @@ -350,19 +397,19 @@ impl MatchmakerService { $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 ) RETURNING * "#, - match_id, - None::, - None::, - MatchType::Ranked as _, - MatchStatus::Pending as _, - candidate.player1.user_id, - Some(candidate.player2.user_id), - candidate.player1.current_elo, - candidate.player2.current_elo, - candidate.player1.game_mode, - now, - now ) + .bind(match_id) + .bind(None::) + .bind(None::) + .bind(format!("{:?}", MatchType::Ranked)) + .bind(format!("{:?}", MatchStatus::Pending)) + .bind(candidate.player1.user_id) + .bind(Some(candidate.player2.user_id)) + .bind(candidate.player1.current_elo) + .bind(candidate.player2.current_elo) + .bind(&candidate.player1.game_mode) + .bind(now) + .bind(now) .fetch_one(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -381,26 +428,33 @@ impl MatchmakerService { /// Get all active games in the queue async fn get_active_games(&self) -> Result, ApiError> { - let games = sqlx::query!("SELECT DISTINCT game FROM matchmaking_queue WHERE status = $1", QueueStatus::Waiting as _) + let rows = sqlx::query("SELECT DISTINCT game FROM matchmaking_queue WHERE status = $1") + .bind(format!("{:?}", QueueStatus::Waiting)) .fetch_all(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; - Ok(games.into_iter().map(|g| g.game).collect()) + Ok(rows + .into_iter() + .filter_map(|r| r.try_get("game").ok()) + .collect()) } /// Get active game modes for a specific game async fn get_active_game_modes(&self, game: &str) -> Result, ApiError> { - let modes = sqlx::query!( + let rows = sqlx::query( "SELECT DISTINCT game_mode FROM matchmaking_queue WHERE game = $1 AND status = $2", - game, - QueueStatus::Waiting as _ ) + .bind(game) + .bind(format!("{:?}", QueueStatus::Waiting)) .fetch_all(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; - Ok(modes.into_iter().map(|m| m.game_mode).collect()) + Ok(rows + .into_iter() + .filter_map(|r| r.try_get("game_mode").ok()) + .collect()) } /// Check if user is in queue @@ -410,17 +464,23 @@ impl MatchmakerService { game: &str, game_mode: &str, ) -> Result, ApiError> { - let mut conn = self.redis_client.get_multiplexed_async_connection().await + let mut conn = self + .redis_client + .get_multiplexed_async_connection() + .await .map_err(|e| ApiError::internal_error(&format!("Redis connection error: {}", e)))?; let key = format!("{}:{}:{}:{}", QUEUE_ENTRY_PREFIX, game, game_mode, user_id); - let entry_json: Option = conn.get(&key).await + let entry_json: Option = conn + .get(&key) + .await .map_err(|e| ApiError::internal_error(&format!("Redis get error: {}", e)))?; match entry_json { Some(json) => { - let entry = serde_json::from_str(&json) - .map_err(|e| ApiError::internal_error(&format!("JSON deserialization error: {}", e)))?; + let entry = serde_json::from_str(&json).map_err(|e| { + ApiError::internal_error(&format!("JSON deserialization error: {}", e)) + })?; Ok(Some(entry)) } None => Ok(None), @@ -429,11 +489,16 @@ impl MatchmakerService { /// Get queue size for a specific game and mode pub async fn get_queue_size(&self, game: &str, game_mode: &str) -> Result { - let mut conn = self.redis_client.get_multiplexed_async_connection().await + let mut conn = self + .redis_client + .get_multiplexed_async_connection() + .await .map_err(|e| ApiError::internal_error(&format!("Redis connection error: {}", e)))?; let pattern = format!("{}:{}:{}:*", QUEUE_ENTRY_PREFIX, game, game_mode); - let keys: Vec = conn.keys(&pattern).await + let keys: Vec = conn + .keys(&pattern) + .await .map_err(|e| ApiError::internal_error(&format!("Redis keys error: {}", e)))?; Ok(keys.len()) @@ -442,12 +507,12 @@ impl MatchmakerService { /// Get estimated wait time for a player pub async fn get_estimated_wait_time( &self, - user_id: Uuid, + _user_id: Uuid, game: &str, game_mode: &str, ) -> Result { let queue_size = self.get_queue_size(game, game_mode).await?; - + // Rough estimation: 2 minutes per person in queue ahead Ok((queue_size as i32) * 120) } @@ -472,7 +537,8 @@ impl EloEngine { player2_id: Uuid, ) -> (i32, i32) { // Calculate expected scores - let expected_player1 = 1.0 / (1.0 + 10.0_f64.powf((player2_elo - player1_elo) as f64 / 400.0)); + let expected_player1 = + 1.0 / (1.0 + 10.0_f64.powf((player2_elo - player1_elo) as f64 / 400.0)); let expected_player2 = 1.0 - expected_player1; // Determine actual scores @@ -490,8 +556,10 @@ impl EloEngine { }; // Calculate new ELO ratings - let new_player1_elo = player1_elo + (self.k_factor * (actual_player1 - expected_player1)) as i32; - let new_player2_elo = player2_elo + (self.k_factor * (actual_player2 - expected_player2)) as i32; + let new_player1_elo = + player1_elo + (self.k_factor * (actual_player1 - expected_player1)) as i32; + let new_player2_elo = + player2_elo + (self.k_factor * (actual_player2 - expected_player2)) as i32; (new_player1_elo, new_player2_elo) } @@ -503,14 +571,15 @@ impl EloEngine { winner_id: Option, ) -> Result<(), ApiError> { // Get match details - let match_record = sqlx::query_as!(Match, "SELECT * FROM matches WHERE id = $1", match_id) + let match_record = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1") + .bind(match_id) .fetch_one(db_pool) .await .map_err(|e| ApiError::database_error(e))?; // Calculate ELO changes let (new_elo1, new_elo2) = self.calculate_elo_change( - match_record.player1_elo_before, + match_record.player1_elo_before.unwrap_or(1200), match_record.player2_elo_before.unwrap_or(1200), winner_id, match_record.player1_id, @@ -518,38 +587,52 @@ impl EloEngine { ); // Update player 1 ELO - sqlx::query!( + sqlx::query( "UPDATE user_elo SET current_rating = $1, updated_at = $2 WHERE user_id = $3 AND game = $4", - new_elo1, - Utc::now(), - match_record.player1_id, - match_record.game_mode ) + .bind(new_elo1) + .bind(Utc::now()) + .bind(match_record.player1_id) + .bind(&match_record.game_mode) .execute(db_pool) .await .map_err(|e| ApiError::database_error(e))?; // Update player 2 ELO if present if let Some(player2_id) = match_record.player2_id { - sqlx::query!( + sqlx::query( "UPDATE user_elo SET current_rating = $1, updated_at = $2 WHERE user_id = $3 AND game = $4", - new_elo2, - Utc::now(), - player2_id, - match_record.game_mode ) + .bind(new_elo2) + .bind(Utc::now()) + .bind(player2_id) + .bind(&match_record.game_mode) .execute(db_pool) .await .map_err(|e| ApiError::database_error(e))?; } // Create ELO history records - self.create_elo_history(db_pool, match_record.player1_id, &match_record.game_mode, - match_record.player1_elo_before, new_elo1, match_id).await?; - + self.create_elo_history( + db_pool, + match_record.player1_id, + &match_record.game_mode, + match_record.player1_elo_before.unwrap_or(1200), + new_elo1, + match_id, + ) + .await?; + if let Some(player2_id) = match_record.player2_id { - self.create_elo_history(db_pool, player2_id, &match_record.game_mode, - match_record.player2_elo_before.unwrap_or(1200), new_elo2, match_id).await?; + self.create_elo_history( + db_pool, + player2_id, + &match_record.game_mode, + match_record.player2_elo_before.unwrap_or(1200), + new_elo2, + match_id, + ) + .await?; } Ok(()) @@ -564,16 +647,16 @@ impl EloEngine { new_elo: i32, match_id: Uuid, ) -> Result<(), ApiError> { - sqlx::query!( + sqlx::query( "INSERT INTO elo_history (user_id, game, old_rating, new_rating, change_amount, match_id, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", - user_id, - game, - old_elo, - new_elo, - new_elo - old_elo, - match_id, - Utc::now() ) + .bind(user_id) + .bind(game) + .bind(old_elo) + .bind(new_elo) + .bind(new_elo - old_elo) + .bind(match_id) + .bind(Utc::now()) .execute(db_pool) .await .map_err(|e| ApiError::database_error(e))?; diff --git a/backend/src/service/mod.rs b/backend/src/service/mod.rs index b2389e1..132b845 100644 --- a/backend/src/service/mod.rs +++ b/backend/src/service/mod.rs @@ -1,10 +1,13 @@ // Service layer module for ArenaX +pub mod auth_service; pub mod governance_service; pub mod idempotency_service; pub mod match_authority_service; +#[cfg(test)] +mod match_authority_service_test; pub mod match_service; -pub mod reaper_service; pub mod matchmaker; +pub mod reaper_service; pub mod reputation_service; pub mod reward_settlement_service; pub mod soroban_service; @@ -12,6 +15,8 @@ pub mod stellar_service; pub mod tournament_service; pub mod wallet_service; +pub use crate::realtime::event_bus::EventBus; +pub use auth_service::AuthService; pub use governance_service::{ CreateProposalDto, GovernanceService, GovernanceServiceError, ProposalRecord, ProposalStatus as GovProposalStatus, @@ -19,8 +24,8 @@ pub use governance_service::{ pub use idempotency_service::IdempotencyService; pub use match_authority_service::MatchAuthorityService; pub use match_service::MatchService; +pub use matchmaker::{EloEngine, MatchmakerService, MatchmakingConfig}; pub use reaper_service::ReaperService; -pub use matchmaker::{MatchmakerService, EloEngine, MatchmakingConfig}; pub use reputation_service::{PlayerReputation, ReputationService, ReputationTier}; pub use soroban_service::{ DecodedEvent, NetworkConfig, RetryConfig, SorobanError, SorobanService, SorobanTxResult, @@ -29,4 +34,3 @@ pub use soroban_service::{ pub use stellar_service::StellarService; pub use tournament_service::TournamentService; pub use wallet_service::WalletService; -pub use crate::realtime::event_bus::EventBus; diff --git a/backend/src/service/reaper_service.rs b/backend/src/service/reaper_service.rs index 0c77bc9..8e92d8c 100644 --- a/backend/src/service/reaper_service.rs +++ b/backend/src/service/reaper_service.rs @@ -69,8 +69,7 @@ impl ReaperService { tokio::spawn(async move { info!( interval_secs, - "Reaper service started — scanning for expired matches every {}s", - interval_secs + "Reaper service started — scanning for expired matches every {}s", interval_secs ); let mut ticker = tokio::time::interval(Duration::from_secs(interval_secs)); // The first tick fires immediately; skip it so we don't reap on @@ -94,30 +93,39 @@ impl ReaperService { async fn reap(&self) -> Result<(), sqlx::Error> { // Efficient query: uses idx_matches_reaper (status, report_deadline). // We deliberately fetch only the columns the Reaper needs. - let expired = sqlx::query!( + let expired = sqlx::query( r#" SELECT id, player1_id, player2_id FROM matches WHERE status = $1 AND report_deadline < NOW() - "#, - MatchStatus::InProgress as _ + "# ) + .bind(MatchStatus::InProgress) .fetch_all(&self.db_pool) .await?; if !expired.is_empty() { - info!(count = expired.len(), "Reaper found expired in-progress matches"); + info!( + count = expired.len(), + "Reaper found expired in-progress matches" + ); } for row in expired { - if let Err(e) = self - .process_expired_match(row.id, row.player1_id, row.player2_id) - .await - { + let id: Uuid = row.try_get("id").map_err(|e| sqlx::Error::ColumnDecode { + index: "id".to_string(), + source: Box::new(e), + })?; + let player1_id: Uuid = row.try_get("player1_id").map_err(|e| sqlx::Error::ColumnDecode { + index: "player1_id".to_string(), + source: Box::new(e), + })?; + let player2_id: Option = row.try_get("player2_id").ok(); + if let Err(e) = self.process_expired_match(id, player1_id, player2_id).await { // Log and continue — one bad match must not block the rest error!( - match_id = %row.id, + match_id = %id, error = %e, "Reaper failed to process expired match" ); @@ -140,9 +148,7 @@ impl ReaperService { None => { // Give the win to player 1 automatically info!(match_id = %match_id, "Reaper: bye match expired — auto-completing for player 1"); - return self - .complete_with_winner(match_id, player1_id, None) - .await; + return self.complete_with_winner(match_id, player1_id, None).await; } Some(id) => id, }; @@ -195,19 +201,13 @@ impl ReaperService { // HELPERS // ======================================================================== - async fn player_has_reported( - &self, - match_id: Uuid, - player_id: Uuid, - ) -> Result { - let row = sqlx::query!( - "SELECT id FROM match_scores WHERE match_id = $1 AND player_id = $2", - match_id, - player_id - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))?; + async fn player_has_reported(&self, match_id: Uuid, player_id: Uuid) -> Result { + let row = sqlx::query("SELECT id FROM match_scores WHERE match_id = $1 AND player_id = $2") + .bind(match_id) + .bind(player_id) + .fetch_optional(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))?; Ok(row.is_some()) } @@ -219,7 +219,7 @@ impl ReaperService { forfeited_player: Uuid, winner_id: Uuid, ) -> Result<(), ApiError> { - sqlx::query!( + sqlx::query( r#" UPDATE matches SET status = $1, @@ -228,13 +228,13 @@ impl ReaperService { completed_at = $4, updated_at = $4 WHERE id = $5 - "#, - MatchStatus::Completed as _, - winner_id, - forfeited_player, - Utc::now(), - match_id + "# ) + .bind(MatchStatus::Completed) + .bind(winner_id) + .bind(forfeited_player) + .bind(Utc::now()) + .bind(match_id) .execute(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -257,7 +257,7 @@ impl ReaperService { winner_id: Uuid, _forfeited_player: Option, ) -> Result<(), ApiError> { - sqlx::query!( + sqlx::query( r#" UPDATE matches SET status = $1, @@ -265,12 +265,12 @@ impl ReaperService { completed_at = $3, updated_at = $3 WHERE id = $4 - "#, - MatchStatus::Completed as _, - winner_id, - Utc::now(), - match_id + "# ) + .bind(MatchStatus::Completed) + .bind(winner_id) + .bind(Utc::now()) + .bind(match_id) .execute(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; @@ -280,17 +280,17 @@ impl ReaperService { /// Neither player reported in time — cancel without awarding a winner. async fn cancel_abandoned(&self, match_id: Uuid) -> Result<(), ApiError> { - sqlx::query!( + sqlx::query( r#" UPDATE matches SET status = $1, updated_at = $2 WHERE id = $3 - "#, - MatchStatus::Cancelled as _, - Utc::now(), - match_id + "# ) + .bind(MatchStatus::Cancelled) + .bind(Utc::now()) + .bind(match_id) .execute(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; diff --git a/backend/src/service/reputation_service.rs b/backend/src/service/reputation_service.rs index ae1502a..ae1de9b 100644 --- a/backend/src/service/reputation_service.rs +++ b/backend/src/service/reputation_service.rs @@ -7,11 +7,9 @@ //! - Track anti-cheat flags and penalties use crate::config::Config; -use crate::models::User; use sqlx::{PgPool, Row}; -use std::sync::Arc; use thiserror::Error; -use tracing::{debug, error, info, warn}; +use tracing::{debug, info, warn}; #[derive(Error, Debug)] pub enum ReputationError { @@ -66,6 +64,7 @@ pub enum ReputationTier { pub struct ReputationService { db_pool: PgPool, + #[allow(dead_code)] config: Config, } @@ -79,7 +78,7 @@ impl ReputationService { &self, user_id: uuid::Uuid, ) -> Result { - let record = sqlx::query!( + let record = sqlx::query( r#" SELECT id as user_id, @@ -90,56 +89,32 @@ impl ReputationService { FROM users WHERE id = $1 "#, - user_id ) + .bind(user_id) .fetch_optional(&self.db_pool) .await - .map_err(|e| ReputationError::DatabaseError(e.to_string()))? + .map_err(|e: sqlx::Error| ReputationError::DatabaseError(e.to_string()))? .ok_or_else(|| ReputationError::InvalidData(format!("User {} not found", user_id)))?; + let last_updated: Option> = + record.try_get("reputation_last_updated").unwrap_or(None); + Ok(PlayerReputation { - user_id: record.user_id, - skill_score: record.skill_score, - fair_play_score: record.fair_play_score, - last_update_ts: record - .reputation_last_updated - .map(|t| t.timestamp() as u64) - .unwrap_or(0), - is_bad_actor: record.is_bad_actor, + user_id: record.try_get("user_id").unwrap_or(user_id), + skill_score: record.try_get("skill_score").unwrap_or(1000), + fair_play_score: record.try_get("fair_play_score").unwrap_or(100), + last_update_ts: last_updated.map(|t| t.timestamp() as u64).unwrap_or(0), + is_bad_actor: record.try_get("is_bad_actor").unwrap_or(false), }) } /// Update player reputation from on-chain contract - /// This should be called periodically or after match events pub async fn sync_reputation_from_chain( &self, user_id: uuid::Uuid, ) -> Result { - // TODO: Implement actual Soroban contract call to fetch on-chain reputation - // For now, we'll use the cached value from the database - // In production, this would call the ReputationIndex contract's get_reputation() method - debug!("Syncing reputation for user {}", user_id); - - // Fetch current cached reputation - let reputation = self.get_player_reputation(user_id).await?; - - // TODO: Call Soroban contract here using soroban_service - // Example (pseudo-code): - // let contract_address = self.get_contract_address("reputation_index").await?; - // let soroban_client = SorobanClient::new(&self.config.stellar.network_url); - // let on_chain_rep = soroban_client - // .invoke_contract::( - // &contract_address, - // "get_reputation", - // vec![user_public_key] - // ) - // .await?; - // - // // Update local cache - // self.update_local_reputation(user_id, on_chain_rep).await?; - - Ok(reputation) + self.get_player_reputation(user_id).await } /// Batch update reputation after match completion @@ -166,7 +141,7 @@ impl ReputationService { let fair_play_delta = 1; // Completion bonus // Update user reputation - sqlx::query!( + sqlx::query( r#" UPDATE users SET @@ -176,49 +151,46 @@ impl ReputationService { updated_at = NOW() WHERE id = $3 "#, - skill_delta, - fair_play_delta, - player_id ) + .bind(skill_delta) + .bind(fair_play_delta) + .bind(player_id) .execute(&mut *tx) .await .map_err(|e| ReputationError::DatabaseError(e.to_string()))?; // Record reputation event - sqlx::query!( + sqlx::query( r#" INSERT INTO reputation_events ( user_id, event_type, skill_delta, fair_play_delta, match_id ) VALUES ($1, $2, $3, $4, $5) "#, - player_id, - "match_completion", - skill_delta, - fair_play_delta, - match_id ) + .bind(player_id) + .bind("match_completion") + .bind(skill_delta) + .bind(fair_play_delta) + .bind(match_id) .execute(&mut *tx) .await .map_err(|e| ReputationError::DatabaseError(e.to_string()))?; // Check if player should be flagged as bad actor - let rep = sqlx::query!( - r#"SELECT fair_play_score FROM users WHERE id = $1"#, - player_id - ) - .fetch_one(&mut *tx) - .await - .map_err(|e| ReputationError::DatabaseError(e.to_string()))?; - - if rep.fair_play_score < 30 { - sqlx::query!( - r#"UPDATE users SET is_bad_actor = true WHERE id = $1"#, - player_id - ) - .execute(&mut *tx) + let rep_row = sqlx::query(r#"SELECT fair_play_score FROM users WHERE id = $1"#) + .bind(player_id) + .fetch_one(&mut *tx) .await .map_err(|e| ReputationError::DatabaseError(e.to_string()))?; + let fair_play: Option = rep_row.try_get("fair_play_score").unwrap_or(None); + if fair_play.unwrap_or(100) < 30 { + sqlx::query(r#"UPDATE users SET is_bad_actor = true WHERE id = $1"#) + .bind(player_id) + .execute(&mut *tx) + .await + .map_err(|e| ReputationError::DatabaseError(e.to_string()))?; + warn!("Player {} flagged as bad actor (fair_play < 30)", player_id); } } @@ -251,7 +223,7 @@ impl ReputationService { .map_err(|e| ReputationError::DatabaseError(e.to_string()))?; // Apply penalty to fair_play score (capped at 0) - let result = sqlx::query!( + let result_row = sqlx::query( r#" UPDATE users SET @@ -266,29 +238,31 @@ impl ReputationService { WHERE id = $2 RETURNING fair_play_score "#, - penalty, - user_id ) + .bind(penalty) + .bind(user_id) .fetch_one(&mut *tx) .await .map_err(|e| ReputationError::DatabaseError(e.to_string()))?; + let new_fair_play: Option = result_row.try_get("fair_play_score").unwrap_or(None); + // Record penalty event - sqlx::query!( + sqlx::query( r#" INSERT INTO reputation_events ( user_id, event_type, skill_delta, fair_play_delta, match_id, transaction_hash, metadata ) VALUES ($1, $2, $3, $4, $5, $6, $7) "#, - user_id, - "anticheat_penalty", - 0, - -penalty, - match_id, - transaction_hash, - serde_json::json!({"penalty": penalty}).to_string() ) + .bind(user_id) + .bind("anticheat_penalty") + .bind(0i32) + .bind(-penalty) + .bind(match_id) + .bind(transaction_hash) + .bind(serde_json::json!({"penalty": penalty}).to_string()) .execute(&mut *tx) .await .map_err(|e| ReputationError::DatabaseError(e.to_string()))?; @@ -299,7 +273,9 @@ impl ReputationService { warn!( "Applied anti-cheat penalty {} to user {} (new fair_play: {})", - penalty, user_id, result.fair_play_score.unwrap_or(0) + penalty, + user_id, + new_fair_play.unwrap_or(0) ); Ok(()) @@ -315,7 +291,7 @@ impl ReputationService { return Ok(vec![]); } - let filtered = sqlx::query!( + let rows = sqlx::query( r#" SELECT id FROM users @@ -323,36 +299,47 @@ impl ReputationService { AND is_bad_actor = false AND COALESCE(fair_play_score, 100) >= $2 "#, - candidate_ids, - min_fair_play ) + .bind(candidate_ids) + .bind(min_fair_play) .fetch_all(&self.db_pool) .await - .map_err(|e| ReputationError::DatabaseError(e.to_string()))? - .into_iter() - .map(|r| r.id) - .collect(); + .map_err(|e| ReputationError::DatabaseError(e.to_string()))?; + + let filtered = rows + .iter() + .filter_map(|r| r.try_get::("id").ok()) + .collect(); Ok(filtered) } /// Get contract address from registry - pub async fn get_contract_address(&self, contract_name: &str) -> Result { - let record = sqlx::query!( + pub async fn get_contract_address( + &self, + contract_name: &str, + ) -> Result { + let record = sqlx::query( r#"SELECT contract_address FROM soroban_contracts WHERE contract_name = $1 AND is_active = true"#, - contract_name ) + .bind(contract_name) .fetch_optional(&self.db_pool) .await .map_err(|e| ReputationError::DatabaseError(e.to_string()))? .ok_or_else(|| ReputationError::ContractNotFound(contract_name.to_string()))?; - Ok(record.contract_address) + Ok(record + .try_get::("contract_address") + .map_err(|e| ReputationError::DatabaseError(e.to_string()))?) } /// Apply time-based decay to player reputation (periodic maintenance) - pub async fn apply_decay(&self, user_id: uuid::Uuid, decay_amount: i32) -> Result<(), ReputationError> { - sqlx::query!( + pub async fn apply_decay( + &self, + user_id: uuid::Uuid, + decay_amount: i32, + ) -> Result<(), ReputationError> { + sqlx::query( r#" UPDATE users SET @@ -362,9 +349,9 @@ impl ReputationService { updated_at = NOW() WHERE id = $2 "#, - decay_amount, - user_id ) + .bind(decay_amount) + .bind(user_id) .execute(&self.db_pool) .await .map_err(|e| ReputationError::DatabaseError(e.to_string()))?; @@ -376,7 +363,7 @@ impl ReputationService { /// Get reputation statistics for monitoring pub async fn get_reputation_stats(&self) -> Result { - let stats = sqlx::query!( + let row = sqlx::query( r#" SELECT COUNT(*) FILTER (WHERE is_bad_actor = true) as bad_actors_count, @@ -386,18 +373,18 @@ impl ReputationService { AVG(COALESCE(fair_play_score, 100)) as avg_fair_play FROM users WHERE is_active = true - "# + "#, ) .fetch_one(&self.db_pool) .await .map_err(|e| ReputationError::DatabaseError(e.to_string()))?; Ok(ReputationStats { - bad_actors_count: stats.bad_actors_count.unwrap_or(0) as i64, - low_fair_play_count: stats.low_fair_play_count.unwrap_or(0) as i64, - high_skill_count: stats.high_skill_count.unwrap_or(0) as i64, - avg_skill: stats.avg_skill.unwrap_or(1000.0), - avg_fair_play: stats.avg_fair_play.unwrap_or(100.0), + bad_actors_count: row.try_get::("bad_actors_count").unwrap_or(0), + low_fair_play_count: row.try_get::("low_fair_play_count").unwrap_or(0), + high_skill_count: row.try_get::("high_skill_count").unwrap_or(0), + avg_skill: row.try_get::("avg_skill").unwrap_or(1000.0), + avg_fair_play: row.try_get::("avg_fair_play").unwrap_or(100.0), }) } } @@ -411,6 +398,3 @@ pub struct ReputationStats { pub avg_skill: f64, pub avg_fair_play: f64, } - -#[cfg(test)] -mod tests; diff --git a/backend/src/service/reward_settlement_service.rs b/backend/src/service/reward_settlement_service.rs index 1c43e77..67041af 100644 --- a/backend/src/service/reward_settlement_service.rs +++ b/backend/src/service/reward_settlement_service.rs @@ -151,7 +151,13 @@ mod tests { use super::*; fn create_test_service() -> RewardSettlementService { - RewardSettlementService::new(DbPool) + // Pool is unused in current implementation (in-memory storage), + // so we create a lazy pool that won't actually connect. + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_lazy("postgres://localhost/test") + .expect("failed to create lazy pool"); + RewardSettlementService::new(pool) } #[test] diff --git a/backend/src/service/soroban_service.rs b/backend/src/service/soroban_service.rs index e14f7f4..691d972 100644 --- a/backend/src/service/soroban_service.rs +++ b/backend/src/service/soroban_service.rs @@ -140,6 +140,7 @@ struct RpcRequest { } #[derive(Debug, Deserialize)] +#[allow(dead_code)] struct RpcResponse { jsonrpc: String, id: u64, @@ -155,6 +156,7 @@ enum RpcResult { } #[derive(Debug, Deserialize)] +#[allow(dead_code)] struct RpcError { code: i32, message: String, @@ -163,6 +165,7 @@ struct RpcError { } #[derive(Debug, Deserialize)] +#[allow(dead_code)] struct SimulateResponse { #[serde(rename = "transactionData")] transaction_data: String, @@ -179,6 +182,7 @@ struct SimulateResponse { } #[derive(Debug, Deserialize)] +#[allow(dead_code)] struct SendTransactionResponse { #[serde(rename = "hash")] hash: String, @@ -191,6 +195,7 @@ struct SendTransactionResponse { } #[derive(Debug, Deserialize)] +#[allow(dead_code)] struct GetTransactionResponse { #[serde(rename = "status")] status: String, @@ -408,7 +413,7 @@ impl SorobanService { }); } "FAILED" => { - let error_msg = format!("Transaction failed on network"); + let error_msg = "Transaction failed on network".to_string(); error!(tx_hash = tx_hash, "Transaction failed"); return Ok(SorobanTxResult { hash: tx_hash.to_string(), @@ -588,7 +593,7 @@ impl SorobanService { fn sign_transaction( &self, tx_data: &serde_json::Value, - secret: &str, + _secret: &str, ) -> Result { // In production, use ed25519-dalek or stellar-sdk to properly sign // This is a simplified placeholder @@ -651,7 +656,7 @@ mod tests { let network = NetworkConfig::testnet(); let service = SorobanService::new(network); // Service should be created without errors - assert!(true); + assert!(!service.network.rpc_url.is_empty()); } #[test] @@ -783,7 +788,7 @@ mod tests { let service = SorobanService::with_retry_config(network, retry_config); // Service should be created without errors - assert!(true); + assert!(!service.network.rpc_url.is_empty()); } #[test] diff --git a/backend/src/service/stellar_service.rs b/backend/src/service/stellar_service.rs index 60dd421..836dfa0 100644 --- a/backend/src/service/stellar_service.rs +++ b/backend/src/service/stellar_service.rs @@ -1,8 +1,8 @@ +use crate::db::DbPool; use crate::models::{StellarAccount, StellarTransaction}; use anyhow::Result; use chrono::Utc; use redis::Client as RedisClient; -use sqlx::PgPool; use std::sync::Arc; use thiserror::Error; use uuid::Uuid; @@ -25,9 +25,8 @@ pub enum StellarError { StellarSdkError(String), } -pub type DbPool = Arc; - #[derive(Clone)] +#[allow(dead_code)] pub struct StellarService { db_pool: DbPool, redis_client: Option>, @@ -70,8 +69,7 @@ impl StellarService { let encrypted_secret = self.encrypt_secret_key(&secret_key)?; // Store in database - let account = sqlx::query_as!( - StellarAccount, + let account = sqlx::query_as::<_, StellarAccount>( r#" INSERT INTO stellar_accounts ( id, user_id, public_key, encrypted_secret_key, account_type, @@ -80,18 +78,18 @@ impl StellarService { VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING * "#, - Uuid::new_v4(), - user_id, - public_key, - Some(encrypted_secret), - account_type, - false, - true, - 0i64, - Utc::now(), - Utc::now() ) - .fetch_one(&*self.db_pool) + .bind(Uuid::new_v4()) + .bind(user_id) + .bind(&public_key) + .bind(Some(&encrypted_secret)) + .bind(account_type) + .bind(false) + .bind(true) + .bind(0i64) + .bind(Utc::now()) + .bind(Utc::now()) + .fetch_one(&self.db_pool) .await?; tracing::info!( @@ -105,17 +103,16 @@ impl StellarService { /// Get Stellar account by user ID pub async fn get_account(&self, user_id: Uuid) -> Result { - let account = sqlx::query_as!( - StellarAccount, + let account = sqlx::query_as::<_, StellarAccount>( r#" SELECT * FROM stellar_accounts WHERE user_id = $1 AND is_active = true ORDER BY created_at DESC LIMIT 1 "#, - user_id ) - .fetch_optional(&*self.db_pool) + .bind(user_id) + .fetch_optional(&self.db_pool) .await?; account.ok_or(StellarError::AccountNotFound) @@ -126,15 +123,14 @@ impl StellarService { &self, public_key: &str, ) -> Result { - let account = sqlx::query_as!( - StellarAccount, + let account = sqlx::query_as::<_, StellarAccount>( r#" SELECT * FROM stellar_accounts WHERE public_key = $1 "#, - public_key ) - .fetch_optional(&*self.db_pool) + .bind(public_key) + .fetch_optional(&self.db_pool) .await?; account.ok_or(StellarError::AccountNotFound) @@ -161,17 +157,17 @@ impl StellarService { // .map_err(|e| StellarError::TransactionFailed(e.to_string()))?; // Mark account as funded - sqlx::query!( + sqlx::query( r#" UPDATE stellar_accounts SET is_funded = true, balance_xlm = $1, updated_at = $2 WHERE public_key = $3 "#, - amount, - Utc::now(), - public_key ) - .execute(&*self.db_pool) + .bind(amount) + .bind(Utc::now()) + .bind(public_key) + .execute(&self.db_pool) .await?; Ok("testnet-funding-tx-hash".to_string()) @@ -183,17 +179,17 @@ impl StellarService { public_key: &str, balance_xlm: i64, ) -> Result<(), StellarError> { - sqlx::query!( + sqlx::query( r#" UPDATE stellar_accounts SET balance_xlm = $1, updated_at = $2 WHERE public_key = $3 "#, - balance_xlm, - Utc::now(), - public_key ) - .execute(&*self.db_pool) + .bind(balance_xlm) + .bind(Utc::now()) + .bind(public_key) + .execute(&self.db_pool) .await?; Ok(()) @@ -375,6 +371,7 @@ impl StellarService { // ======================================================================== /// Record a Stellar transaction in the database + #[allow(clippy::too_many_arguments)] pub async fn record_transaction( &self, tx_hash: &str, @@ -387,8 +384,7 @@ impl StellarService { memo: Option, user_id: Option, ) -> Result { - let transaction = sqlx::query_as!( - StellarTransaction, + let transaction = sqlx::query_as::<_, StellarTransaction>( r#" INSERT INTO stellar_transactions ( id, user_id, transaction_hash, source_account, destination_account, @@ -398,27 +394,27 @@ impl StellarService { VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING * "#, - Uuid::new_v4(), - user_id, - tx_hash, - source_account, - destination_account, - amount, - asset_code, - asset_issuer, - operation_type, - memo, - "pending", - Utc::now() ) - .fetch_one(&*self.db_pool) + .bind(Uuid::new_v4()) + .bind(user_id) + .bind(tx_hash) + .bind(source_account) + .bind(destination_account) + .bind(amount) + .bind(asset_code) + .bind(asset_issuer) + .bind(operation_type) + .bind(&memo) + .bind("pending") + .bind(Utc::now()) + .fetch_one(&self.db_pool) .await?; Ok(transaction) } /// Verify a Stellar transaction on the network - pub async fn verify_transaction(&self, tx_hash: &str) -> Result { + pub async fn verify_transaction(&self, _tx_hash: &str) -> Result { // TODO: Implement actual verification by querying Horizon // let client = reqwest::Client::new(); // let response = client @@ -449,15 +445,14 @@ impl StellarService { /// Get transaction by hash pub async fn get_transaction(&self, tx_hash: &str) -> Result { - let transaction = sqlx::query_as!( - StellarTransaction, + let transaction = sqlx::query_as::<_, StellarTransaction>( r#" SELECT * FROM stellar_transactions WHERE transaction_hash = $1 "#, - tx_hash ) - .fetch_optional(&*self.db_pool) + .bind(tx_hash) + .fetch_optional(&self.db_pool) .await?; transaction.ok_or(StellarError::TransactionFailed( @@ -498,6 +493,7 @@ impl StellarService { } /// Decrypt a secret key from storage + #[allow(dead_code)] fn decrypt_secret_key(&self, encrypted: &str) -> Result { // TODO: Implement actual decryption // For now, just base64 decode (NOT SECURE - implement proper decryption) diff --git a/backend/src/service/tournament_service.rs b/backend/src/service/tournament_service.rs index 2eea2cb..e5871df 100644 --- a/backend/src/service/tournament_service.rs +++ b/backend/src/service/tournament_service.rs @@ -1,11 +1,11 @@ use crate::api_error::ApiError; use crate::db::DbPool; -use crate::models::*; -use chrono::{DateTime, Utc}; +use crate::models::tournament::*; +use crate::models::wallet::Wallet; +use chrono::Utc; use redis::Client as RedisClient; use serde::{Deserialize, Serialize}; use sqlx::Row; -use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; @@ -33,12 +33,9 @@ impl TournamentService { creator_id: Uuid, request: CreateTournamentRequest, ) -> Result { - // Validate tournament data self.validate_tournament_creation(&request).await?; - // Create tournament - let tournament = sqlx::query_as!( - Tournament, + let tournament = sqlx::query_as::<_, Tournament>( r#" INSERT INTO tournaments ( id, name, description, game, max_participants, entry_fee, entry_fee_currency, @@ -48,35 +45,33 @@ impl TournamentService { $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19 ) RETURNING * "#, - Uuid::new_v4(), - request.name, - request.description, - request.game, - request.max_participants, - request.entry_fee, - request.entry_fee_currency, - 0, // Initial prize pool - request.entry_fee_currency.clone(), - TournamentStatus::Draft as _, - request.start_time, - request.registration_deadline, - creator_id, - Utc::now(), - Utc::now(), - request.bracket_type as _, - request.rules, - request.min_skill_level, - request.max_skill_level ) + .bind(Uuid::new_v4()) + .bind(&request.name) + .bind(&request.description) + .bind(&request.game) + .bind(request.max_participants) + .bind(request.entry_fee) + .bind(&request.entry_fee_currency) + .bind(0i64) // Initial prize pool + .bind(&request.entry_fee_currency) + .bind(TournamentStatus::Draft) + .bind(request.start_time) + .bind(request.registration_deadline) + .bind(creator_id) + .bind(Utc::now()) + .bind(Utc::now()) + .bind(&request.bracket_type) + .bind(&request.rules) + .bind(request.min_skill_level) + .bind(request.max_skill_level) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - // Create prize pool record self.create_prize_pool(&tournament.id, &request.entry_fee_currency) .await?; - // Publish tournament created event self.publish_tournament_event(serde_json::json!({ "type": "created", "tournament_id": tournament.id, @@ -86,7 +81,6 @@ impl TournamentService { })) .await?; - // Publish global event self.publish_global_event(serde_json::json!({ "type": "tournament_created", "tournament_id": tournament.id, @@ -109,85 +103,55 @@ impl TournamentService { ) -> Result { let offset = (page - 1) * per_page; - let mut query = String::from( - "SELECT t.*, COUNT(tp.id) as current_participants FROM tournaments t - LEFT JOIN tournament_participants tp ON t.id = tp.tournament_id - WHERE 1=1", - ); - let mut params: Vec + Send + Sync>> = Vec::new(); - let mut param_count = 0; - - if let Some(status) = status_filter { - param_count += 1; - query.push_str(&format!(" AND t.status = ${}", param_count)); - params.push(Box::new(status as i32)); - } - - if let Some(game) = game_filter { - param_count += 1; - query.push_str(&format!(" AND t.game = ${}", param_count)); - params.push(Box::new(game)); - } - - query.push_str(" GROUP BY t.id ORDER BY t.created_at DESC"); - - param_count += 1; - query.push_str(&format!(" LIMIT ${}", param_count)); - params.push(Box::new(per_page)); - - param_count += 1; - query.push_str(&format!(" OFFSET ${}", param_count)); - params.push(Box::new(offset)); - - // For now, we'll use a simpler approach with sqlx::query - let tournaments = sqlx::query!( + let rows = sqlx::query( r#" SELECT t.*, COUNT(tp.id) as current_participants FROM tournaments t LEFT JOIN tournament_participants tp ON t.id = tp.tournament_id - WHERE ($1::text IS NULL OR t.status = $1::tournament_status) + WHERE ($1::text IS NULL OR t.status::text = $1) AND ($2::text IS NULL OR t.game = $2) GROUP BY t.id ORDER BY t.created_at DESC LIMIT $3 OFFSET $4 "#, - status_filter.map(|s| s as i32), - game_filter, - per_page, - offset ) + .bind(status_filter.map(|s| s.to_string())) + .bind(&game_filter) + .bind(per_page) + .bind(offset) .fetch_all(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - // Get total count - let total = sqlx::query!( + let total_row = sqlx::query( r#" SELECT COUNT(*) as count FROM tournaments t - WHERE ($1::text IS NULL OR t.status = $1::tournament_status) + WHERE ($1::text IS NULL OR t.status::text = $1) AND ($2::text IS NULL OR t.game = $2) "#, - status_filter.map(|s| s as i32), - game_filter ) + .bind(status_filter.map(|s| s.to_string())) + .bind(&game_filter) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? - .count - .unwrap_or(0); + .map_err(ApiError::database_error)?; + + let total: i64 = total_row.get("count"); - // Convert to response format let mut tournament_responses = Vec::new(); - for row in tournaments { + for row in rows { + let t_id: Uuid = row.get("id"); + let current_participants: Option = row.get("current_participants"); + let is_participant = if let Some(uid) = user_id { - self.is_user_participant(uid, row.id).await.unwrap_or(false) + self.is_user_participant(uid, t_id).await.unwrap_or(false) } else { false }; let participant_status = if is_participant { - self.get_participant_status(user_id.unwrap(), row.id) + self.get_participant_status(user_id.unwrap(), t_id) .await .ok() } else { @@ -195,26 +159,26 @@ impl TournamentService { }; let can_join = self - .can_user_join_tournament(user_id, row.id) + .can_user_join_tournament(user_id, t_id) .await .unwrap_or(false); tournament_responses.push(TournamentResponse { - id: row.id, - name: row.name, - description: row.description, - game: row.game, - max_participants: row.max_participants, - current_participants: row.current_participants.unwrap_or(0) as i32, - entry_fee: row.entry_fee, - entry_fee_currency: row.entry_fee_currency, - prize_pool: row.prize_pool, - prize_pool_currency: row.prize_pool_currency, - status: row.status.into(), - start_time: row.start_time, - end_time: row.end_time, - registration_deadline: row.registration_deadline, - bracket_type: row.bracket_type.into(), + id: t_id, + name: row.get("name"), + description: row.get("description"), + game: row.get("game"), + max_participants: row.get("max_participants"), + current_participants: current_participants.unwrap_or(0) as i32, + entry_fee: row.get("entry_fee"), + entry_fee_currency: row.get("entry_fee_currency"), + prize_pool: row.get("prize_pool"), + prize_pool_currency: row.get("prize_pool_currency"), + status: row.get("status"), + start_time: row.get("start_time"), + end_time: row.get("end_time"), + registration_deadline: row.get("registration_deadline"), + bracket_type: row.get("bracket_type"), can_join, is_participant, participant_status, @@ -235,7 +199,7 @@ impl TournamentService { tournament_id: Uuid, user_id: Option, ) -> Result { - let tournament = sqlx::query!( + let row = sqlx::query( r#" SELECT t.*, COUNT(tp.id) as current_participants FROM tournaments t @@ -243,13 +207,15 @@ impl TournamentService { WHERE t.id = $1 GROUP BY t.id "#, - tournament_id ) + .bind(tournament_id) .fetch_optional(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? + .map_err(ApiError::database_error)? .ok_or(ApiError::not_found("Tournament not found"))?; + let current_participants: Option = row.get("current_participants"); + let is_participant = if let Some(uid) = user_id { self.is_user_participant(uid, tournament_id) .await @@ -272,21 +238,21 @@ impl TournamentService { .unwrap_or(false); Ok(TournamentResponse { - id: tournament.id, - name: tournament.name, - description: tournament.description, - game: tournament.game, - max_participants: tournament.max_participants, - current_participants: tournament.current_participants.unwrap_or(0) as i32, - entry_fee: tournament.entry_fee, - entry_fee_currency: tournament.entry_fee_currency, - prize_pool: tournament.prize_pool, - prize_pool_currency: tournament.prize_pool_currency, - status: tournament.status.into(), - start_time: tournament.start_time, - end_time: tournament.end_time, - registration_deadline: tournament.registration_deadline, - bracket_type: tournament.bracket_type.into(), + id: row.get("id"), + name: row.get("name"), + description: row.get("description"), + game: row.get("game"), + max_participants: row.get("max_participants"), + current_participants: current_participants.unwrap_or(0) as i32, + entry_fee: row.get("entry_fee"), + entry_fee_currency: row.get("entry_fee_currency"), + prize_pool: row.get("prize_pool"), + prize_pool_currency: row.get("prize_pool_currency"), + status: row.get("status"), + start_time: row.get("start_time"), + end_time: row.get("end_time"), + registration_deadline: row.get("registration_deadline"), + bracket_type: row.get("bracket_type"), can_join, is_participant, participant_status, @@ -300,22 +266,17 @@ impl TournamentService { tournament_id: Uuid, request: JoinTournamentRequest, ) -> Result { - // Validate tournament can be joined let tournament = self.get_tournament_by_id(tournament_id).await?; self.validate_tournament_join(&tournament, user_id).await?; - // Check if user is already a participant if self.is_user_participant(user_id, tournament_id).await? { return Err(ApiError::bad_request("User is already a participant")); } - // Process payment self.process_entry_fee_payment(user_id, &tournament, &request) .await?; - // Add participant - let participant = sqlx::query_as!( - TournamentParticipant, + let participant = sqlx::query_as::<_, TournamentParticipant>( r#" INSERT INTO tournament_participants ( id, tournament_id, user_id, registered_at, entry_fee_paid, status @@ -323,32 +284,28 @@ impl TournamentService { $1, $2, $3, $4, $5, $6 ) RETURNING * "#, - Uuid::new_v4(), - tournament_id, - user_id, - Utc::now(), - true, - ParticipantStatus::Paid as _ ) + .bind(Uuid::new_v4()) + .bind(tournament_id) + .bind(user_id) + .bind(Utc::now()) + .bind(true) + .bind(ParticipantStatus::Paid) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - // Update prize pool self.update_prize_pool(tournament_id, tournament.entry_fee) .await?; - // Update tournament status if needed self.update_tournament_status_if_needed(tournament_id) .await?; - // Get username for event let username = self .get_user_username(user_id) .await - .unwrap_or_else(|| "Unknown".to_string()); + .unwrap_or_else(|_| "Unknown".to_string()); - // Publish participant joined event self.publish_tournament_event(serde_json::json!({ "type": "participant_joined", "tournament_id": tournament_id, @@ -367,23 +324,21 @@ impl TournamentService { tournament_id: Uuid, new_status: TournamentStatus, ) -> Result { - let tournament = sqlx::query_as!( - Tournament, + let tournament = sqlx::query_as::<_, Tournament>( r#" UPDATE tournaments SET status = $1, updated_at = $2 WHERE id = $3 RETURNING * "#, - new_status as _, - Utc::now(), - tournament_id ) + .bind(new_status) + .bind(Utc::now()) + .bind(tournament_id) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - // Handle status-specific logic match new_status { TournamentStatus::InProgress => { self.start_tournament(tournament_id).await?; @@ -394,13 +349,10 @@ impl TournamentService { _ => {} } - // Publish status change event - let old_status = self.get_tournament_by_id(tournament_id).await?.status; self.publish_tournament_event(serde_json::json!({ "type": "status_changed", "tournament_id": tournament_id, - "old_status": old_status, - "new_status": new_status, + "new_status": format!("{}", new_status), })) .await?; @@ -416,27 +368,22 @@ impl TournamentService { if request.name.is_empty() { return Err(ApiError::bad_request("Tournament name is required")); } - if request.max_participants < 2 { return Err(ApiError::bad_request( "Tournament must have at least 2 participants", )); } - if request.entry_fee < 0 { return Err(ApiError::bad_request("Entry fee cannot be negative")); } - if request.start_time <= Utc::now() { return Err(ApiError::bad_request("Start time must be in the future")); } - if request.registration_deadline >= request.start_time { return Err(ApiError::bad_request( "Registration deadline must be before start time", )); } - Ok(()) } @@ -450,18 +397,13 @@ impl TournamentService { "Tournament is not accepting registrations", )); } - if Utc::now() > tournament.registration_deadline { return Err(ApiError::bad_request("Registration deadline has passed")); } - - // Check participant count let current_count = self.get_participant_count(tournament.id).await?; if current_count >= tournament.max_participants { return Err(ApiError::bad_request("Tournament is full")); } - - // Check skill level requirements if let (Some(min_skill), Some(max_skill)) = (tournament.min_skill_level, tournament.max_skill_level) { @@ -472,7 +414,6 @@ impl TournamentService { )); } } - Ok(()) } @@ -484,12 +425,10 @@ impl TournamentService { ) -> Result<(), ApiError> { match request.payment_method.as_str() { "fiat" => { - // Process fiat payment via Paystack/Flutterwave self.process_fiat_payment(user_id, tournament, &request.payment_reference) .await?; } "arenax_token" => { - // Process ArenaX token payment self.process_arenax_token_payment(user_id, tournament) .await?; } @@ -497,7 +436,6 @@ impl TournamentService { return Err(ApiError::bad_request("Invalid payment method")); } } - Ok(()) } @@ -512,10 +450,8 @@ impl TournamentService { "Payment reference is required for fiat payments", )); } - let reference = payment_reference.as_ref().unwrap(); - // Verify payment with payment provider let payment_verified = self .verify_payment_with_provider(reference, tournament.entry_fee) .await?; @@ -524,13 +460,11 @@ impl TournamentService { return Err(ApiError::bad_request("Payment verification failed")); } - // Update user wallet balance self.add_fiat_balance(user_id, tournament.entry_fee).await?; - // Create transaction record self.create_transaction( user_id, - TransactionType::EntryFee, + "entry_fee", tournament.entry_fee, tournament.entry_fee_currency.clone(), format!("Entry fee for tournament: {}", tournament.name), @@ -545,33 +479,21 @@ impl TournamentService { reference: &str, amount: i64, ) -> Result { - // In a real implementation, this would: - // 1. Make API call to Paystack/Flutterwave - // 2. Verify the payment reference and amount - // 3. Check payment status - - // For now, simulate payment verification - // In production, you would use the actual payment provider APIs tracing::info!( "Verifying payment: reference={}, amount={}", reference, amount ); - - // Simulate successful verification Ok(true) } async fn add_fiat_balance(&self, user_id: Uuid, amount: i64) -> Result<(), ApiError> { - sqlx::query!( - "UPDATE wallets SET balance_ngn = balance_ngn + $1 WHERE user_id = $2", - amount, - user_id - ) - .execute(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))?; - + sqlx::query("UPDATE wallets SET balance_ngn = balance_ngn + $1 WHERE user_id = $2") + .bind(amount) + .bind(user_id) + .execute(&self.db_pool) + .await + .map_err(ApiError::database_error)?; Ok(()) } @@ -580,21 +502,18 @@ impl TournamentService { user_id: Uuid, tournament: &Tournament, ) -> Result<(), ApiError> { - // Check user's ArenaX token balance let wallet = self.get_user_wallet(user_id).await?; - if wallet.balance_arenax_tokens < tournament.entry_fee { + if wallet.balance_arenax_tokens.unwrap_or(0) < tournament.entry_fee { return Err(ApiError::bad_request("Insufficient ArenaX token balance")); } - // Deduct tokens from user's wallet self.deduct_arenax_tokens(user_id, tournament.entry_fee) .await?; - // Create transaction record self.create_transaction( user_id, - TransactionType::EntryFee, + "entry_fee", tournament.entry_fee, "ARENAX_TOKEN".to_string(), format!("Entry fee for tournament: {}", tournament.name), @@ -609,10 +528,9 @@ impl TournamentService { tournament_id: &Uuid, currency: &str, ) -> Result<(), ApiError> { - // Create Stellar account for prize pool let stellar_account = self.create_stellar_prize_pool_account().await?; - sqlx::query!( + sqlx::query( r#" INSERT INTO prize_pools ( id, tournament_id, total_amount, currency, stellar_account, @@ -621,36 +539,36 @@ impl TournamentService { $1, $2, $3, $4, $5, $6, $7, $8 ) "#, - Uuid::new_v4(), - tournament_id, - 0i64, - currency, - stellar_account, - r#"[50, 30, 20]"#, // Default distribution: 1st: 50%, 2nd: 30%, 3rd: 20% - Utc::now(), - Utc::now() ) + .bind(Uuid::new_v4()) + .bind(tournament_id) + .bind(0i64) + .bind(currency) + .bind(&stellar_account) + .bind(r#"[50, 30, 20]"#) + .bind(Utc::now()) + .bind(Utc::now()) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; Ok(()) } async fn update_prize_pool(&self, tournament_id: Uuid, entry_fee: i64) -> Result<(), ApiError> { - sqlx::query!( + sqlx::query( r#" UPDATE prize_pools SET total_amount = total_amount + $1, updated_at = $2 WHERE tournament_id = $3 "#, - entry_fee, - Utc::now(), - tournament_id ) + .bind(entry_fee) + .bind(Utc::now()) + .bind(tournament_id) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; Ok(()) } @@ -664,30 +582,25 @@ impl TournamentService { async fn complete_tournament(&self, tournament_id: Uuid) -> Result<(), ApiError> { let payout = crate::orchestrator::PayoutSettler::new(self.db_pool.clone()); payout.finalize_tournament(tournament_id).await?; - // Cleanup handled by background polling worker Ok(()) } async fn generate_tournament_bracket(&self, tournament_id: Uuid) -> Result<(), ApiError> { - // Get all participants - let participants = sqlx::query_as!( - TournamentParticipant, + let participants = sqlx::query_as::<_, TournamentParticipant>( r#" SELECT * FROM tournament_participants WHERE tournament_id = $1 AND status = $2 ORDER BY registered_at "#, - tournament_id, - ParticipantStatus::Active as _ ) + .bind(tournament_id) + .bind(ParticipantStatus::Active) .fetch_all(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - // Get tournament details let tournament = self.get_tournament_by_id(tournament_id).await?; - // Generate bracket based on type match tournament.bracket_type { BracketType::SingleElimination => { self.generate_single_elimination_bracket(tournament_id, participants) @@ -720,13 +633,16 @@ impl TournamentService { return Err(ApiError::bad_request("Not enough participants for bracket")); } - // Calculate number of rounds needed let rounds = (participant_count as f64).log2().ceil() as i32; - // Create rounds for round_num in 1..=rounds { - let round = sqlx::query_as!( - TournamentRound, + let round_type = if round_num == rounds { + RoundType::Final + } else { + RoundType::Elimination + }; + + let round = sqlx::query_as::<_, TournamentRound>( r#" INSERT INTO tournament_rounds ( id, tournament_id, round_number, round_type, status, created_at @@ -734,33 +650,28 @@ impl TournamentService { $1, $2, $3, $4, $5, $6 ) RETURNING * "#, - Uuid::new_v4(), - tournament_id, - round_num, - if round_num == rounds { - RoundType::Final - } else { - RoundType::Elimination - } as _, - RoundStatus::Pending as _, - Utc::now() ) + .bind(Uuid::new_v4()) + .bind(tournament_id) + .bind(round_num) + .bind(round_type.to_string()) + .bind(RoundStatus::Pending.to_string()) + .bind(Utc::now()) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - // Create matches for this round let matches_in_round = if round_num == 1 { participant_count / 2 } else { - (participant_count / (2_i32.pow(round_num as u32))) as usize + participant_count / (2_usize.pow(round_num as u32)) }; for match_num in 1..=matches_in_round { let player1_idx = (match_num - 1) * 2; let player2_idx = player1_idx + 1; - sqlx::query!( + sqlx::query( r#" INSERT INTO tournament_matches ( id, tournament_id, round_id, match_number, player1_id, player2_id, @@ -769,42 +680,36 @@ impl TournamentService { $1, $2, $3, $4, $5, $6, $7, $8, $9 ) "#, - Uuid::new_v4(), - tournament_id, - round.id, - match_num as i32, - participants[player1_idx].user_id, - if player2_idx < participants.len() { - Some(participants[player2_idx].user_id) - } else { - None - }, - MatchStatus::Pending as _, - Utc::now(), - Utc::now() ) + .bind(Uuid::new_v4()) + .bind(tournament_id) + .bind(round.id) + .bind(match_num as i32) + .bind(participants[player1_idx].user_id) + .bind(if player2_idx < participants.len() { + Some(participants[player2_idx].user_id) + } else { + None + }) + .bind(MatchStatus::Pending.to_string()) + .bind(Utc::now()) + .bind(Utc::now()) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; } } Ok(()) } - // Additional helper methods would be implemented here... - // For brevity, I'll include the essential ones and mark others as TODO - async fn get_tournament_by_id(&self, tournament_id: Uuid) -> Result { - sqlx::query_as!( - Tournament, - "SELECT * FROM tournaments WHERE id = $1", - tournament_id - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))? - .ok_or(ApiError::not_found("Tournament not found".to_string())) + sqlx::query_as::<_, Tournament>("SELECT * FROM tournaments WHERE id = $1") + .bind(tournament_id) + .fetch_optional(&self.db_pool) + .await + .map_err(ApiError::database_error)? + .ok_or(ApiError::not_found("Tournament not found".to_string())) } async fn is_user_participant( @@ -812,17 +717,16 @@ impl TournamentService { user_id: Uuid, tournament_id: Uuid, ) -> Result { - let count = sqlx::query!( + let row = sqlx::query( "SELECT COUNT(*) as count FROM tournament_participants WHERE user_id = $1 AND tournament_id = $2", - user_id, - tournament_id ) + .bind(user_id) + .bind(tournament_id) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? - .count - .unwrap_or(0); + .map_err(ApiError::database_error)?; + let count: i64 = row.get("count"); Ok(count > 0) } @@ -831,17 +735,18 @@ impl TournamentService { user_id: Uuid, tournament_id: Uuid, ) -> Result { - let participant = sqlx::query!( + let row = sqlx::query( "SELECT status FROM tournament_participants WHERE user_id = $1 AND tournament_id = $2", - user_id, - tournament_id ) + .bind(user_id) + .bind(tournament_id) .fetch_optional(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? + .map_err(ApiError::database_error)? .ok_or(ApiError::not_found("Participant not found"))?; - Ok(participant.status.into()) + let status: ParticipantStatus = row.get("status"); + Ok(status) } async fn can_user_join_tournament( @@ -852,91 +757,82 @@ impl TournamentService { if user_id.is_none() { return Ok(false); } - let tournament = self.get_tournament_by_id(tournament_id).await?; let user_id = user_id.unwrap(); - // Check if already participant if self.is_user_participant(user_id, tournament_id).await? { return Ok(false); } - - // Check tournament status if tournament.status != TournamentStatus::RegistrationOpen { return Ok(false); } - - // Check registration deadline if Utc::now() > tournament.registration_deadline { return Ok(false); } - - // Check participant limit let current_count = self.get_participant_count(tournament_id).await?; if current_count >= tournament.max_participants { return Ok(false); } - Ok(true) } async fn get_participant_count(&self, tournament_id: Uuid) -> Result { - let count = sqlx::query!( + let row = sqlx::query( "SELECT COUNT(*) as count FROM tournament_participants WHERE tournament_id = $1", - tournament_id ) + .bind(tournament_id) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? - .count - .unwrap_or(0); + .map_err(ApiError::database_error)?; + let count: i64 = row.get("count"); Ok(count as i32) } async fn get_user_elo(&self, user_id: Uuid, game: &str) -> Result { - let elo_record = sqlx::query!( - "SELECT current_rating FROM user_elo WHERE user_id = $1 AND game = $2", - user_id, - game - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))?; + let row = + sqlx::query("SELECT current_rating FROM user_elo WHERE user_id = $1 AND game = $2") + .bind(user_id) + .bind(game) + .fetch_optional(&self.db_pool) + .await + .map_err(ApiError::database_error)?; - Ok(elo_record.map(|r| r.current_rating).unwrap_or(1200)) // Default Elo rating + Ok(row + .map(|r| r.get::("current_rating")) + .unwrap_or(1200)) } async fn get_user_wallet(&self, user_id: Uuid) -> Result { - sqlx::query_as!(Wallet, "SELECT * FROM wallets WHERE user_id = $1", user_id) + sqlx::query_as::<_, Wallet>("SELECT * FROM wallets WHERE user_id = $1") + .bind(user_id) .fetch_optional(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? + .map_err(ApiError::database_error)? .ok_or(ApiError::not_found("Wallet not found")) } async fn deduct_arenax_tokens(&self, user_id: Uuid, amount: i64) -> Result<(), ApiError> { - sqlx::query!( + sqlx::query( "UPDATE wallets SET balance_arenax_tokens = balance_arenax_tokens - $1 WHERE user_id = $2", - amount, - user_id ) + .bind(amount) + .bind(user_id) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; - + .map_err(ApiError::database_error)?; Ok(()) } async fn create_transaction( &self, user_id: Uuid, - transaction_type: TransactionType, + transaction_type: &str, amount: i64, currency: String, description: String, ) -> Result<(), ApiError> { - sqlx::query!( + sqlx::query( r#" INSERT INTO transactions ( id, user_id, transaction_type, amount, currency, status, reference, description, created_at, updated_at @@ -944,33 +840,24 @@ impl TournamentService { $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 ) "#, - Uuid::new_v4(), - user_id, - transaction_type as _, - amount, - currency, - TransactionStatus::Completed as _, - Uuid::new_v4().to_string(), - description, - Utc::now(), - Utc::now() ) + .bind(Uuid::new_v4()) + .bind(user_id) + .bind(transaction_type) + .bind(amount) + .bind(¤cy) + .bind("completed") + .bind(Uuid::new_v4().to_string()) + .bind(&description) + .bind(Utc::now()) + .bind(Utc::now()) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; - + .map_err(ApiError::database_error)?; Ok(()) } async fn create_stellar_prize_pool_account(&self) -> Result { - // Generate a new Stellar account for the prize pool - // In a real implementation, this would: - // 1. Generate a new keypair - // 2. Create the account on Stellar network - // 3. Fund it with XLM - // 4. Return the public key - - // For now, generate a realistic-looking Stellar public key let account_id = format!( "G{}", uuid::Uuid::new_v4() @@ -988,7 +875,6 @@ impl TournamentService { let tournament = self.get_tournament_by_id(tournament_id).await?; let participant_count = self.get_participant_count(tournament_id).await?; - // Auto-close registration if tournament is full if participant_count >= tournament.max_participants && tournament.status == TournamentStatus::RegistrationOpen { @@ -1000,33 +886,27 @@ impl TournamentService { } async fn calculate_final_rankings(&self, tournament_id: Uuid) -> Result<(), ApiError> { - // Get all participants and their match results - let participants = sqlx::query_as!( - TournamentParticipant, + let participants = sqlx::query_as::<_, TournamentParticipant>( "SELECT * FROM tournament_participants WHERE tournament_id = $1 AND status = $2", - tournament_id, - ParticipantStatus::Active as _ ) + .bind(tournament_id) + .bind(ParticipantStatus::Active) .fetch_all(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - // Calculate rankings based on tournament type let tournament = self.get_tournament_by_id(tournament_id).await?; match tournament.bracket_type { BracketType::SingleElimination | BracketType::DoubleElimination => { - // For elimination tournaments, rank by elimination order self.calculate_elimination_rankings(tournament_id, participants) .await?; } BracketType::RoundRobin => { - // For round robin, rank by win/loss record self.calculate_round_robin_rankings(tournament_id, participants) .await?; } BracketType::Swiss => { - // For Swiss, rank by points and tiebreakers self.calculate_swiss_rankings(tournament_id, participants) .await?; } @@ -1036,55 +916,48 @@ impl TournamentService { } async fn distribute_prizes(&self, tournament_id: Uuid) -> Result<(), ApiError> { - // Get prize pool information - let prize_pool = sqlx::query!( - "SELECT * FROM prize_pools WHERE tournament_id = $1", - tournament_id - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))? - .ok_or(ApiError::not_found("Prize pool not found"))?; + let prize_pool_row = sqlx::query("SELECT * FROM prize_pools WHERE tournament_id = $1") + .bind(tournament_id) + .fetch_optional(&self.db_pool) + .await + .map_err(ApiError::database_error)? + .ok_or(ApiError::not_found("Prize pool not found"))?; - // Get final rankings - let participants = sqlx::query_as!( - TournamentParticipant, + let total_amount: i64 = prize_pool_row.get("total_amount"); + let currency: String = prize_pool_row.get("currency"); + let dist_pct: String = prize_pool_row.get("distribution_percentages"); + + let participants = sqlx::query_as::<_, TournamentParticipant>( "SELECT * FROM tournament_participants WHERE tournament_id = $1 AND final_rank IS NOT NULL ORDER BY final_rank", - tournament_id ) + .bind(tournament_id) .fetch_all(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - // Parse distribution percentages - let percentages: Vec = serde_json::from_str(&prize_pool.distribution_percentages) - .map_err(|e| { - ApiError::internal_error(format!("Invalid distribution percentages: {}", e)) - })?; + let percentages: Vec = serde_json::from_str(&dist_pct).map_err(|e| { + ApiError::internal_error(format!("Invalid distribution percentages: {}", e)) + })?; - // Distribute prizes for (index, participant) in participants.iter().enumerate() { if index < percentages.len() && participant.final_rank.unwrap_or(0) <= 3 { let percentage = percentages[index]; - let prize_amount = (prize_pool.total_amount as f64 * percentage / 100.0) as i64; + let prize_amount = (total_amount as f64 * percentage / 100.0) as i64; - // Update participant with prize amount - sqlx::query!( + sqlx::query( "UPDATE tournament_participants SET prize_amount = $1, prize_currency = $2 WHERE id = $3", - prize_amount, - prize_pool.currency, - participant.id ) + .bind(prize_amount) + .bind(¤cy) + .bind(participant.id) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - // TODO: In a real implementation, initiate Stellar transaction to send prize - // For now, we'll just record the prize amount tracing::info!( "Prize distributed: {} {} to user {}", prize_amount, - prize_pool.currency, + currency, participant.user_id ); } @@ -1093,7 +966,6 @@ impl TournamentService { Ok(()) } - // Additional bracket generation methods async fn generate_double_elimination_bracket( &self, tournament_id: Uuid, @@ -1104,13 +976,10 @@ impl TournamentService { return Err(ApiError::bad_request("Not enough participants for bracket")); } - // Calculate number of rounds needed let rounds = (participant_count as f64).log2().ceil() as i32; - // Winners bracket for round_num in 1..=rounds { - let round = sqlx::query_as!( - TournamentRound, + let round = sqlx::query_as::<_, TournamentRound>( r#" INSERT INTO tournament_rounds ( id, tournament_id, round_number, round_type, status, created_at @@ -1118,23 +987,23 @@ impl TournamentService { $1, $2, $3, $4, $5, $6 ) RETURNING * "#, - Uuid::new_v4(), - tournament_id, - round_num, - RoundType::Elimination as _, - RoundStatus::Pending as _, - Utc::now() ) + .bind(Uuid::new_v4()) + .bind(tournament_id) + .bind(round_num) + .bind(RoundType::Elimination.to_string()) + .bind(RoundStatus::Pending.to_string()) + .bind(Utc::now()) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; let matches_in_round = participant_count / 2_i32.pow(round_num as u32) as usize; for match_num in 1..=matches_in_round { let player1_idx = (match_num - 1) * 2; let player2_idx = player1_idx + 1; - sqlx::query!( + sqlx::query( r#" INSERT INTO tournament_matches ( id, tournament_id, round_id, match_number, player1_id, player2_id, @@ -1143,27 +1012,26 @@ impl TournamentService { $1, $2, $3, $4, $5, $6, $7, $8, $9 ) "#, - Uuid::new_v4(), - tournament_id, - round.id, - match_num as i32, - participants[player1_idx].user_id, - if player2_idx < participants.len() { - Some(participants[player2_idx].user_id) - } else { - None - }, - MatchStatus::Pending as _, - Utc::now(), - Utc::now() ) + .bind(Uuid::new_v4()) + .bind(tournament_id) + .bind(round.id) + .bind(match_num as i32) + .bind(participants[player1_idx].user_id) + .bind(if player2_idx < participants.len() { + Some(participants[player2_idx].user_id) + } else { + None + }) + .bind(MatchStatus::Pending.to_string()) + .bind(Utc::now()) + .bind(Utc::now()) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; } } - // Losers bracket would be generated after winners bracket matches tracing::info!( "Double elimination bracket generated for tournament: {}", tournament_id @@ -1181,9 +1049,7 @@ impl TournamentService { return Err(ApiError::bad_request("Not enough participants for bracket")); } - // Create a round for all matches - let round = sqlx::query_as!( - TournamentRound, + let round = sqlx::query_as::<_, TournamentRound>( r#" INSERT INTO tournament_rounds ( id, tournament_id, round_number, round_type, status, created_at @@ -1191,22 +1057,21 @@ impl TournamentService { $1, $2, $3, $4, $5, $6 ) RETURNING * "#, - Uuid::new_v4(), - tournament_id, - 1, - RoundType::Elimination as _, - RoundStatus::Pending as _, - Utc::now() ) + .bind(Uuid::new_v4()) + .bind(tournament_id) + .bind(1) + .bind(RoundType::Elimination.to_string()) + .bind(RoundStatus::Pending.to_string()) + .bind(Utc::now()) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - // Generate round robin pairings let mut match_number = 1; for i in 0..participant_count { for j in (i + 1)..participant_count { - sqlx::query!( + sqlx::query( r#" INSERT INTO tournament_matches ( id, tournament_id, round_id, match_number, player1_id, player2_id, @@ -1215,19 +1080,19 @@ impl TournamentService { $1, $2, $3, $4, $5, $6, $7, $8, $9 ) "#, - Uuid::new_v4(), - tournament_id, - round.id, - match_number, - participants[i].user_id, - participants[j].user_id, - MatchStatus::Pending as _, - Utc::now(), - Utc::now() ) + .bind(Uuid::new_v4()) + .bind(tournament_id) + .bind(round.id) + .bind(match_number) + .bind(participants[i].user_id) + .bind(Some(participants[j].user_id)) + .bind(MatchStatus::Pending.to_string()) + .bind(Utc::now()) + .bind(Utc::now()) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; match_number += 1; } @@ -1251,13 +1116,10 @@ impl TournamentService { return Err(ApiError::bad_request("Not enough participants for bracket")); } - // For Swiss tournaments, we'll generate Round 1 with simple pairings - // Subsequent rounds would be generated based on standings - let rounds = ((participant_count as f64).log2() * 1.5).ceil() as i32; // Typically 1.5x log2(n) rounds + let rounds = ((participant_count as f64).log2() * 1.5).ceil() as i32; for round_num in 1..=rounds { - let round = sqlx::query_as!( - TournamentRound, + let round = sqlx::query_as::<_, TournamentRound>( r#" INSERT INTO tournament_rounds ( id, tournament_id, round_number, round_type, status, created_at @@ -1265,25 +1127,24 @@ impl TournamentService { $1, $2, $3, $4, $5, $6 ) RETURNING * "#, - Uuid::new_v4(), - tournament_id, - round_num, - RoundType::Elimination as _, - RoundStatus::Pending as _, - Utc::now() ) + .bind(Uuid::new_v4()) + .bind(tournament_id) + .bind(round_num) + .bind(RoundType::Elimination.to_string()) + .bind(RoundStatus::Pending.to_string()) + .bind(Utc::now()) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - // For round 1, use simple seed-based pairings if round_num == 1 { - let matches_in_round = (participant_count / 2) as usize; + let matches_in_round = participant_count / 2; for match_num in 1..=matches_in_round { let player1_idx = (match_num - 1) * 2; let player2_idx = player1_idx + 1; - sqlx::query!( + sqlx::query( r#" INSERT INTO tournament_matches ( id, tournament_id, round_id, match_number, player1_id, player2_id, @@ -1292,26 +1153,25 @@ impl TournamentService { $1, $2, $3, $4, $5, $6, $7, $8, $9 ) "#, - Uuid::new_v4(), - tournament_id, - round.id, - match_num as i32, - participants[player1_idx].user_id, - if player2_idx < participants.len() { - Some(participants[player2_idx].user_id) - } else { - None - }, - MatchStatus::Pending as _, - Utc::now(), - Utc::now() ) + .bind(Uuid::new_v4()) + .bind(tournament_id) + .bind(round.id) + .bind(match_num as i32) + .bind(participants[player1_idx].user_id) + .bind(if player2_idx < participants.len() { + Some(participants[player2_idx].user_id) + } else { + None + }) + .bind(MatchStatus::Pending.to_string()) + .bind(Utc::now()) + .bind(Utc::now()) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; } } - // Subsequent Swiss rounds would be pairing based on standings and strength of schedule } tracing::info!( @@ -1325,29 +1185,24 @@ impl TournamentService { async fn calculate_elimination_rankings( &self, tournament_id: Uuid, - participants: Vec, + _participants: Vec, ) -> Result<(), ApiError> { - // For elimination tournaments, rank by elimination order - // Get matches in reverse order to determine elimination sequence - let matches = sqlx::query_as!( - TournamentMatch, + let matches = sqlx::query_as::<_, TournamentMatch>( r#" SELECT tm.* FROM tournament_matches tm JOIN tournament_rounds tr ON tm.round_id = tr.id WHERE tm.tournament_id = $1 AND tm.status = $2 ORDER BY tr.round_number DESC, tm.match_number "#, - tournament_id, - MatchStatus::Completed as _ ) + .bind(tournament_id) + .bind(MatchStatus::Completed.to_string()) .fetch_all(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - let mut rankings = Vec::new(); - let mut current_rank = 1; + let mut current_rank = 1i32; - // Process matches to determine rankings for tournament_match in matches { let loser_id = if tournament_match.winner_id != Some(tournament_match.player1_id) { Some(tournament_match.player1_id) @@ -1357,24 +1212,20 @@ impl TournamentService { .filter(|&p2| tournament_match.winner_id != Some(p2)) }; if let Some(lid) = loser_id { - rankings.push((lid, current_rank)); + sqlx::query( + "UPDATE tournament_participants SET final_rank = $1 WHERE tournament_id = $2 AND user_id = $3", + ) + .bind(current_rank) + .bind(tournament_id) + .bind(lid) + .execute(&self.db_pool) + .await + .map_err(ApiError::database_error)?; + current_rank += 1; } } - // Update participant rankings - for (user_id, rank) in rankings { - sqlx::query!( - "UPDATE tournament_participants SET final_rank = $1 WHERE tournament_id = $2 AND user_id = $3", - rank, - tournament_id, - user_id - ) - .execute(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))?; - } - Ok(()) } @@ -1383,46 +1234,43 @@ impl TournamentService { tournament_id: Uuid, participants: Vec, ) -> Result<(), ApiError> { - // For round robin, calculate win/loss records let mut player_stats = std::collections::HashMap::new(); for participant in &participants { - let wins = sqlx::query!( + let wins_row = sqlx::query( r#" SELECT COUNT(*) as count FROM tournament_matches WHERE tournament_id = $1 AND winner_id = $2 AND status = $3 "#, - tournament_id, - participant.user_id, - MatchStatus::Completed as _ ) + .bind(tournament_id) + .bind(participant.user_id) + .bind(MatchStatus::Completed.to_string()) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? - .count - .unwrap_or(0); + .map_err(ApiError::database_error)?; - let losses = sqlx::query!( + let wins: i64 = wins_row.get("count"); + + let losses_row = sqlx::query( r#" SELECT COUNT(*) as count FROM tournament_matches WHERE tournament_id = $1 AND (player1_id = $2 OR player2_id = $2) AND winner_id != $2 AND status = $3 "#, - tournament_id, - participant.user_id, - participant.user_id, - MatchStatus::Completed as _ ) + .bind(tournament_id) + .bind(participant.user_id) + .bind(MatchStatus::Completed.to_string()) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? - .count - .unwrap_or(0); + .map_err(ApiError::database_error)?; + + let losses: i64 = losses_row.get("count"); player_stats.insert(participant.user_id, (wins, losses)); } - // Sort by wins (descending), then by losses (ascending) let mut sorted_players: Vec<_> = player_stats.into_iter().collect(); sorted_players.sort_by(|a, b| { let (wins_a, losses_a) = a.1; @@ -1430,17 +1278,16 @@ impl TournamentService { wins_b.cmp(&wins_a).then(losses_a.cmp(&losses_b)) }); - // Update rankings for (rank, (user_id, _)) in sorted_players.iter().enumerate() { - sqlx::query!( + sqlx::query( "UPDATE tournament_participants SET final_rank = $1 WHERE tournament_id = $2 AND user_id = $3", - rank as i32 + 1, - tournament_id, - user_id ) + .bind(rank as i32 + 1) + .bind(tournament_id) + .bind(user_id) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; } Ok(()) @@ -1451,80 +1298,70 @@ impl TournamentService { tournament_id: Uuid, participants: Vec, ) -> Result<(), ApiError> { - // For Swiss tournaments, rank by points and tiebreakers let mut player_stats = std::collections::HashMap::new(); for participant in &participants { - let wins = sqlx::query!( + let wins_row = sqlx::query( r#" SELECT COUNT(*) as count FROM tournament_matches WHERE tournament_id = $1 AND winner_id = $2 AND status = $3 "#, - tournament_id, - participant.user_id, - MatchStatus::Completed as _ ) + .bind(tournament_id) + .bind(participant.user_id) + .bind(MatchStatus::Completed.to_string()) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? - .count - .unwrap_or(0); + .map_err(ApiError::database_error)?; + + let wins: i64 = wins_row.get("count"); - let draws = sqlx::query!( + let draws_row = sqlx::query( r#" SELECT COUNT(*) as count FROM tournament_matches WHERE tournament_id = $1 AND (player1_id = $2 OR player2_id = $2) AND winner_id IS NULL AND status = $3 "#, - tournament_id, - participant.user_id, - MatchStatus::Completed as _ ) + .bind(tournament_id) + .bind(participant.user_id) + .bind(MatchStatus::Completed.to_string()) .fetch_one(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? - .count - .unwrap_or(0); + .map_err(ApiError::database_error)?; + + let draws: i64 = draws_row.get("count"); - // Swiss points: 3 for win, 1 for draw, 0 for loss let points = (wins * 3 + draws) as i32; player_stats.insert(participant.user_id, points); } - // Sort by points (descending) let mut sorted_players: Vec<_> = player_stats.into_iter().collect(); sorted_players.sort_by(|a, b| b.1.cmp(&a.1)); - // Update rankings for (rank, (user_id, _)) in sorted_players.iter().enumerate() { - sqlx::query!( + sqlx::query( "UPDATE tournament_participants SET final_rank = $1 WHERE tournament_id = $2 AND user_id = $3", - rank as i32 + 1, - tournament_id, - user_id ) + .bind(rank as i32 + 1) + .bind(tournament_id) + .bind(user_id) .execute(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; } Ok(()) } - // Real-time event publishing methods - // TODO: Implement proper realtime module with event types async fn publish_tournament_event( &self, _event_data: serde_json::Value, ) -> Result<(), ApiError> { - // Placeholder for real-time tournament event publishing - // Will be implemented when realtime module is added Ok(()) } async fn publish_global_event(&self, _event_data: serde_json::Value) -> Result<(), ApiError> { - // Placeholder for real-time global event publishing - // Will be implemented when realtime module is added Ok(()) } @@ -1533,14 +1370,13 @@ impl TournamentService { &self, tournament_id: Uuid, ) -> Result, ApiError> { - let participants = sqlx::query_as!( - TournamentParticipant, + let participants = sqlx::query_as::<_, TournamentParticipant>( "SELECT * FROM tournament_participants WHERE tournament_id = $1 ORDER BY registered_at", - tournament_id ) + .bind(tournament_id) .fetch_all(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; Ok(participants) } @@ -1550,27 +1386,23 @@ impl TournamentService { &self, tournament_id: Uuid, ) -> Result { - // Get tournament rounds - let rounds = sqlx::query_as!( - TournamentRound, + let rounds = sqlx::query_as::<_, TournamentRound>( "SELECT * FROM tournament_rounds WHERE tournament_id = $1 ORDER BY round_number", - tournament_id ) + .bind(tournament_id) .fetch_all(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; - // Get matches for each round let mut bracket_rounds = Vec::new(); for round in rounds { - let matches = sqlx::query_as!( - TournamentMatch, + let matches = sqlx::query_as::<_, TournamentMatch>( "SELECT * FROM tournament_matches WHERE round_id = $1 ORDER BY match_number", - round.id ) + .bind(round.id) .fetch_all(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))?; + .map_err(ApiError::database_error)?; bracket_rounds.push(BracketRound { round_id: round.id, @@ -1600,13 +1432,14 @@ impl TournamentService { } async fn get_user_username(&self, user_id: Uuid) -> Result { - let user = sqlx::query!("SELECT username FROM users WHERE id = $1", user_id) + let row = sqlx::query("SELECT username FROM users WHERE id = $1") + .bind(user_id) .fetch_optional(&self.db_pool) .await - .map_err(|e| ApiError::database_error(e))? + .map_err(ApiError::database_error)? .ok_or(ApiError::not_found("User not found"))?; - Ok(user.username) + Ok(row.get("username")) } } diff --git a/backend/src/service/wallet_service.rs b/backend/src/service/wallet_service.rs index 00efdda..452f632 100644 --- a/backend/src/service/wallet_service.rs +++ b/backend/src/service/wallet_service.rs @@ -1,12 +1,9 @@ -use crate::models::{ - Transaction, TransactionResponse, TransactionStatus, TransactionType, Wallet, WalletResponse, -}; +use crate::db::DbPool; +use crate::models::{Transaction, TransactionStatus, TransactionType, Wallet}; use anyhow::Result; use chrono::Utc; // EventBus is used via crate::realtime::event_bus::EventBus use rust_decimal::Decimal; -use sqlx::PgPool; -use std::sync::Arc; use thiserror::Error; use uuid::Uuid; @@ -28,8 +25,6 @@ pub enum WalletError { RedisError(String), } -pub type DbPool = Arc; - #[derive(Clone)] pub struct WalletService { db_pool: DbPool, @@ -38,10 +33,7 @@ pub struct WalletService { impl WalletService { pub fn new(db_pool: DbPool, event_bus: Option) -> Self { - Self { - db_pool, - event_bus, - } + Self { db_pool, event_bus } } // ======================================================================== @@ -50,15 +42,14 @@ impl WalletService { /// Get wallet for a user pub async fn get_wallet(&self, user_id: Uuid) -> Result { - let wallet = sqlx::query_as!( - Wallet, + let wallet = sqlx::query_as::<_, Wallet>( r#" SELECT * FROM wallets WHERE user_id = $1 "#, - user_id ) - .fetch_optional(&*self.db_pool) + .bind(user_id) + .fetch_optional(&self.db_pool) .await?; wallet.ok_or(WalletError::WalletNotFound) @@ -75,8 +66,7 @@ impl WalletService { /// Create a new wallet for a user pub async fn create_wallet(&self, user_id: Uuid) -> Result { - let wallet = sqlx::query_as!( - Wallet, + let wallet = sqlx::query_as::<_, Wallet>( r#" INSERT INTO wallets ( id, user_id, balance, escrow_balance, currency, @@ -86,19 +76,19 @@ impl WalletService { VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING * "#, - Uuid::new_v4(), - user_id, - Decimal::ZERO, - Decimal::ZERO, - "NGN", - 0i64, // balance_ngn - 0i64, // balance_arenax_tokens - 0i64, // balance_xlm - true, - Utc::now(), - Utc::now() ) - .fetch_one(&*self.db_pool) + .bind(Uuid::new_v4()) + .bind(user_id) + .bind(Decimal::ZERO) + .bind(Decimal::ZERO) + .bind("NGN") + .bind(0i64) + .bind(0i64) + .bind(0i64) + .bind(true) + .bind(Utc::now()) + .bind(Utc::now()) + .fetch_one(&self.db_pool) .await?; Ok(wallet) @@ -112,17 +102,17 @@ impl WalletService { )); } - sqlx::query!( + sqlx::query( r#" UPDATE wallets SET balance_ngn = balance_ngn + $1, updated_at = $2 WHERE user_id = $3 "#, - amount, - Utc::now(), - user_id ) - .execute(&*self.db_pool) + .bind(amount) + .bind(Utc::now()) + .bind(user_id) + .execute(&self.db_pool) .await?; // Publish balance update event @@ -148,17 +138,17 @@ impl WalletService { }); } - sqlx::query!( + sqlx::query( r#" UPDATE wallets SET balance_ngn = balance_ngn - $1, updated_at = $2 WHERE user_id = $3 "#, - amount, - Utc::now(), - user_id ) - .execute(&*self.db_pool) + .bind(amount) + .bind(Utc::now()) + .bind(user_id) + .execute(&self.db_pool) .await?; // Publish balance update event @@ -175,17 +165,17 @@ impl WalletService { )); } - sqlx::query!( + sqlx::query( r#" UPDATE wallets SET balance_arenax_tokens = balance_arenax_tokens + $1, updated_at = $2 WHERE user_id = $3 "#, - amount, - Utc::now(), - user_id ) - .execute(&*self.db_pool) + .bind(amount) + .bind(Utc::now()) + .bind(user_id) + .execute(&self.db_pool) .await?; // Publish balance update event @@ -215,17 +205,17 @@ impl WalletService { }); } - sqlx::query!( + sqlx::query( r#" UPDATE wallets SET balance_arenax_tokens = balance_arenax_tokens - $1, updated_at = $2 WHERE user_id = $3 "#, - amount, - Utc::now(), - user_id ) - .execute(&*self.db_pool) + .bind(amount) + .bind(Utc::now()) + .bind(user_id) + .execute(&self.db_pool) .await?; // Publish balance update event @@ -251,7 +241,7 @@ impl WalletService { }); } - sqlx::query!( + sqlx::query( r#" UPDATE wallets SET balance_ngn = balance_ngn - $1, @@ -259,12 +249,12 @@ impl WalletService { updated_at = $3 WHERE user_id = $4 "#, - amount, - Decimal::from(amount), - Utc::now(), - user_id ) - .execute(&*self.db_pool) + .bind(amount) + .bind(Decimal::from(amount)) + .bind(Utc::now()) + .bind(user_id) + .execute(&self.db_pool) .await?; Ok(()) @@ -278,7 +268,7 @@ impl WalletService { )); } - sqlx::query!( + sqlx::query( r#" UPDATE wallets SET balance_ngn = balance_ngn + $1, @@ -286,12 +276,12 @@ impl WalletService { updated_at = $3 WHERE user_id = $4 "#, - amount, - Decimal::from(amount), - Utc::now(), - user_id ) - .execute(&*self.db_pool) + .bind(amount) + .bind(Decimal::from(amount)) + .bind(Utc::now()) + .bind(user_id) + .execute(&self.db_pool) .await?; Ok(()) @@ -313,8 +303,7 @@ impl WalletService { ) -> Result { let reference = reference.unwrap_or_else(|| format!("TXN-{}", Uuid::new_v4())); - let transaction = sqlx::query_as!( - Transaction, + let transaction = sqlx::query_as::<_, Transaction>( r#" INSERT INTO transactions ( id, user_id, transaction_type, amount, currency, @@ -322,24 +311,22 @@ impl WalletService { ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, user_id, - transaction_type as "transaction_type: TransactionType", - amount, currency, - status as "status: TransactionStatus", - reference, description, metadata, + transaction_type, amount, currency, + status, reference, description, metadata, stellar_transaction_id, created_at, updated_at, completed_at "#, - Uuid::new_v4(), - user_id, - transaction_type as TransactionType, - Decimal::from(amount), - currency, - TransactionStatus::Pending as TransactionStatus, - reference, - description, - Utc::now(), - Utc::now() ) - .fetch_one(&*self.db_pool) + .bind(Uuid::new_v4()) + .bind(user_id) + .bind(transaction_type) + .bind(Decimal::from(amount)) + .bind(¤cy) + .bind(TransactionStatus::Pending) + .bind(&reference) + .bind(&description) + .bind(Utc::now()) + .bind(Utc::now()) + .fetch_one(&self.db_pool) .await?; Ok(transaction) @@ -357,18 +344,18 @@ impl WalletService { None }; - sqlx::query!( + sqlx::query( r#" UPDATE transactions SET status = $1, completed_at = $2, updated_at = $3 WHERE id = $4 "#, - status as TransactionStatus, - completed_at, - Utc::now(), - transaction_id ) - .execute(&*self.db_pool) + .bind(status) + .bind(completed_at) + .bind(Utc::now()) + .bind(transaction_id) + .execute(&self.db_pool) .await?; Ok(()) @@ -383,25 +370,22 @@ impl WalletService { ) -> Result, WalletError> { let offset = (page - 1) * per_page; - let transactions = sqlx::query_as!( - Transaction, + let transactions = sqlx::query_as::<_, Transaction>( r#" SELECT id, user_id, - transaction_type as "transaction_type: TransactionType", - amount, currency, - status as "status: TransactionStatus", - reference, description, metadata, + transaction_type, amount, currency, + status, reference, description, metadata, stellar_transaction_id, created_at, updated_at, completed_at FROM transactions WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 "#, - user_id, - per_page as i64, - offset as i64 ) - .fetch_all(&*self.db_pool) + .bind(user_id) + .bind(per_page as i64) + .bind(offset as i64) + .fetch_all(&self.db_pool) .await?; Ok(transactions) @@ -412,21 +396,18 @@ impl WalletService { &self, reference: &str, ) -> Result { - let transaction = sqlx::query_as!( - Transaction, + let transaction = sqlx::query_as::<_, Transaction>( r#" SELECT id, user_id, - transaction_type as "transaction_type: TransactionType", - amount, currency, - status as "status: TransactionStatus", - reference, description, metadata, + transaction_type, amount, currency, + status, reference, description, metadata, stellar_transaction_id, created_at, updated_at, completed_at FROM transactions WHERE reference = $1 "#, - reference ) - .fetch_optional(&*self.db_pool) + .bind(reference) + .fetch_optional(&self.db_pool) .await?; transaction.ok_or(WalletError::TransactionNotFound) @@ -439,8 +420,8 @@ impl WalletService { /// Verify payment with Paystack pub async fn verify_paystack_payment( &self, - reference: &str, - expected_amount: i64, + _reference: &str, + _expected_amount: i64, ) -> Result { // TODO: Implement actual Paystack API call // For now, this is a placeholder @@ -472,8 +453,8 @@ impl WalletService { /// Verify payment with Flutterwave pub async fn verify_flutterwave_payment( &self, - transaction_id: &str, - expected_amount: i64, + _transaction_id: &str, + _expected_amount: i64, ) -> Result { // TODO: Implement actual Flutterwave API call tracing::warn!("Flutterwave verification not implemented, returning true for testing"); @@ -496,7 +477,7 @@ impl WalletService { TransactionType::EntryFee, amount, currency.to_string(), - format!("Tournament entry fee payment"), + "Tournament entry fee payment".to_string(), reference.clone(), ) .await?; diff --git a/backend/tests/realtime_auth_integration_test.rs b/backend/tests/realtime_auth_integration_test.rs index 157a01d..45e9a22 100644 --- a/backend/tests/realtime_auth_integration_test.rs +++ b/backend/tests/realtime_auth_integration_test.rs @@ -54,7 +54,7 @@ async fn test_ws_connection_with_valid_token() { ).await; // Test with valid token - let uri = format="/ws?token={}", token; + let uri = format!("/ws?token={}", token); let req = test::TestRequest::with_uri(&uri).to_request(); // ws::start would be called here, but in a test environment we'd need more setup for actual WS // For now, let's just assert it passes the upgrade check (which returns 101 Switching Protocols) diff --git a/contracts/README.md b/contracts/README.md index c39cc87..3ff8fa0 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -7,7 +7,9 @@ The ArenaX contracts workspace contains **Soroban smart contracts** for the Aren ## Current Implementation ### 📝 Example Contract + A simple demonstration contract showing basic Soroban functionality: + - Contract initialization with admin setup - Basic storage operations - Simple greeting function @@ -22,6 +24,7 @@ A simple demonstration contract showing basic Soroban functionality: **Purpose**: Automated tournament prize pool management and distribution **Planned Functionality**: + - Create and manage tournament prize pools - Escrow entry fees in XLM or ArenaX Tokens - Automatically distribute prizes to winners @@ -29,6 +32,7 @@ A simple demonstration contract showing basic Soroban functionality: - Multi-signature security for large prize pools **Planned Functions**: + ```rust // Create a new tournament prize pool pub fn create_prize_pool(tournament_id: u64, entry_fee: i128, max_participants: u32) @@ -48,6 +52,7 @@ pub fn refund_entry_fees(tournament_id: u64) **Purpose**: Track player reputation and fairness on-chain **Planned Functionality**: + - Issue Reputation Tokens to players - Update reputation based on match outcomes - Apply penalties for disputes and cheating @@ -55,6 +60,7 @@ pub fn refund_entry_fees(tournament_id: u64) - Enable reputation-based tournament access **Planned Functions**: + ```rust // Issue initial reputation to new player pub fn issue_reputation(player: Address, initial_amount: i128) @@ -74,6 +80,7 @@ pub fn get_reputation(player: Address) -> i128 **Purpose**: In-platform reward and payment token **Planned Functionality**: + - Issue ArenaX Tokens for platform rewards - Enable token transfers and payments - Integrate with Stellar DEX for conversions @@ -81,6 +88,7 @@ pub fn get_reputation(player: Address) -> i128 - Support tournament entry fees **Planned Functions**: + ```rust // Mint new ArenaX Tokens pub fn mint(to: Address, amount: i128) @@ -100,6 +108,7 @@ pub fn approve(from: Address, spender: Address, amount: i128) **Purpose**: Tournament lifecycle and state management **Planned Functionality**: + - Create and manage tournament instances - Handle tournament state transitions (upcoming → ongoing → completed) - Manage participant registration and validation @@ -107,6 +116,7 @@ pub fn approve(from: Address, spender: Address, amount: i128) - Integrate with prize distribution and reputation contracts **Planned Functions**: + ```rust // Create a new tournament pub fn create_tournament(admin: Address, config: TournamentConfig) -> u64 @@ -129,6 +139,7 @@ pub fn complete_tournament(tournament_id: u64, winners: Vec
) **Purpose**: Common utilities and data types shared across contracts **Planned Functionality**: + - Common data structures and enums - Utility functions for address validation - Shared error types and constants @@ -136,6 +147,7 @@ pub fn complete_tournament(tournament_id: u64, winners: Vec
) - Cross-contract communication utilities **Planned Functions**: + ```rust // Validate Stellar address pub fn validate_address(address: Address) -> bool @@ -198,6 +210,7 @@ contracts/ ## Setup & Development ### Prerequisites + - Rust toolchain - Stellar CLI - Soroban SDK @@ -296,6 +309,7 @@ export ADMIN_PUBLIC_KEY=GXXX... ## Development Workflow ### Building Contracts + ```bash # Build all contracts cargo build --target wasm32-unknown-unknown --release @@ -305,6 +319,7 @@ cargo build --target wasm32-unknown-unknown --release --profile release ``` ### Testing + ```bash # Run unit tests cargo test @@ -404,6 +419,7 @@ prize_client.call( ### Cross-Contract Communication The contracts are designed to work together: + - **Tournament Manager** coordinates tournament lifecycle - **Prize Distribution** manages prize pools and payouts - **Reputation** tracks player fairness and skill @@ -413,11 +429,13 @@ The contracts are designed to work together: ## Security Considerations ### Access Control + - Admin-only functions for critical operations - Player-specific functions with proper authorization - Role-based access control for contract functions ### Audit Trail + - All contract operations are logged on-chain - Immutable transaction history - Transparent operations @@ -425,11 +443,13 @@ The contracts are designed to work together: ## Gas Optimization ### Efficient Storage + - Optimize data structures for minimal storage costs - Use packed data types where possible - Implement efficient data access patterns ### Batch Operations + - Group multiple operations in single transactions - Minimize contract calls for better performance - Optimize for Stellar network fees @@ -437,6 +457,7 @@ The contracts are designed to work together: ## Contributing ### Development Guidelines + 1. Follow Rust best practices 2. Write comprehensive tests 3. Document all public functions @@ -444,6 +465,7 @@ The contracts are designed to work together: 5. Optimize for gas efficiency ### Adding New Contracts + 1. Create new module in `src/` 2. Add module to `lib.rs` 3. Write comprehensive tests @@ -453,6 +475,7 @@ The contracts are designed to work together: ## Support For Stellar smart contract development: + - Check Soroban documentation - Review Stellar developer resources - Contact the development team diff --git a/convert_all_macros.py b/convert_all_macros.py new file mode 100644 index 0000000..710cbde --- /dev/null +++ b/convert_all_macros.py @@ -0,0 +1,248 @@ +""" +Convert sqlx::query! and query_as! macros to runtime queries across all affected files. +""" +import re +import os + +BASE = r"c:\OPEN SOURCE\330+ backend\ArenaX\backend\src" + +def convert_file(filepath): + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + original = content + + # Convert sqlx::query_as!(Type, ...) to sqlx::query_as::<_, Type>(...) + # Pattern: sqlx::query_as!(\n Type,\n "SQL",\n binds...\n) + content = convert_query_as_macros(content) + + # Convert sqlx::query!("SQL", binds...) to sqlx::query("SQL").bind(...) + content = convert_query_macros(content) + + if content != original: + with open(filepath, 'w', encoding='utf-8') as f: + f.write(content) + print(f" Converted macros in {os.path.basename(filepath)}") + else: + print(f" No macros found in {os.path.basename(filepath)}") + +def convert_query_as_macros(content): + """Convert sqlx::query_as!(Type, "SQL", binds...) to sqlx::query_as::<_, Type>("SQL").bind(...)""" + # Find all query_as! macro invocations + pattern = r'sqlx::query_as!\(\s*(\w+)\s*,\s*((?:"[^"]*"|r#"[^"]*"#))\s*((?:,\s*[^)]+?)?)\s*\)' + + def replacer(m): + type_name = m.group(1) + sql = m.group(2) + binds_str = m.group(3).strip() + + # Parse binds + binds = parse_binds(binds_str) + + result = f'sqlx::query_as::<_, {type_name}>({sql})' + for bind in binds: + result += f'\n .bind({bind})' + + return result + + # More robust approach: find query_as! and match balanced parens + result = [] + i = 0 + while i < len(content): + # Look for sqlx::query_as!( + idx = content.find('sqlx::query_as!(', i) + if idx == -1: + result.append(content[i:]) + break + + result.append(content[i:idx]) + + # Find the matching closing paren + start = idx + len('sqlx::query_as!(') + paren_depth = 1 + j = start + while j < len(content) and paren_depth > 0: + if content[j] == '(': + paren_depth += 1 + elif content[j] == ')': + paren_depth -= 1 + j += 1 + + inner = content[start:j-1] + + # Parse: TypeName, "SQL" or r#"SQL"#, bind1, bind2, ... + type_name, sql, binds = parse_query_as_inner(inner) + + if type_name and sql: + replacement = f'sqlx::query_as::<_, {type_name}>({sql})' + for bind in binds: + replacement += f'\n .bind({bind})' + result.append(replacement) + else: + # Couldn't parse, keep original + result.append(content[idx:j]) + + i = j + + return ''.join(result) + +def convert_query_macros(content): + """Convert sqlx::query!("SQL", binds...) to sqlx::query("SQL").bind(...)""" + result = [] + i = 0 + while i < len(content): + # Look for sqlx::query!( but NOT sqlx::query_as!( + idx = content.find('sqlx::query!(', i) + if idx == -1: + result.append(content[i:]) + break + + # Make sure it's not query_as! + if idx > 0 and content[max(0,idx-3):idx] == 'as!': + result.append(content[i:idx+13]) + i = idx + 13 + continue + + result.append(content[i:idx]) + + # Find the matching closing paren + start = idx + len('sqlx::query!(') + paren_depth = 1 + j = start + while j < len(content) and paren_depth > 0: + if content[j] == '(': + paren_depth += 1 + elif content[j] == ')': + paren_depth -= 1 + j += 1 + + inner = content[start:j-1] + + # Parse: "SQL" or r#"SQL"#, bind1, bind2, ... + sql, binds = parse_query_inner(inner) + + if sql: + replacement = f'sqlx::query({sql})' + for bind in binds: + replacement += f'\n .bind({bind})' + result.append(replacement) + else: + # Couldn't parse, keep original + result.append(content[idx:j]) + + i = j + + return ''.join(result) + +def parse_query_as_inner(inner): + """Parse TypeName, SQL, binds from query_as! inner content""" + inner = inner.strip() + + # Find type name (first identifier before comma) + m = re.match(r'(\w+)\s*,\s*', inner) + if not m: + return None, None, [] + + type_name = m.group(1) + rest = inner[m.end():] + + # Now parse SQL and binds + sql, binds = parse_sql_and_binds(rest) + return type_name, sql, binds + +def parse_query_inner(inner): + """Parse SQL, binds from query! inner content""" + inner = inner.strip() + return parse_sql_and_binds(inner) + +def parse_sql_and_binds(text): + """Parse SQL string and bind parameters""" + text = text.strip() + + # Handle r#"..."# raw strings + if text.startswith('r#"'): + end_idx = text.find('"#') + if end_idx == -1: + return None, [] + sql = text[:end_idx+2] + rest = text[end_idx+2:].strip() + # Handle regular strings + elif text.startswith('"'): + # Find the closing quote, handling escaped quotes + idx = 1 + while idx < len(text): + if text[idx] == '\\': + idx += 2 + continue + if text[idx] == '"': + break + idx += 1 + sql = text[:idx+1] + rest = text[idx+1:].strip() + else: + return None, [] + + # Parse binds (comma-separated after the SQL) + binds = [] + if rest.startswith(','): + rest = rest[1:].strip() + binds = parse_binds(',' + rest) if rest else [] + # Actually, rest already has the comma stripped, let's parse directly + binds = split_bind_args(rest) + + return sql, binds + +def split_bind_args(text): + """Split bind arguments respecting parentheses and angle brackets""" + text = text.strip() + if not text: + return [] + + args = [] + depth = 0 + current = [] + + for ch in text: + if ch in '(<[': + depth += 1 + current.append(ch) + elif ch in ')>]': + depth -= 1 + current.append(ch) + elif ch == ',' and depth == 0: + arg = ''.join(current).strip() + if arg: + args.append(arg) + current = [] + else: + current.append(ch) + + arg = ''.join(current).strip() + if arg: + args.append(arg) + + return args + +def parse_binds(text): + """Parse comma-separated binds, handling nested expressions""" + text = text.strip() + if text.startswith(','): + text = text[1:] + return split_bind_args(text) + +# Files to convert +files = [ + os.path.join(BASE, "http", "matchmaking.rs"), + os.path.join(BASE, "http", "reputation_handler.rs"), + os.path.join(BASE, "service", "matchmaker.rs"), + os.path.join(BASE, "service", "reputation_service.rs"), +] + +for f in files: + if os.path.exists(f): + print(f"Processing {os.path.basename(f)}...") + convert_file(f) + else: + print(f"File not found: {f}") + +print("\nDone! Now apply manual fixes.") diff --git a/docker-compose.yml b/docker-compose.yml index 79680bc..a2b111e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" services: # PostgreSQL Database