//! Admin-action audit log writes + the admin-facing read query. //! //! Insert is always called from inside the same transaction as the //! action it audits — the executor parameter is `PgExecutor` so the //! caller passes `&mut *tx` directly. [`list`] backs the audit-log viewer. use chrono::{DateTime, Utc}; use serde::Serialize; use sqlx::{FromRow, PgExecutor, PgPool}; use uuid::Uuid; use crate::error::AppResult; /// One audit row joined to the actor's current username (NULL when the /// actor account was deleted — `actor_user_id` is `ON DELETE SET NULL`). #[derive(Debug, Clone, Serialize, FromRow)] pub struct AuditEntryRow { pub id: Uuid, pub actor_user_id: Option, pub actor_username: Option, pub action: String, pub target_kind: String, pub target_id: Option, pub payload: serde_json::Value, pub at: DateTime, } /// Optional filters for [`list`]. `None`/absent widens the scope; an /// empty-string action/target_kind is treated as absent by the caller. #[derive(Debug, Default, Clone)] pub struct AuditFilter<'a> { pub action: Option<&'a str>, pub target_kind: Option<&'a str>, pub actor_user_id: Option, pub since: Option>, } /// Paginated, newest-first audit log. Returns the page slice plus the /// filtered total. Ordering by `at DESC` uses `admin_audit_at_idx`. pub async fn list( pool: &PgPool, filter: AuditFilter<'_>, limit: i64, offset: i64, ) -> AppResult<(Vec, i64)> { let items = sqlx::query_as::<_, AuditEntryRow>( r#" SELECT a.id, a.actor_user_id, u.username AS actor_username, a.action, a.target_kind, a.target_id, a.payload, a.at FROM admin_audit a LEFT JOIN users u ON u.id = a.actor_user_id WHERE ($1::text IS NULL OR a.action = $1) AND ($2::text IS NULL OR a.target_kind = $2) AND ($3::uuid IS NULL OR a.actor_user_id = $3) AND ($4::timestamptz IS NULL OR a.at >= $4) -- `id` tiebreaker for stable pagination: a single admin action -- can write multiple audit rows in the same statement_timestamp -- (e.g. bulk requeue) and ties on the timestamp would otherwise -- skip or repeat rows across pages. ORDER BY a.at DESC, a.id DESC LIMIT $5 OFFSET $6 "#, ) .bind(filter.action) .bind(filter.target_kind) .bind(filter.actor_user_id) .bind(filter.since) .bind(limit) .bind(offset) .fetch_all(pool) .await?; let (total,): (i64,) = sqlx::query_as( r#" SELECT COUNT(*) FROM admin_audit a WHERE ($1::text IS NULL OR a.action = $1) AND ($2::text IS NULL OR a.target_kind = $2) AND ($3::uuid IS NULL OR a.actor_user_id = $3) AND ($4::timestamptz IS NULL OR a.at >= $4) "#, ) .bind(filter.action) .bind(filter.target_kind) .bind(filter.actor_user_id) .bind(filter.since) .fetch_one(pool) .await?; Ok((items, total)) } pub async fn insert<'e, E: PgExecutor<'e>>( executor: E, actor_user_id: Uuid, action: &str, target_kind: &str, target_id: Option, payload: serde_json::Value, ) -> AppResult<()> { sqlx::query( "INSERT INTO admin_audit (actor_user_id, action, target_kind, target_id, payload) \ VALUES ($1, $2, $3, $4, $5)", ) .bind(actor_user_id) .bind(action) .bind(target_kind) .bind(target_id) .bind(payload) .execute(executor) .await?; Ok(()) }