app_settings_service.rs 415 lignes · 13308 octets
use crate::error::AppError;
use crate::models::app_settings;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use std::env;
use uuid::Uuid;

/// Service for managing application settings
///
/// This service provides business logic for global application settings
/// stored as key-value pairs with traceability.
pub struct AppSettingsService;

impl AppSettingsService {
    /// Helper: get boolean setting with fallback keys (first found wins)
    pub async fn get_bool_with_fallback(
        db: &DatabaseConnection,
        keys: &[&str],
        default: bool,
    ) -> Result<bool, AppError> {
        for key in keys {
            if let Some(setting) = Self::get_setting(db, key).await? {
                return Ok(setting.value.parse::<bool>().unwrap_or(default));
            }
        }
        Ok(default)
    }

    /// Helper: get boolean setting with default fallback
    pub async fn get_bool(
        db: &DatabaseConnection,
        key: &str,
        default: bool,
    ) -> Result<bool, AppError> {
        if let Some(setting) = Self::get_setting(db, key).await? {
            return Ok(setting.value.parse::<bool>().unwrap_or(default));
        }
        Ok(default)
    }

    /// Get a setting by key
    ///
    /// # Arguments
    ///
    /// * `db` - Database connection
    /// * `key` - Setting key
    ///
    /// # Returns
    ///
    /// Setting if found, None otherwise
    pub async fn get_setting(
        db: &DatabaseConnection,
        key: &str,
    ) -> Result<Option<app_settings::Model>, AppError> {
        let setting = app_settings::Entity::find()
            .filter(app_settings::Column::Key.eq(key))
            .one(db)
            .await?;

        Ok(setting)
    }

    /// Set or update a setting
    ///
    /// # Arguments
    ///
    /// * `db` - Database connection
    /// * `key` - Setting key
    /// * `value` - Setting value
    /// * `updated_by` - UUID of the user who updated this setting
    ///
    /// # Returns
    ///
    /// The created or updated setting model
    pub async fn set_setting(
        db: &DatabaseConnection,
        key: String,
        value: String,
        updated_by: Option<Uuid>,
    ) -> Result<app_settings::Model, AppError> {
        let existing = app_settings::Entity::find()
            .filter(app_settings::Column::Key.eq(&key))
            .one(db)
            .await?;

        let now = Utc::now();

        if let Some(setting) = existing {
            // Update existing setting
            let mut active_setting: app_settings::ActiveModel = setting.into();
            active_setting.value = Set(value);
            active_setting.updated_by = Set(updated_by);
            active_setting.updated_at = Set(now);

            let result = active_setting.update(db).await?;
            Ok(result)
        } else {
            // Create new setting
            let setting = app_settings::ActiveModel {
                id: Set(Uuid::new_v4()),
                key: Set(key),
                value: Set(value),
                description: Set(None),
                updated_by: Set(updated_by),
                updated_at: Set(now),
            };

            let result = setting.insert(db).await?;
            Ok(result)
        }
    }

    /// Get all settings
    ///
    /// # Arguments
    ///
    /// * `db` - Database connection
    ///
    /// # Returns
    ///
    /// Vector of all settings
    pub async fn get_all_settings(
        db: &DatabaseConnection,
    ) -> Result<Vec<app_settings::Model>, AppError> {
        let settings = app_settings::Entity::find().all(db).await?;
        Ok(settings)
    }

    /// Get server configuration from AppSettings.
    ///
    /// This method reads configuration from AppSettings using the standardized key format
    /// `{app_id}:server_config:{config_name}`. Returns the first matching configuration found.
    ///
    /// # Arguments
    ///
    /// * `db` - Database connection
    /// * `config_name` - Name of the configuration (e.g., "max_body_size")
    ///
    /// # Returns
    ///
    /// `Ok(Some(value))` if configuration found, `Ok(None)` if not found
    ///
    /// # Errors
    ///
    /// Returns `AppError` if database query fails
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// let max_size = AppSettingsService::get_server_config(&db, "max_body_size").await?;
    /// ```
    pub async fn get_server_config(
        db: &DatabaseConnection,
        config_name: &str,
    ) -> Result<Option<String>, AppError> {
        // Search for all settings matching the pattern *:server_config:{config_name}
        // Use SQL LIKE pattern: %:server_config:{config_name}
        let pattern = format!("%:server_config:{}", config_name);
        let settings = app_settings::Entity::find()
            .filter(app_settings::Column::Key.like(&pattern))
            .all(db)
            .await
            .map_err(AppError::Database)?;

        // Return the first matching configuration
        Ok(settings.into_iter().next().map(|s| s.value))
    }

    /// Set server configuration in AppSettings.
    ///
    /// Stores configuration using the standardized key format
    /// `{app_id}:server_config:{config_name}`.
    ///
    /// # Arguments
    ///
    /// * `db` - Database connection
    /// * `app_id` - ID of the application
    /// * `config_name` - Name of the configuration (e.g., "max_body_size")
    /// * `value` - Configuration value (as string)
    ///
    /// # Returns
    ///
    /// The created or updated setting model
    ///
    /// # Errors
    ///
    /// Returns `AppError` if database operation fails
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// AppSettingsService::set_server_config(&db, "files", "max_body_size", "10737418240").await?;
    /// ```
    pub async fn set_server_config(
        db: &DatabaseConnection,
        app_id: &str,
        config_name: &str,
        value: String,
    ) -> Result<app_settings::Model, AppError> {
        let key = format!("{}:server_config:{}", app_id, config_name);
        Self::set_setting(db, key, value, None).await
    }

    /// Initialize default settings
    ///
    /// This function creates default application settings if they don't exist.
    /// It's idempotent and should be called at application startup.
    ///
    /// # Arguments
    ///
    /// * `db` - Database connection
    ///
    /// # Returns
    ///
    /// Ok(()) if successful
    pub async fn initialize_default_settings(db: &DatabaseConnection) -> Result<(), AppError> {
        // Get app domain from environment or use default
        let app_domain = env::var("APP_DOMAIN").unwrap_or_else(|_| "localhost".to_string());

        // List of default settings to initialize
        let default_settings = vec![
            (
                "app_domain",
                app_domain.as_str(),
                Some("Application domain name"),
            ),
            (
                "allow_registration",
                "false",
                Some("Allow user registration"),
            ),
            (
                "validation_email_required",
                "true",
                Some("Require email validation to login"),
            ),
            (
                "audit_log_level",
                "INFO",
                Some("Audit log level: DEBUG, INFO, WARN, ERROR"),
            ),
            // Audience measurement. The script URL is deliberately absent:
            // it feeds the CSP, which is frozen at startup, so it stays an
            // environment/builder setting. See `config::analytics`.
            (
                "analytics_enabled",
                "false",
                Some("Enable the audience-measurement script"),
            ),
            (
                "analytics_site_id",
                "",
                Some("Site identifier handed to the analytics vendor"),
            ),
            (
                "analytics_attributes",
                "{}",
                Some("Extra data-* attributes for the analytics script (JSON object)"),
            ),
            (
                "password_min_length",
                "8",
                Some("Minimum password length (6-128)"),
            ),
            (
                "password_require_uppercase",
                "false",
                Some("Require uppercase letters in password"),
            ),
            (
                "password_require_lowercase",
                "false",
                Some("Require lowercase letters in password"),
            ),
            (
                "password_require_digits",
                "false",
                Some("Require digits in password"),
            ),
            (
                "password_require_special",
                "false",
                Some("Require special characters in password"),
            ),
            (
                "password_change_require_email",
                "true",
                Some("Require email confirmation for password changes"),
            ),
            (
                "password_expiration_enabled",
                "false",
                Some("Enable password expiration"),
            ),
            (
                "password_expiration_days",
                "0",
                Some("Password expiration duration in days (0 = disabled, 1-365)"),
            ),
            (
                "password_expiration_alert_enabled",
                "false",
                Some("Enable email alerts for password expiration"),
            ),
            (
                "password_expiration_alert_days_before",
                "7",
                Some("Days before expiration to send alert"),
            ),
            (
                "audit_log_actions",
                r#"["create","update","delete","reset_password"]"#,
                Some("Actions to log in audit_logs table (JSON array)"),
            ),
            (
                "oauth_enabled",
                "false",
                Some("Enable OAuth/OpenID Connect authentication"),
            ),
            (
                "oauth_google_enabled",
                "false",
                Some("Enable Google OAuth provider"),
            ),
            (
                "oauth_github_enabled",
                "false",
                Some("Enable GitHub OAuth provider"),
            ),
            (
                "oauth_discord_enabled",
                "false",
                Some("Enable Discord OAuth provider"),
            ),
            (
                "oauth_microsoft_enabled",
                "false",
                Some("Enable Microsoft OAuth provider"),
            ),
            (
                "oauth_redirect_base_url",
                "",
                Some(
                    "Base URL for OAuth redirect callbacks (fallback to OAUTH_REDIRECT_BASE_URL env var)",
                ),
            ),
            (
                "oauth_auto_register",
                "true",
                Some("Auto-create user account on first OAuth login if email not found"),
            ),
            (
                "oauth_link_existing_account",
                "true",
                Some("Auto-link OAuth account to existing user with same verified email"),
            ),
            ("oauth_google_client_id", "", Some("Google OAuth Client ID")),
            (
                "oauth_google_client_secret",
                "",
                Some("Google OAuth Client Secret (encrypted)"),
            ),
            ("oauth_github_client_id", "", Some("GitHub OAuth Client ID")),
            (
                "oauth_github_client_secret",
                "",
                Some("GitHub OAuth Client Secret (encrypted)"),
            ),
            (
                "oauth_discord_client_id",
                "",
                Some("Discord OAuth Client ID"),
            ),
            (
                "oauth_discord_client_secret",
                "",
                Some("Discord OAuth Client Secret (encrypted)"),
            ),
            (
                "oauth_microsoft_client_id",
                "",
                Some("Microsoft OAuth Client ID"),
            ),
            (
                "oauth_microsoft_client_secret",
                "",
                Some("Microsoft OAuth Client Secret (encrypted)"),
            ),
            (
                "oauth_microsoft_tenant",
                "",
                Some("Microsoft Azure AD Tenant ID"),
            ),
        ];

        // Check if settings already exist before creating
        for (key, value, description) in default_settings {
            let existing = Self::get_setting(db, key).await?;
            if existing.is_none() {
                let active_setting = app_settings::ActiveModel {
                    id: Set(Uuid::new_v4()),
                    key: Set(key.to_string()),
                    value: Set(value.to_string()),
                    description: Set(description.map(|s| s.to_string())),
                    updated_by: Set(None),
                    updated_at: Set(Utc::now()),
                };

                active_setting.insert(db).await?;
            }
        }

        Ok(())
    }
}