//! Append-only audit log access. use domain::{AuditActor, AuditEntry, FieldChange, NewAuditEvent}; use sqlx::Row; use uuid::Uuid; /// Append an audit event. Accepts any executor, so callers can record the event /// inside the same transaction as the change it describes. pub async fn record<'e, E>(executor: E, event: &NewAuditEvent) -> Result<(), sqlx::Error> where E: sqlx::PgExecutor<'e>, { let (actor_kind, actor_id) = match event.actor { AuditActor::User(id) => ("user", Some(id)), AuditActor::System => ("system", None), }; sqlx::query( "INSERT INTO audit_log \ (actor_kind, actor_id, action, entity_type, entity_id, changes) \ VALUES ($1, $2, $3, $4, $5, $6)", ) .bind(actor_kind) .bind(actor_id) .bind(event.action.as_str()) .bind(&event.entity_type) .bind(event.entity_id) .bind(sqlx::types::Json(&event.changes)) .execute(executor) .await?; Ok(()) } /// Read the full history for one entity, oldest first. pub async fn history_for<'e, E>( executor: E, entity_type: &str, entity_id: Uuid, ) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { // TODO: add LIMIT/keyset pagination before exposing history_for via the API. let rows = sqlx::query( "SELECT seq, at, actor_kind, actor_id, action, entity_type, entity_id, changes \ FROM audit_log \ WHERE entity_type = $1 AND entity_id = $2 \ ORDER BY seq", ) .bind(entity_type) .bind(entity_id) .fetch_all(executor) .await?; rows.into_iter().map(map_row).collect() } fn map_row(row: sqlx::postgres::PgRow) -> Result { let seq: i64 = row.try_get("seq")?; let at: time::OffsetDateTime = row.try_get("at")?; let actor_kind: String = row.try_get("actor_kind")?; let actor_id: Option = row.try_get("actor_id")?; let action_str: String = row.try_get("action")?; let entity_type: String = row.try_get("entity_type")?; let entity_id: Uuid = row.try_get("entity_id")?; let changes: sqlx::types::Json> = row.try_get("changes")?; let actor = match actor_kind.as_str() { "user" => AuditActor::User( actor_id.ok_or_else(|| sqlx::Error::Decode("user actor missing actor_id".into()))?, ), "system" => AuditActor::System, other => { return Err(sqlx::Error::Decode( format!("unknown actor_kind: {other}").into(), )); } }; let action = domain::AuditAction::from_db(&action_str) .ok_or_else(|| sqlx::Error::Decode(format!("unknown action: {action_str}").into()))?; Ok(AuditEntry { seq, at, actor, action, entity_type, entity_id, changes: changes.0, }) }