Three medium correctness fixes: 1. **Pagination ORDER BY missing tiebreakers** — four admin list queries sorted by a timestamp or `chapters.number` with no stable secondary sort. Add `id` tiebreakers across `repo::admin_view`, `repo::admin_audit`, `repo::bookmark`. 2. **`enqueue_pages` NOT EXISTS read-then-insert race** — concurrent admin clicks could land duplicate analyze_page jobs. Migration 0031 adds a partial unique index mirroring 0014; query relies on `ON CONFLICT DO NOTHING`. 3. **`record_duration` overwrote a prior done row's duration on a non-terminal failed retry of a force-re-analyze.** Gate the call on "did this attempt actually write a row?". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
121 lines
3.6 KiB
Rust
121 lines
3.6 KiB
Rust
//! 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<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)
|
|
-- `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<Uuid>,
|
|
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(())
|
|
}
|