Skip to main content

sc/storage/
events.rs

1//! Audit event storage and retrieval.
2//!
3//! Events track all mutations in the database for debugging and history.
4
5use rusqlite::{Connection, Result};
6
7/// Event types for audit logging.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum EventType {
10    // Session events
11    SessionCreated,
12    SessionUpdated,
13    SessionPaused,
14    SessionCompleted,
15    SessionDeleted,
16    SessionPathAdded,
17    SessionPathRemoved,
18
19    // Context item events
20    ItemCreated,
21    ItemUpdated,
22    ItemDeleted,
23
24    // Issue events
25    IssueCreated,
26    IssueUpdated,
27    IssueClosed,
28    IssueClaimed,
29    IssueReleased,
30    IssueDeleted,
31
32    // Checkpoint events
33    CheckpointCreated,
34    CheckpointRestored,
35    CheckpointDeleted,
36
37    // Plan events
38    PlanCreated,
39    PlanUpdated,
40    PlanCompleted,
41
42    // Time entry events
43    TimeEntryCreated,
44    TimeEntryUpdated,
45    TimeEntryDeleted,
46    TimeEntryStatusChanged,
47
48    // Memory events
49    MemorySaved,
50    MemoryDeleted,
51
52    // Project events
53    ProjectCreated,
54    ProjectUpdated,
55    ProjectDeleted,
56}
57
58impl EventType {
59    /// Get the string representation for storage.
60    #[must_use]
61    pub const fn as_str(&self) -> &'static str {
62        match self {
63            Self::SessionCreated => "session_created",
64            Self::SessionUpdated => "session_updated",
65            Self::SessionPaused => "session_paused",
66            Self::SessionCompleted => "session_completed",
67            Self::SessionDeleted => "session_deleted",
68            Self::SessionPathAdded => "session_path_added",
69            Self::SessionPathRemoved => "session_path_removed",
70            Self::ItemCreated => "item_created",
71            Self::ItemUpdated => "item_updated",
72            Self::ItemDeleted => "item_deleted",
73            Self::IssueCreated => "issue_created",
74            Self::IssueUpdated => "issue_updated",
75            Self::IssueClosed => "issue_closed",
76            Self::IssueClaimed => "issue_claimed",
77            Self::IssueReleased => "issue_released",
78            Self::IssueDeleted => "issue_deleted",
79            Self::CheckpointCreated => "checkpoint_created",
80            Self::CheckpointRestored => "checkpoint_restored",
81            Self::CheckpointDeleted => "checkpoint_deleted",
82            Self::PlanCreated => "plan_created",
83            Self::PlanUpdated => "plan_updated",
84            Self::PlanCompleted => "plan_completed",
85            Self::TimeEntryCreated => "time_entry_created",
86            Self::TimeEntryUpdated => "time_entry_updated",
87            Self::TimeEntryDeleted => "time_entry_deleted",
88            Self::TimeEntryStatusChanged => "time_entry_status_changed",
89            Self::MemorySaved => "memory_saved",
90            Self::MemoryDeleted => "memory_deleted",
91            Self::ProjectCreated => "project_created",
92            Self::ProjectUpdated => "project_updated",
93            Self::ProjectDeleted => "project_deleted",
94        }
95    }
96}
97
98/// An audit event record.
99#[derive(Debug, Clone)]
100pub struct Event {
101    pub id: i64,
102    pub entity_type: String,
103    pub entity_id: String,
104    pub event_type: EventType,
105    pub actor: String,
106    pub old_value: Option<String>,
107    pub new_value: Option<String>,
108    pub comment: Option<String>,
109    pub created_at: i64,
110}
111
112impl Event {
113    /// Create a new event (id will be assigned by database).
114    #[must_use]
115    pub fn new(
116        entity_type: &str,
117        entity_id: &str,
118        event_type: EventType,
119        actor: &str,
120    ) -> Self {
121        Self {
122            id: 0,
123            entity_type: entity_type.to_string(),
124            entity_id: entity_id.to_string(),
125            event_type,
126            actor: actor.to_string(),
127            old_value: None,
128            new_value: None,
129            comment: None,
130            created_at: chrono::Utc::now().timestamp_millis(),
131        }
132    }
133
134    /// Add old/new values for field change tracking.
135    #[must_use]
136    pub fn with_values(mut self, old: Option<String>, new: Option<String>) -> Self {
137        self.old_value = old;
138        self.new_value = new;
139        self
140    }
141
142    /// Add a comment to the event.
143    #[must_use]
144    pub fn with_comment(mut self, comment: &str) -> Self {
145        self.comment = Some(comment.to_string());
146        self
147    }
148}
149
150/// Insert an event into the database.
151///
152/// # Errors
153///
154/// Returns an error if the insert fails.
155pub fn insert_event(conn: &Connection, event: &Event) -> Result<i64> {
156    conn.execute(
157        "INSERT INTO events (entity_type, entity_id, event_type, actor, old_value, new_value, comment, created_at)
158         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
159        rusqlite::params![
160            event.entity_type,
161            event.entity_id,
162            event.event_type.as_str(),
163            event.actor,
164            event.old_value,
165            event.new_value,
166            event.comment,
167            event.created_at,
168        ],
169    )?;
170    Ok(conn.last_insert_rowid())
171}
172
173/// Get events for an entity.
174///
175/// # Errors
176///
177/// Returns an error if the query fails.
178pub fn get_events(
179    conn: &Connection,
180    entity_type: &str,
181    entity_id: &str,
182    limit: Option<u32>,
183) -> Result<Vec<Event>> {
184    let limit = limit.unwrap_or(100);
185    let mut stmt = conn.prepare(
186        "SELECT id, entity_type, entity_id, event_type, actor, old_value, new_value, comment, created_at
187         FROM events
188         WHERE entity_type = ?1 AND entity_id = ?2
189         ORDER BY created_at DESC
190         LIMIT ?3",
191    )?;
192
193    let rows = stmt.query_map(rusqlite::params![entity_type, entity_id, limit], |row| {
194        Ok(Event {
195            id: row.get(0)?,
196            entity_type: row.get(1)?,
197            entity_id: row.get(2)?,
198            event_type: parse_event_type(row.get::<_, String>(3)?.as_str()),
199            actor: row.get(4)?,
200            old_value: row.get(5)?,
201            new_value: row.get(6)?,
202            comment: row.get(7)?,
203            created_at: row.get(8)?,
204        })
205    })?;
206
207    rows.collect()
208}
209
210fn parse_event_type(s: &str) -> EventType {
211    match s {
212        "session_created" => EventType::SessionCreated,
213        "session_updated" => EventType::SessionUpdated,
214        "session_paused" => EventType::SessionPaused,
215        "session_completed" => EventType::SessionCompleted,
216        "session_deleted" => EventType::SessionDeleted,
217        "session_path_added" => EventType::SessionPathAdded,
218        "session_path_removed" => EventType::SessionPathRemoved,
219        "item_created" => EventType::ItemCreated,
220        "item_updated" => EventType::ItemUpdated,
221        "item_deleted" => EventType::ItemDeleted,
222        "issue_created" => EventType::IssueCreated,
223        "issue_updated" => EventType::IssueUpdated,
224        "issue_closed" => EventType::IssueClosed,
225        "issue_claimed" => EventType::IssueClaimed,
226        "issue_released" => EventType::IssueReleased,
227        "issue_deleted" => EventType::IssueDeleted,
228        "checkpoint_created" => EventType::CheckpointCreated,
229        "checkpoint_restored" => EventType::CheckpointRestored,
230        "checkpoint_deleted" => EventType::CheckpointDeleted,
231        "plan_created" => EventType::PlanCreated,
232        "plan_updated" => EventType::PlanUpdated,
233        "plan_completed" => EventType::PlanCompleted,
234        "time_entry_created" => EventType::TimeEntryCreated,
235        "time_entry_updated" => EventType::TimeEntryUpdated,
236        "time_entry_deleted" => EventType::TimeEntryDeleted,
237        "time_entry_status_changed" => EventType::TimeEntryStatusChanged,
238        "memory_saved" => EventType::MemorySaved,
239        "memory_deleted" => EventType::MemoryDeleted,
240        "project_created" => EventType::ProjectCreated,
241        "project_updated" => EventType::ProjectUpdated,
242        "project_deleted" => EventType::ProjectDeleted,
243        _ => EventType::SessionUpdated, // Fallback
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::storage::schema::apply_schema;
251
252    #[test]
253    fn test_event_insert_and_get() {
254        let conn = Connection::open_in_memory().unwrap();
255        apply_schema(&conn).unwrap();
256
257        let event = Event::new("session", "sess_123", EventType::SessionCreated, "test-actor")
258            .with_comment("Test session created");
259
260        let id = insert_event(&conn, &event).unwrap();
261        assert!(id > 0);
262
263        let events = get_events(&conn, "session", "sess_123", Some(10)).unwrap();
264        assert_eq!(events.len(), 1);
265        assert_eq!(events[0].actor, "test-actor");
266        assert_eq!(events[0].comment, Some("Test session created".to_string()));
267    }
268}