use crate::db::DbPool;
use crate::error::{AppError, AppResult, ErrorCode};
use chrono::{DateTime, Utc};
use serde_json::Value;
use std::collections::BTreeMap;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct StoredRuntimeConfig {
pub service: String,
pub key: String,
pub value: Value,
pub updated_at: DateTime<Utc>,
pub updated_by: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RuntimeConfigAuditEntry {
pub service: String,
pub key: String,
pub old_value: Option<Value>,
pub new_value: Value,
pub actor: Option<String>,
pub changed_at: DateTime<Utc>,
}
pub async fn load_all_values(pool: &DbPool) -> AppResult<BTreeMap<(String, String), Value>> {
let rows = sqlx::query_as::<_, (String, String, Value)>(
"select service, key, value from config.setting_values",
)
.fetch_all(pool)
.await
.map_err(store_error)?;
Ok(rows
.into_iter()
.map(|(service, key, value)| ((service, key), value))
.collect())
}
pub async fn upsert_value(
pool: &DbPool,
service: &str,
key: &str,
value: &Value,
actor: Option<&str>,
) -> AppResult<StoredRuntimeConfig> {
let mut tx = pool.begin().await.map_err(store_error)?;
let old_value = sqlx::query_scalar::<_, Value>(
"select value from config.setting_values where service = $1 and key = $2",
)
.bind(service)
.bind(key)
.fetch_optional(&mut *tx)
.await
.map_err(store_error)?;
let row = sqlx::query_as::<_, (String, String, Value, DateTime<Utc>, Option<String>)>(
r#"
insert into config.setting_values (service, key, value, updated_at, updated_by)
values ($1, $2, $3, now(), $4)
on conflict (service, key)
do update set value = excluded.value, updated_at = now(), updated_by = excluded.updated_by
returning service, key, value, updated_at, updated_by
"#,
)
.bind(service)
.bind(key)
.bind(value)
.bind(actor)
.fetch_one(&mut *tx)
.await
.map_err(store_error)?;
sqlx::query(
r#"
insert into config.setting_audit (id, service, key, old_value, new_value, actor, changed_at)
values ($1, $2, $3, $4, $5, $6, now())
"#,
)
.bind(Uuid::now_v7())
.bind(service)
.bind(key)
.bind(&old_value)
.bind(value)
.bind(actor)
.execute(&mut *tx)
.await
.map_err(store_error)?;
tx.commit().await.map_err(store_error)?;
Ok(StoredRuntimeConfig {
service: row.0,
key: row.1,
value: row.2,
updated_at: row.3,
updated_by: row.4,
})
}
pub async fn upsert_value_if_missing(
pool: &DbPool,
service: &str,
key: &str,
value: &Value,
actor: Option<&str>,
) -> AppResult<Option<StoredRuntimeConfig>> {
let mut tx = pool.begin().await.map_err(store_error)?;
let row = sqlx::query_as::<
_,
(
String,
String,
Value,
DateTime<Utc>,
Option<String>,
Option<Value>,
),
>(
r#"
with previous as (
select value from config.setting_values where service = $1 and key = $2
)
insert into config.setting_values (service, key, value, updated_at, updated_by)
values ($1, $2, $3, now(), $4)
on conflict (service, key)
do update set value = excluded.value, updated_at = now(), updated_by = excluded.updated_by
where config.setting_values.value = 'null'::jsonb
or config.setting_values.value = '""'::jsonb
returning service, key, value, updated_at, updated_by, (select value from previous) as old_value
"#,
)
.bind(service)
.bind(key)
.bind(value)
.bind(actor)
.fetch_optional(&mut *tx)
.await
.map_err(store_error)?;
let Some(row) = row else {
tx.commit().await.map_err(store_error)?;
return Ok(None);
};
sqlx::query(
r#"
insert into config.setting_audit (id, service, key, old_value, new_value, actor, changed_at)
values ($1, $2, $3, $4, $5, $6, now())
"#,
)
.bind(Uuid::now_v7())
.bind(service)
.bind(key)
.bind(&row.5)
.bind(value)
.bind(actor)
.execute(&mut *tx)
.await
.map_err(store_error)?;
tx.commit().await.map_err(store_error)?;
Ok(Some(StoredRuntimeConfig {
service: row.0,
key: row.1,
value: row.2,
updated_at: row.3,
updated_by: row.4,
}))
}
pub async fn delete_value(
pool: &DbPool,
service: &str,
key: &str,
actor: Option<&str>,
) -> AppResult<bool> {
let mut tx = pool.begin().await.map_err(store_error)?;
let old_value = sqlx::query_scalar::<_, Value>(
"delete from config.setting_values where service = $1 and key = $2 returning value",
)
.bind(service)
.bind(key)
.fetch_optional(&mut *tx)
.await
.map_err(store_error)?;
let deleted = old_value.is_some();
if let Some(old) = old_value {
sqlx::query(
r#"
insert into config.setting_audit (id, service, key, old_value, new_value, actor, changed_at)
values ($1, $2, $3, $4, 'null'::jsonb, $5, now())
"#,
)
.bind(Uuid::now_v7())
.bind(service)
.bind(key)
.bind(&old)
.bind(actor)
.execute(&mut *tx)
.await
.map_err(store_error)?;
}
tx.commit().await.map_err(store_error)?;
Ok(deleted)
}
pub async fn load_audit(
pool: &DbPool,
service: &str,
key: &str,
limit: i64,
) -> AppResult<Vec<RuntimeConfigAuditEntry>> {
let rows = sqlx::query_as::<
_,
(
String,
String,
Option<Value>,
Value,
Option<String>,
DateTime<Utc>,
),
>(
r#"
select service, key, old_value, new_value, actor, changed_at
from config.setting_audit
where service = $1 and key = $2
order by changed_at desc
limit $3
"#,
)
.bind(service)
.bind(key)
.bind(limit)
.fetch_all(pool)
.await
.map_err(store_error)?;
Ok(rows
.into_iter()
.map(
|(service, key, old_value, new_value, actor, changed_at)| RuntimeConfigAuditEntry {
service,
key,
old_value,
new_value,
actor,
changed_at,
},
)
.collect())
}
fn store_error(source: sqlx::Error) -> AppError {
AppError::new(ErrorCode::Internal, "settings store query failed").with_source(source)
}