use chrono::{DateTime, Utc}; use sqlx::PgPool; use uuid::Uuid; use serde_json::json; /// Minimal audit logger - records version snapshots on mutation #[derive(Clone)] pub struct AuditLogger { pool: PgPool, } impl AuditLogger { pub fn new(pool: PgPool) -> Self { Self { pool } } /// Log entity mutation pub async fn log_entity( &self, entity_id: &str, version: i32, operation: &str, // 'create' | 'update' | 'delete' snapshot: &serde_json::Value, changed_by: &str, // JWT sub claim fields_changed: &[String], ) -> Result<(), sqlx::Error> { sqlx::query!( r#" INSERT INTO memory_entity_version (entity_id, version_num, operation, snapshot, changed_by, fields_changed) VALUES ($1, $2, $3, $4, $5, $6) "#, entity_id, version, operation, snapshot, changed_by, fields_changed, ) .execute(&self.pool) .await?; Ok(()) } /// Log edge mutation pub async fn log_edge( &self, edge_id: Uuid, version: i32, operation: &str, snapshot: &serde_json::Value, changed_by: &str, fields_changed: &[String], ) -> Result<(), sqlx::Error> { sqlx::query!( r#" INSERT INTO memory_edge_version (edge_id, version_num, operation, snapshot, changed_by, fields_changed) VALUES ($1, $2, $3, $4, $5, $6) "#, edge_id, version, operation, snapshot, changed_by, fields_changed, ) .execute(&self.pool) .await?; Ok(()) } /// Get entity audit history pub async fn get_entity_history( &self, entity_id: &str, ) -> Result, sqlx::Error> { sqlx::query_as!( AuditEntry, r#" SELECT id, entity_id, version_num, operation, snapshot, changed_at, changed_by, COALESCE(fields_changed, '{}') as "fields_changed!" FROM memory_entity_version WHERE entity_id = $1 ORDER BY version_num DESC "#, entity_id ) .fetch_all(&self.pool) .await } /// Get edge audit history pub async fn get_edge_history( &self, edge_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( AuditEntry, r#" SELECT id, edge_id as entity_id, version_num, operation, snapshot, changed_at, changed_by, COALESCE(fields_changed, '{}') as "fields_changed!" FROM memory_edge_version WHERE edge_id = $1 ORDER BY version_num DESC "#, edge_id ) .fetch_all(&self.pool) .await } } #[derive(Debug, Clone, sqlx::FromRow)] pub struct AuditEntry { pub id: Uuid, pub entity_id: Option, // or edge_id pub version_num: i32, pub operation: String, pub snapshot: serde_json::Value, pub changed_at: DateTime, pub changed_by: String, pub fields_changed: Vec, } /// Helper: Compare two snapshots to find changed fields pub fn diff_fields(old: &serde_json::Value, new: &serde_json::Value) -> Vec { let mut changed = Vec::new(); let old_obj = old.as_object(); let new_obj = new.as_object(); if let (Some(old_map), Some(new_map)) = (old_obj, new_obj) { // Check for modified fields for (key, old_val) in old_map { if let Some(new_val) = new_map.get(key) { if old_val != new_val { changed.push(key.clone()); } } else { changed.push(format!("{}(removed)", key)); } } // Check for added fields for key in new_map.keys() { if !old_map.contains_key(key) { changed.push(format!("{}(added)", key)); } } } else if old != new { changed.push("*".to_string()); // Entire structure changed } changed } /// Interceptor wrapper for entity repo - auto-logs mutations pub struct AuditedEntityRepo { // Will wrap the actual repo and intercept mutations // This is a design pattern - actual implementation depends on repo trait } #[cfg(test)] mod tests { use super::*; #[test] fn test_diff_fields_modified() { let old = json!({"name": "old", "value": 42}); let new = json!({"name": "new", "value": 42}); let changed = diff_fields(&old, &new); assert!(changed.contains(&"name".to_string())); assert!(!changed.contains(&"value".to_string())); } #[test] fn test_diff_fields_added_removed() { let old = json!({"name": "test", "old_field": "gone"}); let new = json!({"name": "test", "new_field": "added"}); let changed = diff_fields(&old, &new); assert!(changed.iter().any(|f| f.contains("old_field"))); assert!(changed.iter().any(|f| f.contains("new_field"))); } #[test] fn test_diff_fields_no_change() { let snapshot = json!({"name": "test", "value": 42}); let changed = diff_fields(&snapshot, &snapshot); assert!(changed.is_empty()); } }