use sqlx::PgPool; use uuid::Uuid; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; // TODO: Define WipeProfile struct (id, server_id, name, wipe_type, commands, plugin_actions, created_at) // TODO: Define WipeSchedule struct (id, profile_id, cron_expression, next_run_at, enabled) /// Wipe history entry (full schema). #[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)] pub struct WipeHistoryRow { pub id: Uuid, pub license_id: Uuid, pub wipe_schedule_id: Option, pub wipe_profile_id: Uuid, pub wipe_type: String, pub trigger_type: String, pub status: String, pub started_at: Option>, pub completed_at: Option>, pub map_used: Option, pub plugins_wiped: Vec, pub plugins_preserved: Vec, pub backup_reference: Option, pub error_message: Option, pub created_at: DateTime, } /// Analytics: Wipe with duration and peak population metadata. #[derive(Debug, Clone, Serialize)] pub struct WipeAnalyticsEntry { pub date: DateTime, pub duration_seconds: i64, pub peak_population: i32, pub hours_to_peak: f64, pub success: bool, } /// Analytics: Population curve aggregates (average players per day post-wipe). #[derive(Debug, Clone, Serialize)] pub struct PopulationCurve { pub day_1_avg: f64, pub day_2_avg: f64, pub day_3_avg: f64, } /// Analytics: Optimal wipe timing recommendation. #[derive(Debug, Clone, Serialize)] pub struct OptimalWipeTiming { pub optimal_wipe_day: String, pub optimal_wipe_hour: i32, } /// Create a new wipe profile (template for a wipe operation). pub async fn create_wipe_profile(pool: &PgPool, server_id: Uuid, name: &str, wipe_type: &str) -> Result { todo!() } /// Get all wipe profiles for a server. pub async fn get_wipe_profiles(pool: &PgPool, server_id: Uuid) -> Result<()> { todo!() } /// Create a cron-based schedule for a wipe profile. pub async fn create_wipe_schedule(pool: &PgPool, profile_id: Uuid, cron_expression: &str) -> Result { todo!() } /// Get all schedules for a wipe profile. pub async fn get_wipe_schedules(pool: &PgPool, profile_id: Uuid) -> Result<()> { todo!() } /// Record the start of a wipe execution. pub async fn create_wipe_history(pool: &PgPool, profile_id: Uuid) -> Result { todo!() } /// Update a wipe history entry with completion status and log output. pub async fn update_wipe_history(pool: &PgPool, history_id: Uuid, status: &str, log: Option<&str>) -> Result<()> { todo!() } /// Get wipe history for a profile, ordered by most recent first. pub async fn get_wipe_history(pool: &PgPool, profile_id: Uuid, limit: i64) -> Result<()> { todo!() } // ======================================================================== // ANALYTICS QUERIES — Phase 2 Wipe Performance Tracking // ======================================================================== /// Get wipe success rate over a time range (days). /// Returns (total_wipes, successful_wipes, failed_wipes). pub async fn get_wipe_success_rate( pool: &PgPool, license_id: Uuid, days: i64, ) -> Result<(i64, i64, i64)> { let result: (i64, i64, i64) = sqlx::query_as( "SELECT COUNT(*) as total_wipes, COUNT(*) FILTER (WHERE status = 'success') as successful_wipes, COUNT(*) FILTER (WHERE status IN ('failed', 'rolled_back')) as failed_wipes FROM wipe_history WHERE license_id = $1 AND created_at >= NOW() - ($2 || ' days')::INTERVAL AND status IN ('success', 'failed', 'rolled_back')", ) .bind(license_id) .bind(days) .fetch_one(pool) .await .context("Failed to query wipe success rate")?; Ok(result) } /// Get average wipe duration (in seconds) over a time range (days). /// Only includes successful wipes with valid start/completion timestamps. pub async fn get_average_wipe_duration( pool: &PgPool, license_id: Uuid, days: i64, ) -> Result { let result: Option<(Option,)> = sqlx::query_as( "SELECT AVG(EXTRACT(EPOCH FROM (completed_at - started_at))) as avg_duration FROM wipe_history WHERE license_id = $1 AND created_at >= NOW() - ($2 || ' days')::INTERVAL AND status = 'success' AND started_at IS NOT NULL AND completed_at IS NOT NULL", ) .bind(license_id) .bind(days) .fetch_optional(pool) .await .context("Failed to query average wipe duration")?; Ok(result.and_then(|r| r.0).unwrap_or(0.0)) } /// Get hours from wipe completion to peak population. /// For each wipe, finds the max player count in the 24 hours post-wipe, /// then calculates how many hours after wipe completion that peak occurred. /// Returns average across all wipes in the time range. pub async fn get_wipe_to_peak_population( pool: &PgPool, license_id: Uuid, days: i64, ) -> Result { // For each successful wipe, find the peak player count within 24 hours post-wipe // and calculate hours from wipe completion to that peak. let result: Option<(Option,)> = sqlx::query_as( "WITH wipe_peaks AS ( SELECT wh.id as wipe_id, wh.completed_at, MAX(ssh.max_players) as peak_players, (SELECT ssh2.hour FROM server_stats_hourly ssh2 WHERE ssh2.license_id = wh.license_id AND ssh2.hour >= wh.completed_at AND ssh2.hour < wh.completed_at + INTERVAL '24 hours' AND ssh2.max_players = MAX(ssh.max_players) ORDER BY ssh2.hour ASC LIMIT 1 ) as peak_hour FROM wipe_history wh LEFT JOIN server_stats_hourly ssh ON ssh.license_id = wh.license_id AND ssh.hour >= wh.completed_at AND ssh.hour < wh.completed_at + INTERVAL '24 hours' WHERE wh.license_id = $1 AND wh.created_at >= NOW() - ($2 || ' days')::INTERVAL AND wh.status = 'success' AND wh.completed_at IS NOT NULL GROUP BY wh.id, wh.completed_at ) SELECT AVG(EXTRACT(EPOCH FROM (peak_hour - completed_at)) / 3600.0) as avg_hours_to_peak FROM wipe_peaks WHERE peak_hour IS NOT NULL", ) .bind(license_id) .bind(days) .fetch_optional(pool) .await .context("Failed to query wipe-to-peak population")?; Ok(result.and_then(|r| r.0).unwrap_or(0.0)) } /// Get population curve: average player count on day 1, day 2, day 3 post-wipe. /// Aggregates all successful wipes in the time range. pub async fn get_population_curve_by_cycle( pool: &PgPool, license_id: Uuid, days: i64, ) -> Result { // For each successful wipe, calculate avg players on day 1 (0-24h), day 2 (24-48h), day 3 (48-72h) let result: Vec<(i32, f64)> = sqlx::query_as( "WITH wipe_days AS ( SELECT wh.id as wipe_id, wh.completed_at, CASE WHEN ssh.hour >= wh.completed_at AND ssh.hour < wh.completed_at + INTERVAL '24 hours' THEN 1 WHEN ssh.hour >= wh.completed_at + INTERVAL '24 hours' AND ssh.hour < wh.completed_at + INTERVAL '48 hours' THEN 2 WHEN ssh.hour >= wh.completed_at + INTERVAL '48 hours' AND ssh.hour < wh.completed_at + INTERVAL '72 hours' THEN 3 END as day_num, ssh.avg_players FROM wipe_history wh LEFT JOIN server_stats_hourly ssh ON ssh.license_id = wh.license_id AND ssh.hour >= wh.completed_at AND ssh.hour < wh.completed_at + INTERVAL '72 hours' WHERE wh.license_id = $1 AND wh.created_at >= NOW() - ($2 || ' days')::INTERVAL AND wh.status = 'success' AND wh.completed_at IS NOT NULL ) SELECT day_num, AVG(avg_players) as avg_players_per_day FROM wipe_days WHERE day_num IS NOT NULL GROUP BY day_num ORDER BY day_num", ) .bind(license_id) .bind(days) .fetch_all(pool) .await .context("Failed to query population curve")?; let mut curve = PopulationCurve { day_1_avg: 0.0, day_2_avg: 0.0, day_3_avg: 0.0, }; for (day_num, avg_players) in result { match day_num { 1 => curve.day_1_avg = avg_players, 2 => curve.day_2_avg = avg_players, 3 => curve.day_3_avg = avg_players, _ => {} } } Ok(curve) } /// Get optimal wipe timing: best day of week and hour based on historical peak populations. /// Analyzes successful wipes and their subsequent player peaks. pub async fn get_optimal_wipe_timing( pool: &PgPool, license_id: Uuid, days: i64, ) -> Result { // Find the day of week and hour with highest average peak population within 24h post-wipe let result: Option<(String, f64, f64)> = sqlx::query_as( "WITH wipe_performance AS ( SELECT wh.id as wipe_id, wh.completed_at, EXTRACT(DOW FROM wh.completed_at) as day_of_week, EXTRACT(HOUR FROM wh.completed_at) as hour_of_day, MAX(ssh.max_players) as peak_players FROM wipe_history wh LEFT JOIN server_stats_hourly ssh ON ssh.license_id = wh.license_id AND ssh.hour >= wh.completed_at AND ssh.hour < wh.completed_at + INTERVAL '24 hours' WHERE wh.license_id = $1 AND wh.created_at >= NOW() - ($2 || ' days')::INTERVAL AND wh.status = 'success' AND wh.completed_at IS NOT NULL GROUP BY wh.id, wh.completed_at ), best_timing AS ( SELECT day_of_week, hour_of_day, AVG(peak_players) as avg_peak_players FROM wipe_performance GROUP BY day_of_week, hour_of_day ORDER BY avg_peak_players DESC LIMIT 1 ) SELECT CASE day_of_week::INTEGER WHEN 0 THEN 'Sunday' WHEN 1 THEN 'Monday' WHEN 2 THEN 'Tuesday' WHEN 3 THEN 'Wednesday' WHEN 4 THEN 'Thursday' WHEN 5 THEN 'Friday' WHEN 6 THEN 'Saturday' END as day_name, hour_of_day, avg_peak_players FROM best_timing", ) .bind(license_id) .bind(days) .fetch_optional(pool) .await .context("Failed to query optimal wipe timing")?; let (day_name, hour_of_day, _) = result.unwrap_or(("Thursday".to_string(), 18.0, 0.0)); Ok(OptimalWipeTiming { optimal_wipe_day: day_name, optimal_wipe_hour: hour_of_day as i32, }) } /// Get detailed wipe analytics entries (for charting). /// Returns individual wipe records with duration, peak population, hours to peak. pub async fn get_wipe_analytics_entries( pool: &PgPool, license_id: Uuid, days: i64, ) -> Result> { let rows: Vec<(Uuid, DateTime, Option>, Option>, String)> = sqlx::query_as( "SELECT id, created_at, started_at, completed_at, status FROM wipe_history WHERE license_id = $1 AND created_at >= NOW() - ($2 || ' days')::INTERVAL AND status IN ('success', 'failed', 'rolled_back') ORDER BY created_at ASC", ) .bind(license_id) .bind(days) .fetch_all(pool) .await .context("Failed to query wipe analytics entries")?; let mut entries = Vec::new(); for (wipe_id, created_at, started_at, completed_at, status) in rows { let success = status == "success"; let duration_seconds = if let (Some(start), Some(end)) = (started_at, completed_at) { (end - start).num_seconds() } else { 0 }; // Find peak population and hours to peak for successful wipes let (peak_population, hours_to_peak) = if success && completed_at.is_some() { let peak_result: Option<(i32, DateTime)> = sqlx::query_as( "SELECT max_players, hour FROM server_stats_hourly WHERE license_id = $1 AND hour >= $2 AND hour < $2 + INTERVAL '24 hours' ORDER BY max_players DESC, hour ASC LIMIT 1", ) .bind(license_id) .bind(completed_at.unwrap()) .fetch_optional(pool) .await .context("Failed to query peak population for wipe")?; if let Some((peak, peak_hour)) = peak_result { let hours = (peak_hour - completed_at.unwrap()).num_seconds() as f64 / 3600.0; (peak, hours) } else { (0, 0.0) } } else { (0, 0.0) }; entries.push(WipeAnalyticsEntry { date: created_at, duration_seconds, peak_population, hours_to_peak, success, }); } Ok(entries) }