//! Audit log for admin scan-pipeline actions. //! //! Every promote / quarantine / rescan from the `/admin/uploads` dashboard //! writes one row here. Bulk operations write one row per affected target //! (the `action` column distinguishes per-row from bulk). See //! `docs/scan-pipeline-audit.md` ยง 5.2 (Audit Trail). use chrono::{DateTime, Utc}; use sqlx::{FromRow, PgPool}; use uuid::Uuid; use super::{ItemId, UserId, VersionId}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AdminAction { Promote, Quarantine, Rescan, /// Reserved for the Phase 2b bulk-promote action; not yet wired into routes. #[allow(dead_code)] BulkPromote, BulkRescan, } impl AdminAction { pub fn as_str(&self) -> &'static str { match self { AdminAction::Promote => "promote", AdminAction::Quarantine => "quarantine", AdminAction::Rescan => "rescan", AdminAction::BulkPromote => "bulk_promote", AdminAction::BulkRescan => "bulk_rescan", } } } /// An audit-log row. Either `version_id` or `item_id` is populated. #[allow(dead_code)] #[derive(Debug, Clone, FromRow)] pub struct ScanAdminActionRow { pub id: Uuid, pub version_id: Option, pub item_id: Option, pub admin_id: UserId, pub action: String, pub prev_status: Option, pub new_status: Option, pub note: Option, pub created_at: DateTime, } /// Log an admin action against a version. #[tracing::instrument(skip_all, fields(%version_id, %admin_id, action = action.as_str()))] pub async fn log_version( db: &PgPool, version_id: VersionId, admin_id: UserId, action: AdminAction, prev_status: Option<&str>, new_status: Option<&str>, note: Option<&str>, ) -> Result<(), sqlx::Error> { sqlx::query( r#" INSERT INTO scan_admin_actions (version_id, admin_id, action, prev_status, new_status, note) VALUES ($1, $2, $3, $4, $5, $6) "#, ) .bind(*version_id.as_uuid()) .bind(admin_id) .bind(action.as_str()) .bind(prev_status) .bind(new_status) .bind(note) .execute(db) .await?; Ok(()) } /// Log an admin action against an item. #[tracing::instrument(skip_all, fields(%item_id, %admin_id, action = action.as_str()))] pub async fn log_item( db: &PgPool, item_id: ItemId, admin_id: UserId, action: AdminAction, prev_status: Option<&str>, new_status: Option<&str>, note: Option<&str>, ) -> Result<(), sqlx::Error> { sqlx::query( r#" INSERT INTO scan_admin_actions (item_id, admin_id, action, prev_status, new_status, note) VALUES ($1, $2, $3, $4, $5, $6) "#, ) .bind(*item_id.as_uuid()) .bind(admin_id) .bind(action.as_str()) .bind(prev_status) .bind(new_status) .bind(note) .execute(db) .await?; Ok(()) } /// Brief "last action" summary attached to a held row inline. #[derive(Debug, Clone, FromRow)] pub struct LastActionSummary { pub action: String, pub admin_username: String, pub created_at: DateTime, } /// Latest admin action recorded against each version_id in the given set. /// Returns a map keyed by version_id string. Empty input returns empty map. pub async fn latest_per_version( db: &PgPool, version_ids: &[Uuid], ) -> Result, sqlx::Error> { if version_ids.is_empty() { return Ok(std::collections::HashMap::new()); } let rows = sqlx::query( r#" SELECT DISTINCT ON (saa.version_id) saa.version_id, saa.action, saa.created_at, u.username AS admin_username FROM scan_admin_actions saa JOIN users u ON u.id = saa.admin_id WHERE saa.version_id = ANY($1) ORDER BY saa.version_id, saa.created_at DESC "#, ) .bind(version_ids) .fetch_all(db) .await?; use sqlx::Row; let mut out = std::collections::HashMap::with_capacity(rows.len()); for row in rows { let id: Uuid = row.try_get("version_id")?; out.insert(id, LastActionSummary { action: row.try_get("action")?, admin_username: row.try_get("admin_username")?, created_at: row.try_get("created_at")?, }); } Ok(out) } /// Latest admin action per item_id in the given set. pub async fn latest_per_item( db: &PgPool, item_ids: &[Uuid], ) -> Result, sqlx::Error> { if item_ids.is_empty() { return Ok(std::collections::HashMap::new()); } let rows = sqlx::query( r#" SELECT DISTINCT ON (saa.item_id) saa.item_id, saa.action, saa.created_at, u.username AS admin_username FROM scan_admin_actions saa JOIN users u ON u.id = saa.admin_id WHERE saa.item_id = ANY($1) ORDER BY saa.item_id, saa.created_at DESC "#, ) .bind(item_ids) .fetch_all(db) .await?; use sqlx::Row; let mut out = std::collections::HashMap::with_capacity(rows.len()); for row in rows { let id: Uuid = row.try_get("item_id")?; out.insert(id, LastActionSummary { action: row.try_get("action")?, admin_username: row.try_get("admin_username")?, created_at: row.try_get("created_at")?, }); } Ok(out) } /// Audit-log row joined with the actor's username, for the audit page. #[derive(Debug, Clone, FromRow)] pub struct AuditLogRow { pub version_id: Option, pub item_id: Option, pub admin_username: String, pub action: String, pub prev_status: Option, pub new_status: Option, pub note: Option, pub created_at: DateTime, } /// Recent audit entries joined with the admin's username, newest first. /// Phase 2b consumer for the audit-log page. Superseded by `list_filtered` /// for the live route; retained as a simpler no-filter accessor for tests + /// future read-only consumers. #[allow(dead_code)] pub async fn list_recent_with_admin(db: &PgPool, limit: i64) -> Result, sqlx::Error> { sqlx::query_as::<_, AuditLogRow>( r#" SELECT saa.version_id, saa.item_id, u.username AS admin_username, saa.action, saa.prev_status, saa.new_status, saa.note, saa.created_at FROM scan_admin_actions saa JOIN users u ON u.id = saa.admin_id ORDER BY saa.created_at DESC LIMIT $1 "#, ) .bind(limit) .fetch_all(db) .await } /// Filtered audit entries for the dashboard `/admin/uploads/audit` page. /// All filters are optional; a `None` for any field means no constraint on /// that column. Newest first, capped at `limit`. #[allow(clippy::too_many_arguments)] pub async fn list_filtered( db: &PgPool, action: Option<&str>, admin_username: Option<&str>, since_days: Option, limit: i64, ) -> Result, sqlx::Error> { sqlx::query_as::<_, AuditLogRow>( r#" SELECT saa.version_id, saa.item_id, u.username AS admin_username, saa.action, saa.prev_status, saa.new_status, saa.note, saa.created_at FROM scan_admin_actions saa JOIN users u ON u.id = saa.admin_id WHERE ($1::TEXT IS NULL OR saa.action = $1) AND ($2::TEXT IS NULL OR u.username = $2) AND ($3::BIGINT IS NULL OR saa.created_at > NOW() - ($3 || ' days')::interval) ORDER BY saa.created_at DESC LIMIT $4 "#, ) .bind(action) .bind(admin_username) .bind(since_days) .bind(limit) .fetch_all(db) .await } /// Recent audit entries for the full-log page. Phase 2 surface. #[allow(dead_code)] pub async fn list_recent(db: &PgPool, limit: i64) -> Result, sqlx::Error> { sqlx::query_as::<_, ScanAdminActionRow>( r#" SELECT id, version_id, item_id, admin_id, action, prev_status, new_status, note, created_at FROM scan_admin_actions ORDER BY created_at DESC LIMIT $1 "#, ) .bind(limit) .fetch_all(db) .await } #[cfg(test)] mod tests { use super::*; #[test] fn admin_action_as_str() { assert_eq!(AdminAction::Promote.as_str(), "promote"); assert_eq!(AdminAction::Quarantine.as_str(), "quarantine"); assert_eq!(AdminAction::Rescan.as_str(), "rescan"); assert_eq!(AdminAction::BulkPromote.as_str(), "bulk_promote"); assert_eq!(AdminAction::BulkRescan.as_str(), "bulk_rescan"); } }