use crate::banner::BannerApi; use crate::cli::ServiceName; use crate::config::Config; use crate::scraper::ScraperService; use crate::services::bot::BotService; use crate::services::manager::ServiceManager; use crate::services::web::WebService; use crate::state::AppState; use crate::web::auth::AuthConfig; use anyhow::Context; use figment::value::UncasedStr; use figment::{Figment, providers::Env}; use sqlx::postgres::PgPoolOptions; use std::process::ExitCode; use std::sync::Arc; use std::time::Duration; use tracing::{error, info}; /// Main application struct containing all necessary components pub struct App { config: Config, db_pool: sqlx::PgPool, banner_api: Arc, app_state: AppState, service_manager: ServiceManager, } impl App { /// Create a new App instance with all necessary components initialized pub async fn new() -> Result { // Load configuration let config: Config = Figment::new() .merge(Env::raw().map(|k| { if k == UncasedStr::new("RAILWAY_DEPLOYMENT_DRAINING_SECONDS") { "SHUTDOWN_TIMEOUT".into() } else { k.into() } })) .extract() .context("Failed to load config")?; // Check if the database URL is via private networking let is_private = config.database_url.contains("railway.internal"); let slow_threshold = Duration::from_millis(if is_private { 200 } else { 500 }); // Create database connection pool let db_pool = PgPoolOptions::new() .min_connections(0) .max_connections(4) .acquire_slow_threshold(slow_threshold) .acquire_timeout(Duration::from_secs(4)) .idle_timeout(Duration::from_secs(60 * 2)) .max_lifetime(Duration::from_secs(60 * 30)) .connect(&config.database_url) .await .context("Failed to create database pool")?; info!( is_private = is_private, slow_threshold = format!("{:.2?}", slow_threshold), "database pool established" ); // Run database migrations info!("Running database migrations..."); sqlx::migrate!("./migrations") .run(&db_pool) .await .context("Failed to run database migrations")?; info!("Database migrations completed successfully"); // Create BannerApi and AppState let banner_api = BannerApi::new_with_config( config.banner_base_url.clone(), config.rate_limiting.clone(), ) .context("Failed to create BannerApi")?; let banner_api_arc = Arc::new(banner_api); let app_state = AppState::new(banner_api_arc.clone(), db_pool.clone()); // Load reference data cache from DB (may be empty on first run) if let Err(e) = app_state.load_reference_cache().await { info!(error = ?e, "Could not load reference cache on startup (may be empty)"); } // Seed the initial admin user if configured if let Some(admin_id) = config.admin_discord_id { let user = crate::data::users::ensure_seed_admin(&db_pool, admin_id as i64) .await .context("Failed to seed admin user")?; info!(discord_id = admin_id, username = %user.discord_username, "Seed admin ensured"); } Ok(App { config, db_pool, banner_api: banner_api_arc, app_state, service_manager: ServiceManager::new(), }) } /// Setup and register services based on enabled service list pub fn setup_services(&mut self, services: &[ServiceName]) -> Result<(), anyhow::Error> { // Register enabled services with the manager if services.contains(&ServiceName::Web) { let auth_config = AuthConfig { client_id: self.config.discord_client_id.clone(), client_secret: self.config.discord_client_secret.clone(), redirect_base: self.config.discord_redirect_uri.clone(), }; let web_service = Box::new(WebService::new( self.config.port, self.app_state.clone(), auth_config, )); self.service_manager .register_service(ServiceName::Web.as_str(), web_service); } if services.contains(&ServiceName::Scraper) { let scraper_service = Box::new(ScraperService::new( self.db_pool.clone(), self.banner_api.clone(), self.app_state.reference_cache.clone(), self.app_state.service_statuses.clone(), self.app_state.scrape_job_tx.clone(), )); self.service_manager .register_service(ServiceName::Scraper.as_str(), scraper_service); } // Check if any services are enabled if !self.service_manager.has_services() && !services.contains(&ServiceName::Bot) { error!("No services enabled. Cannot start application."); return Err(anyhow::anyhow!("No services enabled")); } Ok(()) } /// Setup bot service if enabled pub async fn setup_bot_service(&mut self) -> Result<(), anyhow::Error> { use std::sync::Arc; use tokio::sync::{Mutex, broadcast}; // Create shutdown channel for status update task let (status_shutdown_tx, status_shutdown_rx) = broadcast::channel(1); let status_task_handle = Arc::new(Mutex::new(None)); let client = BotService::create_client( &self.config, self.app_state.clone(), status_task_handle.clone(), status_shutdown_rx, ) .await .context("Failed to create Discord client")?; let bot_service = Box::new(BotService::new( client, status_task_handle, status_shutdown_tx, self.app_state.service_statuses.clone(), )); self.service_manager .register_service(ServiceName::Bot.as_str(), bot_service); Ok(()) } /// Start all registered services pub fn start_services(&mut self) { self.service_manager.spawn_all(); } /// Run the application and handle shutdown signals pub async fn run(self) -> ExitCode { use crate::signals::handle_shutdown_signals; handle_shutdown_signals(self.service_manager, self.config.shutdown_timeout).await } /// Get a reference to the configuration pub fn config(&self) -> &Config { &self.config } }