use adminx_core::audit::{AuditEntry, Auditor};
use adminx_core::storage::{storage, FilterClause, FilterOp, QueryOptions, StorageError};
use async_trait::async_trait;
use serde_json::{Map, Value};
pub const TABLE: &str = "adminx_audit_versions";
pub struct StorageAuditor {
strict: bool,
}
impl StorageAuditor {
pub fn new(strict: bool) -> Self {
Self { strict }
}
fn row(entry: &AuditEntry) -> Map<String, Value> {
let mut row = Map::new();
row.insert("item_type".into(), Value::String(entry.item_type.clone()));
row.insert("item_id".into(), Value::String(entry.item_id.clone()));
row.insert(
"event".into(),
Value::String(entry.event.as_str().to_string()),
);
row.insert(
"whodunnit".into(),
entry
.whodunnit
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
);
row.insert(
"whodunnit_email".into(),
entry
.whodunnit_email
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
);
row.insert(
"changes".into(),
Value::String(
serde_json::to_string(&Value::Object(entry.changes.clone()))
.unwrap_or_else(|_| "{}".into()),
),
);
row.insert(
"created_at".into(),
Value::String(chrono::Utc::now().to_rfc3339()),
);
row
}
}
#[async_trait]
impl Auditor for StorageAuditor {
async fn record(&self, entry: AuditEntry) -> Result<(), StorageError> {
storage().create(TABLE, Self::row(&entry)).await?;
Ok(())
}
async fn history(
&self,
item_type: &str,
item_id: &str,
limit: u64,
) -> Result<Vec<Value>, StorageError> {
let opts = QueryOptions {
page: 1,
per_page: limit,
sort_by: Some("id".to_string()),
sort_desc: true,
filters: vec![
FilterClause {
field: "item_type".into(),
op: FilterOp::Eq,
value: item_type.to_string(),
},
FilterClause {
field: "item_id".into(),
op: FilterOp::Eq,
value: item_id.to_string(),
},
],
};
Ok(storage().list(TABLE, &opts).await?.rows)
}
fn strict(&self) -> bool {
self.strict
}
}
#[cfg(test)]
mod tests {
use super::*;
use adminx_core::audit::Event;
use adminx_core::request::{Claims, ReqCtx};
use serde_json::json;
fn entry() -> AuditEntry {
let ctx = ReqCtx::new().with_claims(Claims {
sub: "7".into(),
email: "admin@example.com".into(),
..Default::default()
});
let mut changes = Map::new();
changes.insert("title".into(), json!(["Old", "New"]));
AuditEntry::new(&ctx, "posts", "42", Event::Update, changes)
}
#[test]
fn row_carries_the_actor_and_the_target() {
let row = StorageAuditor::row(&entry());
assert_eq!(row["item_type"], json!("posts"));
assert_eq!(row["item_id"], json!("42"));
assert_eq!(row["event"], json!("update"));
assert_eq!(row["whodunnit"], json!("7"));
assert_eq!(row["whodunnit_email"], json!("admin@example.com"));
}
#[test]
fn changes_serialize_to_a_json_string() {
let row = StorageAuditor::row(&entry());
let text = row["changes"].as_str().expect("changes must be a string");
let parsed: Value = serde_json::from_str(text).expect("must be valid JSON");
assert_eq!(parsed["title"], json!(["Old", "New"]));
}
#[test]
fn an_anonymous_actor_is_null_not_empty() {
let ctx = ReqCtx::new();
let e = AuditEntry::new(&ctx, "posts", "1", Event::Create, Map::new());
let row = StorageAuditor::row(&e);
assert_eq!(row["whodunnit"], Value::Null);
assert_eq!(row["whodunnit_email"], Value::Null);
}
#[test]
fn created_at_is_rfc3339() {
let row = StorageAuditor::row(&entry());
let at = row["created_at"].as_str().unwrap();
assert!(
chrono::DateTime::parse_from_rfc3339(at).is_ok(),
"not RFC3339: {at}"
);
}
}