use std::sync::Arc; use axum::{ extract::{Path, Query, State}, routing::{get, patch, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::middleware::auth::SuperAdmin; use crate::models::error::{ApiError, ApiResult}; use crate::AppState; pub fn router() -> Router> { Router::new() .route("/stats", get(stats)) .route("/licenses", get(list_licenses)) .route("/licenses", post(create_license)) .route("/licenses/{id}", get(get_license)) .route("/licenses/{id}", patch(update_license)) .route("/subscriptions", get(list_subscriptions)) .route("/users", get(list_users)) .route("/users/{id}", patch(update_user)) .route("/servers", get(list_servers)) .route("/health", get(health)) } // ────────────────────────────────────────────── // DTOs // ────────────────────────────────────────────── #[derive(Serialize)] struct PlatformStats { total_licenses: i64, active_licenses: i64, total_users: i64, module_mrr: f64, servers_online: i64, new_signups_this_week: i64, } #[derive(Deserialize)] struct PaginationParams { page: Option, per_page: Option, search: Option, status: Option, } #[derive(Serialize)] struct PaginatedResponse { data: Vec, total: i64, page: i64, per_page: i64, } #[derive(Serialize, sqlx::FromRow)] struct LicenseListItem { id: Uuid, license_key: String, status: String, owner_email: String, owner_username: String, server_name: Option, modules_enabled: Option>, webstore_active: bool, created_at: chrono::DateTime, expires_at: Option>, } #[derive(Serialize)] struct LicenseDetail { license: LicenseListItem, team_count: i64, wipe_count: i64, server_connection: Option, } #[derive(Serialize, sqlx::FromRow)] struct ServerConnectionInfo { connection_type: String, connection_status: String, server_ip: Option, game_port: Option, plugin_last_seen: Option>, companion_last_seen: Option>, } #[derive(Deserialize)] struct CreateLicenseRequest { owner_email: String, license_key: Option, } #[derive(Deserialize)] struct UpdateLicenseRequest { status: Option, expires_at: Option, } #[derive(Serialize, sqlx::FromRow)] struct UserListItem { id: Uuid, email: String, username: String, is_super_admin: bool, email_verified: bool, license_count: i64, created_at: chrono::DateTime, last_login_at: Option>, } #[derive(Deserialize)] struct UpdateUserRequest { is_super_admin: Option, disabled: Option, } #[derive(Serialize, sqlx::FromRow)] struct SubscriptionListItem { owner_email: String, owner_username: String, license_id: Uuid, module_name: String, } #[derive(Serialize)] struct SubscriptionSummary { subscriptions: Vec, module_counts: Vec, total_subscribers: i64, } #[derive(Serialize, sqlx::FromRow)] struct ModuleCount { module_name: String, subscriber_count: i64, } #[derive(Serialize, sqlx::FromRow)] struct ServerFleetItem { license_id: Uuid, owner_email: String, server_name: Option, connection_type: String, connection_status: String, server_ip: Option, game_port: Option, plugin_last_seen: Option>, companion_last_seen: Option>, } #[derive(Serialize)] struct HealthInfo { db_pool_size: u32, nats_connected: bool, table_counts: Vec, } #[derive(Serialize)] struct TableCount { table_name: String, row_count: i64, } // ────────────────────────────────────────────── // Handlers // ────────────────────────────────────────────── /// GET /api/admin/stats — KPI summary async fn stats( _admin: SuperAdmin, State(state): State>, ) -> ApiResult> { let total_licenses: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM licenses") .fetch_one(&state.db).await.map_err(|e| ApiError::Internal(e.to_string()))?; let active_licenses: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM licenses WHERE status = 'active'" ).fetch_one(&state.db).await.map_err(|e| ApiError::Internal(e.to_string()))?; let total_users: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") .fetch_one(&state.db).await.map_err(|e| ApiError::Internal(e.to_string()))?; // MRR from webstore subscriptions (count active webstore licenses * $10/mo) let webstore_count: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM licenses WHERE webstore_active = true" ).fetch_one(&state.db).await.map_err(|e| ApiError::Internal(e.to_string()))?; let module_mrr = webstore_count.0 as f64 * 10.0; let servers_online: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM server_connections WHERE connection_status = 'connected'" ).fetch_one(&state.db).await.map_err(|e| ApiError::Internal(e.to_string()))?; let new_signups: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM users WHERE created_at >= NOW() - INTERVAL '7 days'" ).fetch_one(&state.db).await.map_err(|e| ApiError::Internal(e.to_string()))?; Ok(Json(PlatformStats { total_licenses: total_licenses.0, active_licenses: active_licenses.0, total_users: total_users.0, module_mrr, servers_online: servers_online.0, new_signups_this_week: new_signups.0, })) } /// GET /api/admin/licenses — paginated, filterable async fn list_licenses( _admin: SuperAdmin, State(state): State>, Query(params): Query, ) -> ApiResult>> { let page = params.page.unwrap_or(1).max(1); let per_page = params.per_page.unwrap_or(25).min(100); let offset = (page - 1) * per_page; let search = params.search.unwrap_or_default(); let status_filter = params.status.unwrap_or_default(); let licenses = sqlx::query_as::<_, LicenseListItem>( "SELECT l.id, l.license_key, l.status, u.email AS owner_email, \ u.username AS owner_username, l.server_name, l.modules_enabled, \ l.webstore_active, l.created_at, l.expires_at \ FROM licenses l \ JOIN users u ON u.id = l.owner_user_id \ WHERE ($1 = '' OR l.license_key ILIKE '%' || $1 || '%' OR u.email ILIKE '%' || $1 || '%') \ AND ($2 = '' OR l.status = $2) \ ORDER BY l.created_at DESC \ LIMIT $3 OFFSET $4" ) .bind(&search) .bind(&status_filter) .bind(per_page) .bind(offset) .fetch_all(&state.db) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let total: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM licenses l \ JOIN users u ON u.id = l.owner_user_id \ WHERE ($1 = '' OR l.license_key ILIKE '%' || $1 || '%' OR u.email ILIKE '%' || $1 || '%') \ AND ($2 = '' OR l.status = $2)" ) .bind(&search) .bind(&status_filter) .fetch_one(&state.db) .await .map_err(|e| ApiError::Internal(e.to_string()))?; Ok(Json(PaginatedResponse { data: licenses, total: total.0, page, per_page, })) } /// GET /api/admin/licenses/:id — detail view async fn get_license( _admin: SuperAdmin, State(state): State>, Path(id): Path, ) -> ApiResult> { let license = sqlx::query_as::<_, LicenseListItem>( "SELECT l.id, l.license_key, l.status, u.email AS owner_email, \ u.username AS owner_username, l.server_name, l.modules_enabled, \ l.webstore_active, l.created_at, l.expires_at \ FROM licenses l \ JOIN users u ON u.id = l.owner_user_id \ WHERE l.id = $1" ) .bind(id) .fetch_optional(&state.db) .await .map_err(|e| ApiError::Internal(e.to_string()))? .ok_or(ApiError::NotFound("License not found".to_string()))?; let team_count: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM team_members WHERE license_id = $1" ) .bind(id) .fetch_one(&state.db) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let wipe_count: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM wipe_history WHERE license_id = $1" ) .bind(id) .fetch_one(&state.db) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let server_connection = sqlx::query_as::<_, ServerConnectionInfo>( "SELECT connection_type, connection_status, server_ip, game_port, \ plugin_last_seen, companion_last_seen \ FROM server_connections WHERE license_id = $1" ) .bind(id) .fetch_optional(&state.db) .await .map_err(|e| ApiError::Internal(e.to_string()))?; Ok(Json(LicenseDetail { license, team_count: team_count.0, wipe_count: wipe_count.0, server_connection, })) } /// POST /api/admin/licenses — manual generation async fn create_license( _admin: SuperAdmin, State(state): State>, Json(body): Json, ) -> ApiResult> { // Find user by email let user = crate::db::users::get_user_by_email(&state.db, &body.owner_email) .await .map_err(|e| ApiError::Internal(e.to_string()))? .ok_or(ApiError::NotFound("User not found".to_string()))?; let license_key = body.license_key.unwrap_or_else(|| { format!("CORROSION-{}", crate::services::encryption::generate_token(8).to_uppercase()) }); let id = crate::db::licenses::create_license(&state.db, &license_key, user.id) .await .map_err(|e| ApiError::Internal(e.to_string()))?; Ok(Json(serde_json::json!({ "id": id, "license_key": license_key, }))) } /// PATCH /api/admin/licenses/:id — revoke/activate async fn update_license( _admin: SuperAdmin, State(state): State>, Path(id): Path, Json(body): Json, ) -> ApiResult> { if let Some(status) = &body.status { let valid = ["active", "suspended", "expired", "revoked"]; if !valid.contains(&status.as_str()) { return Err(ApiError::BadRequest(format!("Invalid status: {status}"))); } crate::db::licenses::update_license_status(&state.db, id, status) .await .map_err(|e| ApiError::Internal(e.to_string()))?; } if let Some(expires_at) = &body.expires_at { let parsed = chrono::DateTime::parse_from_rfc3339(expires_at) .map_err(|_| ApiError::BadRequest("Invalid date format, use RFC3339".to_string()))?; sqlx::query("UPDATE licenses SET expires_at = $1 WHERE id = $2") .bind(parsed) .bind(id) .execute(&state.db) .await .map_err(|e| ApiError::Internal(e.to_string()))?; } Ok(Json(serde_json::json!({ "updated": true }))) } /// GET /api/admin/subscriptions — module subscription list async fn list_subscriptions( _admin: SuperAdmin, State(state): State>, ) -> ApiResult> { // Unnest modules_enabled array to get per-module subscription data let subscriptions = sqlx::query_as::<_, SubscriptionListItem>( "SELECT u.email AS owner_email, u.username AS owner_username, \ l.id AS license_id, unnest(l.modules_enabled) AS module_name \ FROM licenses l \ JOIN users u ON u.id = l.owner_user_id \ WHERE l.status = 'active' AND array_length(l.modules_enabled, 1) > 0 \ ORDER BY u.email" ) .fetch_all(&state.db) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let module_counts = sqlx::query_as::<_, ModuleCount>( "SELECT m AS module_name, COUNT(*) AS subscriber_count \ FROM licenses, unnest(modules_enabled) AS m \ WHERE status = 'active' AND array_length(modules_enabled, 1) > 0 \ GROUP BY m ORDER BY subscriber_count DESC" ) .fetch_all(&state.db) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let total: i64 = module_counts.iter().map(|m| m.subscriber_count).sum(); Ok(Json(SubscriptionSummary { subscriptions, module_counts, total_subscribers: total, })) } /// GET /api/admin/users — paginated, filterable async fn list_users( _admin: SuperAdmin, State(state): State>, Query(params): Query, ) -> ApiResult>> { let page = params.page.unwrap_or(1).max(1); let per_page = params.per_page.unwrap_or(25).min(100); let offset = (page - 1) * per_page; let search = params.search.unwrap_or_default(); let users = sqlx::query_as::<_, UserListItem>( "SELECT u.id, u.email, u.username, u.is_super_admin, u.email_verified, \ COUNT(l.id) AS license_count, u.created_at, u.last_login_at \ FROM users u \ LEFT JOIN licenses l ON l.owner_user_id = u.id \ WHERE ($1 = '' OR u.email ILIKE '%' || $1 || '%' OR u.username ILIKE '%' || $1 || '%') \ GROUP BY u.id \ ORDER BY u.created_at DESC \ LIMIT $2 OFFSET $3" ) .bind(&search) .bind(per_page) .bind(offset) .fetch_all(&state.db) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let total: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM users \ WHERE ($1 = '' OR email ILIKE '%' || $1 || '%' OR username ILIKE '%' || $1 || '%')" ) .bind(&search) .fetch_one(&state.db) .await .map_err(|e| ApiError::Internal(e.to_string()))?; Ok(Json(PaginatedResponse { data: users, total: total.0, page, per_page, })) } /// PATCH /api/admin/users/:id — disable/enable/set-admin async fn update_user( _admin: SuperAdmin, State(state): State>, Path(id): Path, Json(body): Json, ) -> ApiResult> { // Verify user exists let _user = crate::db::users::get_user_by_id(&state.db, id) .await .map_err(|e| ApiError::Internal(e.to_string()))? .ok_or(ApiError::NotFound("User not found".to_string()))?; if let Some(is_admin) = body.is_super_admin { sqlx::query("UPDATE users SET is_super_admin = $1 WHERE id = $2") .bind(is_admin) .bind(id) .execute(&state.db) .await .map_err(|e| ApiError::Internal(e.to_string()))?; } // "Disabled" means we revoke all their licenses if body.disabled == Some(true) { sqlx::query("UPDATE licenses SET status = 'suspended' WHERE owner_user_id = $1") .bind(id) .execute(&state.db) .await .map_err(|e| ApiError::Internal(e.to_string()))?; } Ok(Json(serde_json::json!({ "updated": true }))) } /// GET /api/admin/servers — fleet overview async fn list_servers( _admin: SuperAdmin, State(state): State>, Query(params): Query, ) -> ApiResult>> { let status_filter = params.status.unwrap_or_default(); let servers = sqlx::query_as::<_, ServerFleetItem>( "SELECT sc.license_id, u.email AS owner_email, \ cfg.server_name, sc.connection_type, sc.connection_status, \ sc.server_ip, sc.game_port, sc.plugin_last_seen, sc.companion_last_seen \ FROM server_connections sc \ JOIN licenses l ON l.id = sc.license_id \ JOIN users u ON u.id = l.owner_user_id \ LEFT JOIN server_config cfg ON cfg.license_id = sc.license_id \ WHERE ($1 = '' OR sc.connection_status = $1) \ ORDER BY sc.connection_status ASC, u.email ASC" ) .bind(&status_filter) .fetch_all(&state.db) .await .map_err(|e| ApiError::Internal(e.to_string()))?; Ok(Json(servers)) } /// GET /api/admin/health — system metrics async fn health( _admin: SuperAdmin, State(state): State>, ) -> ApiResult> { let tables = vec![ "users", "licenses", "server_connections", "wipe_history", "chat_logs", "plugin_registry", "webstore_transactions", ]; let mut table_counts = Vec::new(); for table in tables { let count: (i64,) = sqlx::query_as(&format!("SELECT COUNT(*) FROM {table}")) .fetch_one(&state.db) .await .unwrap_or((0,)); table_counts.push(TableCount { table_name: table.to_string(), row_count: count.0, }); } Ok(Json(HealthInfo { db_pool_size: state.config.database_max_connections, nats_connected: state.nats.is_some(), table_counts, })) }