use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditLogEntry {
pub id: i64,
pub timestamp: String,
pub action: String,
pub tracking_id: String,
pub user_id: Option<i64>,
pub details: Option<String>,
}
pub const VALID_ACTIONS: &[&str] = &[
"create",
"update",
"delete",
"import",
"export",
"settings_reset",
];
pub fn record(
conn: &Connection,
action: &str,
tracking_id: &str,
user_id: Option<i64>,
details: Option<&str>,
) -> Result<i64, rusqlite::Error> {
let timestamp = chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
conn.execute(
"INSERT INTO audit_log (timestamp, action, tracking_id, user_id, details) \
VALUES (?1, ?2, ?3, ?4, ?5)",
params![timestamp, action, tracking_id, user_id, details],
)?;
Ok(conn.last_insert_rowid())
}
pub fn list(
conn: &Connection,
tracking_id: Option<&str>,
limit: usize,
offset: usize,
) -> Result<Vec<AuditLogEntry>, rusqlite::Error> {
let (sql, has_filter) = if tracking_id.is_some() {
(
"SELECT id, timestamp, action, tracking_id, user_id, details \
FROM audit_log WHERE tracking_id = ?1 \
ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3",
true,
)
} else {
(
"SELECT id, timestamp, action, tracking_id, user_id, details \
FROM audit_log ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2",
false,
)
};
let mut stmt = conn.prepare(sql)?;
let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
let offset_i64 = i64::try_from(offset).unwrap_or(i64::MAX);
let rows = if has_filter {
stmt.query_map(
params![tracking_id.unwrap_or_default(), limit_i64, offset_i64],
map_row,
)?
} else {
stmt.query_map(params![limit_i64, offset_i64], map_row)?
};
rows.collect()
}
pub fn count(conn: &Connection, tracking_id: Option<&str>) -> Result<usize, rusqlite::Error> {
let to_usize = |n: i64| usize::try_from(n.max(0)).unwrap_or(usize::MAX);
tracking_id.map_or_else(
|| {
conn.query_row("SELECT COUNT(*) FROM audit_log", [], |row| {
row.get::<_, i64>(0).map(to_usize)
})
},
|tid| {
conn.query_row(
"SELECT COUNT(*) FROM audit_log WHERE tracking_id = ?1",
params![tid],
|row| row.get::<_, i64>(0).map(to_usize),
)
},
)
}
fn map_row(row: &rusqlite::Row<'_>) -> Result<AuditLogEntry, rusqlite::Error> {
Ok(AuditLogEntry {
id: row.get("id")?,
timestamp: row.get("timestamp")?,
action: row.get("action")?,
tracking_id: row.get("tracking_id")?,
user_id: row.get("user_id")?,
details: row.get("details")?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::DbPool;
#[test]
fn test_record_and_list() {
let pool = DbPool::open_in_memory().expect("DB open failed");
pool.with_conn(|conn| {
record(
conn,
"create",
"ndaal-sa-2026-001",
None,
Some(r#"{"source":"manual"}"#),
)?;
record(conn, "update", "ndaal-sa-2026-001", None, None)?;
record(conn, "delete", "ndaal-sa-2026-002", None, None)?;
let all = list(conn, None, 100, 0)?;
assert_eq!(all.len(), 3);
let filtered = list(conn, Some("ndaal-sa-2026-001"), 100, 0)?;
assert_eq!(filtered.len(), 2);
let total = count(conn, None)?;
assert_eq!(total, 3);
let filtered_count = count(conn, Some("ndaal-sa-2026-001"))?;
assert_eq!(filtered_count, 2);
Ok(())
})
.expect("DB operation failed");
}
#[test]
fn test_iso_8601_timestamp() {
let pool = DbPool::open_in_memory().expect("DB open failed");
pool.with_conn(|conn| {
record(conn, "create", "test-id", None, None)?;
let entries = list(conn, None, 1, 0)?;
let entry = &entries[0];
assert!(
entry.timestamp.contains('T'),
"Timestamp should contain 'T': {}",
entry.timestamp
);
assert!(
entry.timestamp.ends_with('Z'),
"Timestamp should end with 'Z': {}",
entry.timestamp
);
Ok(())
})
.expect("DB operation failed");
}
}