use crate::error::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use sqlx::{PgPool, Row};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ValueType {
String,
Number,
Boolean,
Json,
}
impl ValueType {
pub fn as_str(&self) -> &'static str {
match self {
ValueType::String => "string",
ValueType::Number => "number",
ValueType::Boolean => "boolean",
ValueType::Json => "json",
}
}
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)]
pub struct SystemConfig {
pub id: Uuid,
pub config_key: String,
pub config_value: String,
pub value_type: String,
pub description: Option<String>,
pub category: Option<String>,
pub environment: String,
pub version: i32,
pub is_active: bool,
pub validation_rules: Option<JsonValue>,
pub is_sensitive: bool,
pub changed_by: Option<Uuid>,
pub changed_reason: Option<String>,
pub previous_value: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub effective_from: Option<DateTime<Utc>>,
pub effective_until: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSystemConfig {
pub config_key: String,
pub config_value: String,
pub value_type: ValueType,
pub description: Option<String>,
pub category: Option<String>,
pub environment: Option<String>,
pub validation_rules: Option<JsonValue>,
pub is_sensitive: Option<bool>,
pub changed_by: Option<Uuid>,
pub changed_reason: Option<String>,
pub effective_from: Option<DateTime<Utc>>,
pub effective_until: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateSystemConfig {
pub config_value: String,
pub description: Option<String>,
pub changed_by: Option<Uuid>,
pub changed_reason: Option<String>,
pub effective_from: Option<DateTime<Utc>>,
pub effective_until: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)]
pub struct ConfigHistory {
pub id: Uuid,
pub config_id: Uuid,
pub config_key: String,
pub old_value: Option<String>,
pub new_value: String,
pub value_type: String,
pub change_type: String,
pub changed_by: Option<Uuid>,
pub changed_reason: Option<String>,
pub environment: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)]
pub struct ConfigCategorySummary {
pub category: String,
pub total_configs: i64,
pub active_configs: i64,
}
pub struct SystemConfigRepository {
pool: PgPool,
}
impl SystemConfigRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub async fn create(&self, params: CreateSystemConfig) -> Result<SystemConfig> {
let config = sqlx::query_as::<_, SystemConfig>(
r#"
INSERT INTO system_config (
config_key, config_value, value_type, description, category,
environment, validation_rules, is_sensitive, changed_by, changed_reason,
effective_from, effective_until
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *
"#,
)
.bind(¶ms.config_key)
.bind(¶ms.config_value)
.bind(params.value_type.as_str())
.bind(¶ms.description)
.bind(¶ms.category)
.bind(
params
.environment
.unwrap_or_else(|| "production".to_string()),
)
.bind(¶ms.validation_rules)
.bind(params.is_sensitive.unwrap_or(false))
.bind(params.changed_by)
.bind(¶ms.changed_reason)
.bind(params.effective_from)
.bind(params.effective_until)
.fetch_one(&self.pool)
.await?;
self.record_history(
config.id,
&config.config_key,
None,
&config.config_value,
&config.value_type,
"created",
params.changed_by,
params.changed_reason.as_deref(),
&config.environment,
)
.await?;
Ok(config)
}
pub async fn get_by_key(
&self,
key: &str,
environment: Option<&str>,
) -> Result<Option<SystemConfig>> {
let env = environment.unwrap_or("production");
let config = sqlx::query_as::<_, SystemConfig>(
r#"
SELECT * FROM system_config
WHERE config_key = $1
AND (environment = $2 OR environment = 'all')
AND is_active = true
AND (effective_from IS NULL OR effective_from <= NOW())
AND (effective_until IS NULL OR effective_until > NOW())
ORDER BY
CASE WHEN environment = $2 THEN 0 ELSE 1 END,
version DESC
LIMIT 1
"#,
)
.bind(key)
.bind(env)
.fetch_optional(&self.pool)
.await?;
Ok(config)
}
pub async fn get_all(&self, environment: Option<&str>) -> Result<Vec<SystemConfig>> {
let env = environment.unwrap_or("production");
let configs = sqlx::query_as::<_, SystemConfig>(
r#"
SELECT DISTINCT ON (config_key) *
FROM system_config
WHERE (environment = $1 OR environment = 'all')
AND is_active = true
AND (effective_from IS NULL OR effective_from <= NOW())
AND (effective_until IS NULL OR effective_until > NOW())
ORDER BY config_key,
CASE WHEN environment = $1 THEN 0 ELSE 1 END,
version DESC
"#,
)
.bind(env)
.fetch_all(&self.pool)
.await?;
Ok(configs)
}
pub async fn get_by_category(
&self,
category: &str,
environment: Option<&str>,
) -> Result<Vec<SystemConfig>> {
let env = environment.unwrap_or("production");
let configs = sqlx::query_as::<_, SystemConfig>(
r#"
SELECT DISTINCT ON (config_key) *
FROM system_config
WHERE category = $1
AND (environment = $2 OR environment = 'all')
AND is_active = true
AND (effective_from IS NULL OR effective_from <= NOW())
AND (effective_until IS NULL OR effective_until > NOW())
ORDER BY config_key,
CASE WHEN environment = $2 THEN 0 ELSE 1 END,
version DESC
"#,
)
.bind(category)
.bind(env)
.fetch_all(&self.pool)
.await?;
Ok(configs)
}
pub async fn update(
&self,
key: &str,
environment: Option<&str>,
params: UpdateSystemConfig,
) -> Result<SystemConfig> {
let env = environment.unwrap_or("production");
let current = self.get_by_key(key, Some(env)).await?;
if current.is_none() {
return Err(crate::error::DbError::NotFound(format!(
"Configuration not found: {}",
key
)));
}
let current = current.unwrap();
sqlx::query(
r#"
UPDATE system_config
SET is_active = false, updated_at = NOW()
WHERE config_key = $1 AND environment = $2 AND is_active = true
"#,
)
.bind(key)
.bind(env)
.execute(&self.pool)
.await?;
let new_config = sqlx::query_as::<_, SystemConfig>(
r#"
INSERT INTO system_config (
config_key, config_value, value_type, description, category,
environment, version, validation_rules, is_sensitive,
changed_by, changed_reason, previous_value,
effective_from, effective_until
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING *
"#,
)
.bind(key)
.bind(¶ms.config_value)
.bind(¤t.value_type)
.bind(params.description.or(current.description))
.bind(¤t.category)
.bind(env)
.bind(current.version + 1)
.bind(¤t.validation_rules)
.bind(current.is_sensitive)
.bind(params.changed_by)
.bind(¶ms.changed_reason)
.bind(¤t.config_value)
.bind(params.effective_from)
.bind(params.effective_until)
.fetch_one(&self.pool)
.await?;
self.record_history(
new_config.id,
key,
Some(¤t.config_value),
¶ms.config_value,
¤t.value_type,
"updated",
params.changed_by,
params.changed_reason.as_deref(),
env,
)
.await?;
Ok(new_config)
}
pub async fn delete(
&self,
key: &str,
environment: Option<&str>,
changed_by: Option<Uuid>,
reason: Option<&str>,
) -> Result<()> {
let env = environment.unwrap_or("production");
let current = self.get_by_key(key, Some(env)).await?;
if let Some(config) = current {
sqlx::query(
r#"
UPDATE system_config
SET is_active = false, updated_at = NOW()
WHERE config_key = $1 AND environment = $2 AND is_active = true
"#,
)
.bind(key)
.bind(env)
.execute(&self.pool)
.await?;
self.record_history(
config.id,
key,
Some(&config.config_value),
"",
&config.value_type,
"deleted",
changed_by,
reason,
env,
)
.await?;
}
Ok(())
}
pub async fn get_history(&self, key: &str, limit: i64) -> Result<Vec<ConfigHistory>> {
let history = sqlx::query_as::<_, ConfigHistory>(
r#"
SELECT * FROM system_config_history
WHERE config_key = $1
ORDER BY created_at DESC
LIMIT $2
"#,
)
.bind(key)
.bind(limit)
.fetch_all(&self.pool)
.await?;
Ok(history)
}
pub async fn get_versions(
&self,
key: &str,
environment: Option<&str>,
) -> Result<Vec<SystemConfig>> {
let env = environment.unwrap_or("production");
let versions = sqlx::query_as::<_, SystemConfig>(
r#"
SELECT * FROM system_config
WHERE config_key = $1 AND environment = $2
ORDER BY version DESC
"#,
)
.bind(key)
.bind(env)
.fetch_all(&self.pool)
.await?;
Ok(versions)
}
pub async fn rollback(
&self,
key: &str,
environment: Option<&str>,
changed_by: Option<Uuid>,
reason: Option<&str>,
) -> Result<SystemConfig> {
let env = environment.unwrap_or("production");
let versions = self.get_versions(key, Some(env)).await?;
if versions.len() < 2 {
return Err(crate::error::DbError::Other(
"No previous version available for rollback".to_string(),
));
}
let previous = &versions[1];
self.update(
key,
Some(env),
UpdateSystemConfig {
config_value: previous.config_value.clone(),
description: previous.description.clone(),
changed_by,
changed_reason: Some(format!(
"Rollback to version {}: {}",
previous.version,
reason.unwrap_or("No reason provided")
)),
effective_from: None,
effective_until: None,
},
)
.await
}
pub async fn batch_update(
&self,
updates: Vec<(String, String, Option<Uuid>, Option<String>)>,
) -> Result<i64> {
let mut count = 0i64;
for (key, value, changed_by, reason) in updates {
let update_params = UpdateSystemConfig {
config_value: value,
description: None,
changed_by,
changed_reason: reason,
effective_from: None,
effective_until: None,
};
if self.update(&key, None, update_params).await.is_ok() {
count += 1;
}
}
Ok(count)
}
pub async fn get_category_summary(&self) -> Result<Vec<ConfigCategorySummary>> {
let summary = sqlx::query_as::<_, ConfigCategorySummary>(
r#"
SELECT
COALESCE(category, 'uncategorized') as category,
COUNT(*) as total_configs,
COUNT(*) FILTER (WHERE is_active = true) as active_configs
FROM system_config
GROUP BY category
ORDER BY active_configs DESC
"#,
)
.fetch_all(&self.pool)
.await?;
Ok(summary)
}
pub async fn count_active(&self, environment: Option<&str>) -> Result<i64> {
let env = environment.unwrap_or("production");
let row = sqlx::query(
r#"
SELECT COUNT(DISTINCT config_key) as count
FROM system_config
WHERE (environment = $1 OR environment = 'all')
AND is_active = true
"#,
)
.bind(env)
.fetch_one(&self.pool)
.await?;
Ok(row.get("count"))
}
pub async fn is_feature_enabled(
&self,
feature_key: &str,
environment: Option<&str>,
) -> Result<bool> {
if let Some(config) = self.get_by_key(feature_key, environment).await? {
Ok(config.config_value.eq_ignore_ascii_case("true") || config.config_value == "1")
} else {
Ok(false)
}
}
pub async fn get_string(&self, key: &str, environment: Option<&str>) -> Result<Option<String>> {
Ok(self
.get_by_key(key, environment)
.await?
.map(|c| c.config_value))
}
pub async fn get_number(&self, key: &str, environment: Option<&str>) -> Result<Option<f64>> {
Ok(self
.get_by_key(key, environment)
.await?
.and_then(|c| c.config_value.parse::<f64>().ok()))
}
pub async fn get_bool(&self, key: &str, environment: Option<&str>) -> Result<Option<bool>> {
Ok(self
.get_by_key(key, environment)
.await?
.map(|c| c.config_value.eq_ignore_ascii_case("true") || c.config_value == "1"))
}
pub async fn get_json(
&self,
key: &str,
environment: Option<&str>,
) -> Result<Option<JsonValue>> {
Ok(self
.get_by_key(key, environment)
.await?
.and_then(|c| serde_json::from_str(&c.config_value).ok()))
}
#[allow(clippy::too_many_arguments)]
async fn record_history(
&self,
config_id: Uuid,
config_key: &str,
old_value: Option<&str>,
new_value: &str,
value_type: &str,
change_type: &str,
changed_by: Option<Uuid>,
changed_reason: Option<&str>,
environment: &str,
) -> Result<()> {
sqlx::query(
r#"
INSERT INTO system_config_history (
config_id, config_key, old_value, new_value, value_type,
change_type, changed_by, changed_reason, environment
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"#,
)
.bind(config_id)
.bind(config_key)
.bind(old_value)
.bind(new_value)
.bind(value_type)
.bind(change_type)
.bind(changed_by)
.bind(changed_reason)
.bind(environment)
.execute(&self.pool)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_value_type_as_str() {
assert_eq!(ValueType::String.as_str(), "string");
assert_eq!(ValueType::Number.as_str(), "number");
assert_eq!(ValueType::Boolean.as_str(), "boolean");
assert_eq!(ValueType::Json.as_str(), "json");
}
#[test]
fn test_value_type_serialization() {
let vtype = ValueType::String;
let json = serde_json::to_string(&vtype).unwrap();
assert_eq!(json, r#""string""#);
let deserialized: ValueType = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, ValueType::String);
}
#[test]
fn test_create_config_params() {
let params = CreateSystemConfig {
config_key: "max_api_requests".to_string(),
config_value: "1000".to_string(),
value_type: ValueType::Number,
description: Some("Maximum API requests per hour".to_string()),
category: Some("api_limits".to_string()),
environment: Some("production".to_string()),
validation_rules: None,
is_sensitive: Some(false),
changed_by: Some(Uuid::new_v4()),
changed_reason: Some("Initial configuration".to_string()),
effective_from: None,
effective_until: None,
};
assert_eq!(params.config_key, "max_api_requests");
assert_eq!(params.value_type, ValueType::Number);
}
#[test]
fn test_update_config_params() {
let params = UpdateSystemConfig {
config_value: "2000".to_string(),
description: Some("Updated limit".to_string()),
changed_by: Some(Uuid::new_v4()),
changed_reason: Some("Increased for peak hours".to_string()),
effective_from: None,
effective_until: None,
};
assert_eq!(params.config_value, "2000");
assert!(params.changed_by.is_some());
}
#[test]
fn test_config_category_summary_structure() {
let summary = ConfigCategorySummary {
category: "feature_flags".to_string(),
total_configs: 10,
active_configs: 8,
};
assert_eq!(summary.category, "feature_flags");
assert_eq!(summary.active_configs, 8);
}
}