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] = &["create", "update", "delete", "import", "export"];
30
31pub fn record(
37 conn: &Connection,
38 action: &str,
39 tracking_id: &str,
40 user_id: Option<i64>,
41 details: Option<&str>,
42) -> Result<i64, rusqlite::Error> {
43 let timestamp = chrono::Utc::now()
44 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
45 .to_string();
46
47 conn.execute(
48 "INSERT INTO audit_log (timestamp, action, tracking_id, user_id, details) \
49 VALUES (?1, ?2, ?3, ?4, ?5)",
50 params![timestamp, action, tracking_id, user_id, details],
51 )?;
52
53 Ok(conn.last_insert_rowid())
54}
55
56pub fn list(
62 conn: &Connection,
63 tracking_id: Option<&str>,
64 limit: usize,
65 offset: usize,
66) -> Result<Vec<AuditLogEntry>, rusqlite::Error> {
67 let (sql, has_filter) = if tracking_id.is_some() {
68 (
69 "SELECT id, timestamp, action, tracking_id, user_id, details \
70 FROM audit_log WHERE tracking_id = ?1 \
71 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3",
72 true,
73 )
74 } else {
75 (
76 "SELECT id, timestamp, action, tracking_id, user_id, details \
77 FROM audit_log ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2",
78 false,
79 )
80 };
81
82 let mut stmt = conn.prepare(sql)?;
83
84 let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
88 let offset_i64 = i64::try_from(offset).unwrap_or(i64::MAX);
89 let rows = if has_filter {
90 stmt.query_map(
91 params![tracking_id.unwrap_or_default(), limit_i64, offset_i64],
92 map_row,
93 )?
94 } else {
95 stmt.query_map(params![limit_i64, offset_i64], map_row)?
96 };
97
98 rows.collect()
99}
100
101pub fn count(conn: &Connection, tracking_id: Option<&str>) -> Result<usize, rusqlite::Error> {
107 let to_usize = |n: i64| {
112 usize::try_from(n.max(0)).unwrap_or(usize::MAX)
113 };
114 tracking_id.map_or_else(
115 || {
116 conn.query_row("SELECT COUNT(*) FROM audit_log", [], |row| {
117 row.get::<_, i64>(0).map(to_usize)
118 })
119 },
120 |tid| {
121 conn.query_row(
122 "SELECT COUNT(*) FROM audit_log WHERE tracking_id = ?1",
123 params![tid],
124 |row| row.get::<_, i64>(0).map(to_usize),
125 )
126 },
127 )
128}
129
130fn map_row(row: &rusqlite::Row<'_>) -> Result<AuditLogEntry, rusqlite::Error> {
132 Ok(AuditLogEntry {
133 id: row.get("id")?,
134 timestamp: row.get("timestamp")?,
135 action: row.get("action")?,
136 tracking_id: row.get("tracking_id")?,
137 user_id: row.get("user_id")?,
138 details: row.get("details")?,
139 })
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use crate::db::DbPool;
146
147 #[test]
148 fn test_record_and_list() {
149 let pool = DbPool::open_in_memory().expect("DB open failed");
150 pool.with_conn(|conn| {
151 record(
152 conn,
153 "create",
154 "ndaal-sa-2026-001",
155 None,
156 Some(r#"{"source":"manual"}"#),
157 )?;
158 record(conn, "update", "ndaal-sa-2026-001", None, None)?;
159 record(conn, "delete", "ndaal-sa-2026-002", None, None)?;
160
161 let all = list(conn, None, 100, 0)?;
162 assert_eq!(all.len(), 3);
163
164 let filtered = list(conn, Some("ndaal-sa-2026-001"), 100, 0)?;
165 assert_eq!(filtered.len(), 2);
166
167 let total = count(conn, None)?;
168 assert_eq!(total, 3);
169
170 let filtered_count = count(conn, Some("ndaal-sa-2026-001"))?;
171 assert_eq!(filtered_count, 2);
172
173 Ok(())
174 })
175 .expect("DB operation failed");
176 }
177
178 #[test]
179 fn test_iso_8601_timestamp() {
180 let pool = DbPool::open_in_memory().expect("DB open failed");
181 pool.with_conn(|conn| {
182 record(conn, "create", "test-id", None, None)?;
183 let entries = list(conn, None, 1, 0)?;
184 let entry = &entries[0];
185
186 assert!(
188 entry.timestamp.contains('T'),
189 "Timestamp should contain 'T': {}",
190 entry.timestamp
191 );
192 assert!(
193 entry.timestamp.ends_with('Z'),
194 "Timestamp should end with 'Z': {}",
195 entry.timestamp
196 );
197
198 Ok(())
199 })
200 .expect("DB operation failed");
201 }
202}