use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use umbral::orm::Model;
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, Model)]
#[umbral(display = "User preference", icon = "settings-2")]
pub struct AdminUserPref {
pub id: i64,
#[umbral(noedit)]
pub user_id: i64,
#[umbral(noedit)]
pub theme: String,
#[umbral(noedit)]
pub density: String,
#[umbral(noedit)]
pub sidebar_collapsed: bool,
#[umbral(noedit)]
pub dashboard_layout: String,
#[umbral(noedit, widget = "code")]
pub preferences: Option<String>,
#[umbral(noedit)]
pub updated_at: DateTime<Utc>,
}
impl AdminUserPref {
pub fn default_for(user_id: i64) -> Self {
Self {
id: 0,
user_id,
theme: "dark".to_string(),
density: "comfortable".to_string(),
sidebar_collapsed: false,
dashboard_layout: "[]".to_string(),
preferences: None,
updated_at: Utc::now(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TablePref {
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub filters: std::collections::HashMap<String, String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub search: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub sort: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub per_page: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hidden_cols: Vec<String>,
}
pub async fn get_table_pref(user_id: i64, table: &str) -> Result<Option<TablePref>, sqlx::Error> {
let prefs = fetch_or_default(user_id).await?;
let Some(raw) = prefs.preferences.as_deref() else {
return Ok(None);
};
let Ok(root) = serde_json::from_str::<serde_json::Value>(raw) else {
return Ok(None);
};
let Some(table_obj) = root.get("tables").and_then(|t| t.get(table)) else {
return Ok(None);
};
let Ok(pref) = serde_json::from_value::<TablePref>(table_obj.clone()) else {
return Ok(None);
};
Ok(Some(pref))
}
pub async fn set_table_pref(
user_id: i64,
table: &str,
pref: &TablePref,
) -> Result<(), sqlx::Error> {
let existing = fetch_or_default(user_id).await?;
let mut root: serde_json::Value = existing
.preferences
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_else(|| serde_json::json!({}));
let pref_value = serde_json::to_value(pref).unwrap_or(serde_json::Value::Null);
root.as_object_mut()
.expect("root is always an object")
.entry("tables")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.expect("tables is always an object")
.insert(table.to_string(), pref_value);
let mut next = existing;
next.preferences = Some(root.to_string());
upsert(next).await?;
Ok(())
}
pub async fn get_last_path(user_id: i64) -> Result<Option<String>, sqlx::Error> {
let prefs = fetch_or_default(user_id).await?;
let Some(raw) = prefs.preferences.as_deref() else {
return Ok(None);
};
let Ok(root) = serde_json::from_str::<serde_json::Value>(raw) else {
return Ok(None);
};
Ok(root
.get("last_path")
.and_then(|v| v.as_str())
.map(|s| s.to_string()))
}
pub async fn set_last_path(user_id: i64, path: &str) -> Result<(), sqlx::Error> {
let existing = fetch_or_default(user_id).await?;
let mut root: serde_json::Value = existing
.preferences
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_else(|| serde_json::json!({}));
root.as_object_mut()
.expect("root is always an object")
.insert(
"last_path".to_string(),
serde_json::Value::String(path.to_string()),
);
let mut next = existing;
next.preferences = Some(root.to_string());
upsert(next).await?;
Ok(())
}
pub async fn get_widget_period(
user_id: i64,
widget_key: &str,
) -> Result<Option<String>, sqlx::Error> {
let prefs = fetch_or_default(user_id).await?;
let Some(raw) = prefs.preferences.as_deref() else {
return Ok(None);
};
let Ok(root) = serde_json::from_str::<serde_json::Value>(raw) else {
return Ok(None);
};
Ok(root
.get("dashboard")
.and_then(|d| d.get("widget_periods"))
.and_then(|p| p.get(widget_key))
.and_then(|v| v.as_str())
.map(|s| s.to_string()))
}
pub async fn set_widget_period(
user_id: i64,
widget_key: &str,
period: &str,
) -> Result<(), sqlx::Error> {
let existing = fetch_or_default(user_id).await?;
let mut root: serde_json::Value = existing
.preferences
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_else(|| serde_json::json!({}));
root.as_object_mut()
.expect("root is always an object")
.entry("dashboard")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.expect("dashboard is always an object")
.entry("widget_periods")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.expect("widget_periods is always an object")
.insert(
widget_key.to_string(),
serde_json::Value::String(period.to_string()),
);
let mut next = existing;
next.preferences = Some(root.to_string());
upsert(next).await?;
Ok(())
}
pub async fn toggle_table_col(
user_id: i64,
table: &str,
column: &str,
) -> Result<bool, sqlx::Error> {
let mut pref = get_table_pref(user_id, table).await?.unwrap_or_default();
let now_visible = if let Some(pos) = pref.hidden_cols.iter().position(|c| c == column) {
pref.hidden_cols.remove(pos);
true
} else {
pref.hidden_cols.push(column.to_string());
false
};
set_table_pref(user_id, table, &pref).await?;
Ok(now_visible)
}
pub async fn fetch_or_default(user_id: i64) -> Result<AdminUserPref, sqlx::Error> {
let existing = AdminUserPref::objects()
.filter(admin_user_pref::USER_ID.eq(user_id))
.first()
.await?;
Ok(existing.unwrap_or_else(|| AdminUserPref::default_for(user_id)))
}
pub async fn upsert(prefs: AdminUserPref) -> Result<AdminUserPref, sqlx::Error> {
let mut prefs = prefs;
prefs.updated_at = Utc::now();
AdminUserPref::objects()
.save(prefs)
.await
.map_err(|e| match e {
umbral::orm::SaveError::Write(umbral::orm::WriteError::Sqlx(e)) => e,
other => sqlx::Error::Protocol(other.to_string()),
})
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, Model)]
#[umbral(display = "Audit log", icon = "scroll-text")]
pub struct AdminAuditLog {
pub id: i64,
#[umbral(noedit)]
pub actor_user_id: i64,
#[umbral(noedit)]
pub action: String,
#[umbral(noedit)]
pub model: String,
#[umbral(noedit)]
pub object_id: Option<String>,
#[umbral(noedit)]
pub diff_summary: String,
#[umbral(noedit)]
pub created_at: DateTime<Utc>,
}
pub async fn log(
actor_user_id: i64,
action: &str,
model: &str,
object_id: Option<String>,
diff_summary: &str,
) {
let entry = AdminAuditLog {
id: 0,
actor_user_id,
action: action.to_string(),
model: model.to_string(),
object_id,
diff_summary: diff_summary.to_string(),
created_at: Utc::now(),
};
if let Err(e) = AdminAuditLog::objects().save(entry).await {
tracing::error!(error = %e, "admin: audit log insert failed");
}
}
pub async fn audit_for_object(
model: &str,
object_id: &str,
limit: u64,
) -> Result<Vec<AuditEntry>, sqlx::Error> {
let rows = AdminAuditLog::objects()
.filter(admin_audit_log::MODEL.eq(model.to_string()))
.filter(admin_audit_log::OBJECT_ID.eq(object_id.to_string()))
.order_by(admin_audit_log::CREATED_AT.desc())
.limit(limit)
.fetch()
.await?;
Ok(rows.into_iter().map(AuditEntry::from).collect())
}
#[derive(Debug, Clone, Serialize)]
pub struct AuditEntry {
pub id: i64,
pub actor_user_id: i64,
pub action: String,
pub model: String,
pub object_id: Option<String>,
pub diff_summary: String,
pub created_at: String,
}
impl From<AdminAuditLog> for AuditEntry {
fn from(row: AdminAuditLog) -> Self {
Self {
id: row.id,
actor_user_id: row.actor_user_id,
action: row.action,
model: row.model,
object_id: row.object_id,
diff_summary: row.diff_summary,
created_at: row.created_at.to_rfc3339(),
}
}
}
#[doc(hidden)]
pub async fn ensure_tables_for_tests(pool: &sqlx::SqlitePool) -> Result<(), sqlx::Error> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS admin_user_pref (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
theme TEXT NOT NULL DEFAULT 'dark',
density TEXT NOT NULL DEFAULT 'comfortable',
sidebar_collapsed INTEGER NOT NULL DEFAULT 0,
dashboard_layout TEXT NOT NULL DEFAULT '[]',
preferences TEXT,
updated_at TEXT NOT NULL
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS admin_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_user_id INTEGER NOT NULL,
action TEXT NOT NULL,
model TEXT NOT NULL,
object_id TEXT,
diff_summary TEXT NOT NULL,
created_at TEXT NOT NULL
)",
)
.execute(pool)
.await?;
Ok(())
}