postcrate_core/db/
audit.rs1use chrono::Utc;
4use serde::Serialize;
5use sqlx::{Row, SqlitePool};
6
7use crate::error::Result;
8
9#[derive(Debug, Clone, Serialize)]
10#[cfg_attr(feature = "specta", derive(specta::Type))]
11#[serde(rename_all = "camelCase")]
12pub struct AuditEntry {
13 pub id: i64,
14 pub at: i64,
15 pub actor: String,
16 pub action: String,
17 pub target_kind: Option<String>,
18 pub target_id: Option<String>,
19 pub metadata: Option<serde_json::Value>,
20}
21
22#[derive(Debug, Clone)]
23pub(crate) struct AuditAppend {
24 pub actor: String,
25 pub action: String,
26 pub target_kind: Option<String>,
27 pub target_id: Option<String>,
28 pub metadata: Option<serde_json::Value>,
29}
30
31pub(crate) async fn append(pool: &SqlitePool, entry: AuditAppend) -> Result<AuditEntry> {
32 let now = Utc::now().timestamp_millis();
33 let metadata_json = entry
34 .metadata
35 .as_ref()
36 .map(|v| serde_json::to_string(v).unwrap_or_else(|_| "null".into()));
37
38 let res = sqlx::query(
39 r"INSERT INTO audit_log (at, actor, action, target_kind, target_id, metadata_json)
40 VALUES (?, ?, ?, ?, ?, ?)",
41 )
42 .bind(now)
43 .bind(&entry.actor)
44 .bind(&entry.action)
45 .bind(&entry.target_kind)
46 .bind(&entry.target_id)
47 .bind(&metadata_json)
48 .execute(pool)
49 .await?;
50
51 Ok(AuditEntry {
52 id: res.last_insert_rowid(),
53 at: now,
54 actor: entry.actor,
55 action: entry.action,
56 target_kind: entry.target_kind,
57 target_id: entry.target_id,
58 metadata: entry.metadata,
59 })
60}
61
62pub(crate) async fn list(pool: &SqlitePool, limit: u32, offset: u32) -> Result<Vec<AuditEntry>> {
63 let rows = sqlx::query(
64 r"SELECT id, at, actor, action, target_kind, target_id, metadata_json
65 FROM audit_log
66 ORDER BY at DESC
67 LIMIT ? OFFSET ?",
68 )
69 .bind(i64::from(limit))
70 .bind(i64::from(offset))
71 .fetch_all(pool)
72 .await?;
73 Ok(rows.iter().map(row_to_entry).collect())
74}
75
76pub(crate) async fn prune_older_than(pool: &SqlitePool, days: u32) -> Result<u64> {
77 let cutoff = Utc::now().timestamp_millis() - (i64::from(days) * 86_400_000);
78 let res = sqlx::query("DELETE FROM audit_log WHERE at < ?")
79 .bind(cutoff)
80 .execute(pool)
81 .await?;
82 Ok(res.rows_affected())
83}
84
85pub(crate) async fn clear_all(pool: &SqlitePool) -> Result<u64> {
86 let res = sqlx::query("DELETE FROM audit_log").execute(pool).await?;
87 Ok(res.rows_affected())
88}
89
90fn row_to_entry(row: &sqlx::sqlite::SqliteRow) -> AuditEntry {
91 let metadata_json: Option<String> = row.try_get("metadata_json").ok();
92 let metadata = metadata_json
93 .as_deref()
94 .and_then(|s| serde_json::from_str(s).ok());
95 AuditEntry {
96 id: row.try_get("id").unwrap_or(0),
97 at: row.try_get("at").unwrap_or(0),
98 actor: row.try_get("actor").unwrap_or_default(),
99 action: row.try_get("action").unwrap_or_default(),
100 target_kind: row.try_get("target_kind").ok(),
101 target_id: row.try_get("target_id").ok(),
102 metadata,
103 }
104}