1use rusqlite::{Connection, params};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AuditLogEntry {
14 pub id: i64,
16 pub timestamp: String,
18 pub action: String,
20 pub tracking_id: String,
22 pub user_id: Option<i64>,
24 pub details: Option<String>,
26}
27
28pub const VALID_ACTIONS: &[&str] = &[
32 "create",
33 "update",
34 "delete",
35 "import",
36 "export",
37 "settings_reset",
38];
39
40pub fn record(
46 conn: &Connection,
47 action: &str,
48 tracking_id: &str,
49 user_id: Option<i64>,
50 details: Option<&str>,
51) -> Result<i64, rusqlite::Error> {
52 let timestamp = chrono::Utc::now()
53 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
54 .to_string();
55
56 conn.execute(
57 "INSERT INTO audit_log (timestamp, action, tracking_id, user_id, details) \
58 VALUES (?1, ?2, ?3, ?4, ?5)",
59 params![timestamp, action, tracking_id, user_id, details],
60 )?;
61
62 Ok(conn.last_insert_rowid())
63}
64
65pub fn list(
71 conn: &Connection,
72 tracking_id: Option<&str>,
73 limit: usize,
74 offset: usize,
75) -> Result<Vec<AuditLogEntry>, rusqlite::Error> {
76 let (sql, has_filter) = if tracking_id.is_some() {
77 (
78 "SELECT id, timestamp, action, tracking_id, user_id, details \
79 FROM audit_log WHERE tracking_id = ?1 \
80 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3",
81 true,
82 )
83 } else {
84 (
85 "SELECT id, timestamp, action, tracking_id, user_id, details \
86 FROM audit_log ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2",
87 false,
88 )
89 };
90
91 let mut stmt = conn.prepare(sql)?;
92
93 let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
97 let offset_i64 = i64::try_from(offset).unwrap_or(i64::MAX);
98 let rows = if has_filter {
99 stmt.query_map(
100 params![tracking_id.unwrap_or_default(), limit_i64, offset_i64],
101 map_row,
102 )?
103 } else {
104 stmt.query_map(params![limit_i64, offset_i64], map_row)?
105 };
106
107 rows.collect()
108}
109
110pub fn count(conn: &Connection, tracking_id: Option<&str>) -> Result<usize, rusqlite::Error> {
116 let to_usize = |n: i64| usize::try_from(n.max(0)).unwrap_or(usize::MAX);
121 tracking_id.map_or_else(
122 || {
123 conn.query_row("SELECT COUNT(*) FROM audit_log", [], |row| {
124 row.get::<_, i64>(0).map(to_usize)
125 })
126 },
127 |tid| {
128 conn.query_row(
129 "SELECT COUNT(*) FROM audit_log WHERE tracking_id = ?1",
130 params![tid],
131 |row| row.get::<_, i64>(0).map(to_usize),
132 )
133 },
134 )
135}
136
137fn map_row(row: &rusqlite::Row<'_>) -> Result<AuditLogEntry, rusqlite::Error> {
139 Ok(AuditLogEntry {
140 id: row.get("id")?,
141 timestamp: row.get("timestamp")?,
142 action: row.get("action")?,
143 tracking_id: row.get("tracking_id")?,
144 user_id: row.get("user_id")?,
145 details: row.get("details")?,
146 })
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use crate::db::DbPool;
153
154 #[test]
155 fn test_record_and_list() {
156 let pool = DbPool::open_in_memory().expect("DB open failed");
157 pool.with_conn(|conn| {
158 record(
159 conn,
160 "create",
161 "ndaal-sa-2026-001",
162 None,
163 Some(r#"{"source":"manual"}"#),
164 )?;
165 record(conn, "update", "ndaal-sa-2026-001", None, None)?;
166 record(conn, "delete", "ndaal-sa-2026-002", None, None)?;
167
168 let all = list(conn, None, 100, 0)?;
169 assert_eq!(all.len(), 3);
170
171 let filtered = list(conn, Some("ndaal-sa-2026-001"), 100, 0)?;
172 assert_eq!(filtered.len(), 2);
173
174 let total = count(conn, None)?;
175 assert_eq!(total, 3);
176
177 let filtered_count = count(conn, Some("ndaal-sa-2026-001"))?;
178 assert_eq!(filtered_count, 2);
179
180 Ok(())
181 })
182 .expect("DB operation failed");
183 }
184
185 #[test]
186 fn test_iso_8601_timestamp() {
187 let pool = DbPool::open_in_memory().expect("DB open failed");
188 pool.with_conn(|conn| {
189 record(conn, "create", "test-id", None, None)?;
190 let entries = list(conn, None, 1, 0)?;
191 let entry = &entries[0];
192
193 assert!(
195 entry.timestamp.contains('T'),
196 "Timestamp should contain 'T': {}",
197 entry.timestamp
198 );
199 assert!(
200 entry.timestamp.ends_with('Z'),
201 "Timestamp should end with 'Z': {}",
202 entry.timestamp
203 );
204
205 Ok(())
206 })
207 .expect("DB operation failed");
208 }
209}