feat(admin): audit-log viewer
Surface the admin_audit table (written by every mutating admin action but previously unreadable) as a new /admin/audit tab. New repo::admin_audit::list (LEFT JOIN users for the actor's username, filter by action/target_kind/actor/ since, at DESC via admin_audit_at_idx) behind GET /v1/admin/audit, mirroring the crawler history endpoint's paged/clamped idiom. Frontend: a self-contained AuditTable (target-kind + window + action filters, expandable JSON payload, AbortController-cancelled fetches, loading/error/empty states) and shared fmtAgo() in format.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,98 @@
|
||||
//! Admin-action audit log writes.
|
||||
//! 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.
|
||||
//! caller passes `&mut *tx` directly. [`list`] backs the audit-log viewer.
|
||||
|
||||
use sqlx::PgExecutor;
|
||||
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<Uuid>,
|
||||
pub actor_username: Option<String>,
|
||||
pub action: String,
|
||||
pub target_kind: String,
|
||||
pub target_id: Option<Uuid>,
|
||||
pub payload: serde_json::Value,
|
||||
pub at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 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<Uuid>,
|
||||
pub since: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 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<AuditEntryRow>, 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)
|
||||
ORDER BY a.at 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,
|
||||
|
||||
Reference in New Issue
Block a user