1use crate::db::models::Event;
2use crate::error::{IntentError, Result};
3use chrono::Utc;
4use sqlx::{Row, SqlitePool};
5
6pub struct EventManager<'a> {
7 pool: &'a SqlitePool,
8}
9
10impl<'a> EventManager<'a> {
11 pub fn new(pool: &'a SqlitePool) -> Self {
12 Self { pool }
13 }
14
15 pub async fn add_event(
17 &self,
18 task_id: i64,
19 log_type: &str,
20 discussion_data: &str,
21 ) -> Result<Event> {
22 let task_exists: bool =
24 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM tasks WHERE id = ?)")
25 .bind(task_id)
26 .fetch_one(self.pool)
27 .await?;
28
29 if !task_exists {
30 return Err(IntentError::TaskNotFound(task_id));
31 }
32
33 let now = Utc::now();
34
35 let result = sqlx::query(
36 r#"
37 INSERT INTO events (task_id, log_type, discussion_data, timestamp)
38 VALUES (?, ?, ?, ?)
39 "#,
40 )
41 .bind(task_id)
42 .bind(log_type)
43 .bind(discussion_data)
44 .bind(now)
45 .execute(self.pool)
46 .await?;
47
48 let id = result.last_insert_rowid();
49
50 Ok(Event {
51 id,
52 task_id,
53 timestamp: now,
54 log_type: log_type.to_string(),
55 discussion_data: discussion_data.to_string(),
56 })
57 }
58
59 pub async fn list_events(
61 &self,
62 task_id: Option<i64>,
63 limit: Option<i64>,
64 log_type: Option<String>,
65 since: Option<String>,
66 ) -> Result<Vec<Event>> {
67 if let Some(tid) = task_id {
69 let task_exists: bool =
70 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM tasks WHERE id = ?)")
71 .bind(tid)
72 .fetch_one(self.pool)
73 .await?;
74
75 if !task_exists {
76 return Err(IntentError::TaskNotFound(tid));
77 }
78 }
79
80 let limit = limit.unwrap_or(50);
81
82 let since_timestamp = if let Some(duration_str) = since {
84 Some(Self::parse_duration(&duration_str)?)
85 } else {
86 None
87 };
88
89 let mut query = String::from(
91 "SELECT id, task_id, timestamp, log_type, discussion_data FROM events WHERE 1=1",
92 );
93 let mut conditions = Vec::new();
94
95 if task_id.is_some() {
96 conditions.push("task_id = ?");
97 }
98
99 if log_type.is_some() {
100 conditions.push("log_type = ?");
101 }
102
103 if since_timestamp.is_some() {
104 conditions.push("timestamp >= ?");
105 }
106
107 if !conditions.is_empty() {
108 query.push_str(" AND ");
109 query.push_str(&conditions.join(" AND "));
110 }
111
112 query.push_str(" ORDER BY timestamp DESC LIMIT ?");
113
114 let mut sql_query = sqlx::query_as::<_, Event>(&query);
116
117 if let Some(tid) = task_id {
118 sql_query = sql_query.bind(tid);
119 }
120
121 if let Some(ref typ) = log_type {
122 sql_query = sql_query.bind(typ);
123 }
124
125 if let Some(ts) = since_timestamp {
126 sql_query = sql_query.bind(ts);
127 }
128
129 sql_query = sql_query.bind(limit);
130
131 let events = sql_query.fetch_all(self.pool).await?;
132
133 Ok(events)
134 }
135
136 fn parse_duration(duration: &str) -> Result<chrono::DateTime<Utc>> {
138 let len = duration.len();
139 if len < 2 {
140 return Err(IntentError::InvalidInput(
141 "Duration must be in format like '7d', '24h', or '30m'".to_string(),
142 ));
143 }
144
145 let (num_str, unit) = duration.split_at(len - 1);
146 let num: i64 = num_str.parse().map_err(|_| {
147 IntentError::InvalidInput(format!("Invalid number in duration: {}", num_str))
148 })?;
149
150 let now = Utc::now();
151 let result = match unit {
152 "d" => now - chrono::Duration::days(num),
153 "h" => now - chrono::Duration::hours(num),
154 "m" => now - chrono::Duration::minutes(num),
155 "s" => now - chrono::Duration::seconds(num),
156 _ => {
157 return Err(IntentError::InvalidInput(format!(
158 "Invalid duration unit '{}'. Use 'd' (days), 'h' (hours), 'm' (minutes), or 's' (seconds)",
159 unit
160 )))
161 }
162 };
163
164 Ok(result)
165 }
166
167 pub async fn search_events_fts5(
169 &self,
170 query: &str,
171 limit: Option<i64>,
172 ) -> Result<Vec<EventSearchResult>> {
173 let limit = limit.unwrap_or(20);
174
175 let results = sqlx::query(
177 r#"
178 SELECT
179 e.id,
180 e.task_id,
181 e.timestamp,
182 e.log_type,
183 e.discussion_data,
184 snippet(events_fts, 0, '**', '**', '...', 15) as match_snippet
185 FROM events_fts
186 INNER JOIN events e ON events_fts.rowid = e.id
187 WHERE events_fts MATCH ?
188 ORDER BY rank
189 LIMIT ?
190 "#,
191 )
192 .bind(query)
193 .bind(limit)
194 .fetch_all(self.pool)
195 .await?;
196
197 let mut search_results = Vec::new();
198 for row in results {
199 let event = Event {
200 id: row.get("id"),
201 task_id: row.get("task_id"),
202 timestamp: row.get("timestamp"),
203 log_type: row.get("log_type"),
204 discussion_data: row.get("discussion_data"),
205 };
206 let match_snippet: String = row.get("match_snippet");
207
208 search_results.push(EventSearchResult {
209 event,
210 match_snippet,
211 });
212 }
213
214 Ok(search_results)
215 }
216}
217
218#[derive(Debug)]
220pub struct EventSearchResult {
221 pub event: Event,
222 pub match_snippet: String,
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::tasks::TaskManager;
229 use crate::test_utils::test_helpers::TestContext;
230
231 #[tokio::test]
232 async fn test_add_event() {
233 let ctx = TestContext::new().await;
234 let task_mgr = TaskManager::new(ctx.pool());
235 let event_mgr = EventManager::new(ctx.pool());
236
237 let task = task_mgr.add_task("Test task", None, None).await.unwrap();
238 let event = event_mgr
239 .add_event(task.id, "decision", "Test decision")
240 .await
241 .unwrap();
242
243 assert_eq!(event.task_id, task.id);
244 assert_eq!(event.log_type, "decision");
245 assert_eq!(event.discussion_data, "Test decision");
246 }
247
248 #[tokio::test]
249 async fn test_add_event_nonexistent_task() {
250 let ctx = TestContext::new().await;
251 let event_mgr = EventManager::new(ctx.pool());
252
253 let result = event_mgr.add_event(999, "decision", "Test").await;
254 assert!(matches!(result, Err(IntentError::TaskNotFound(999))));
255 }
256
257 #[tokio::test]
258 async fn test_list_events() {
259 let ctx = TestContext::new().await;
260 let task_mgr = TaskManager::new(ctx.pool());
261 let event_mgr = EventManager::new(ctx.pool());
262
263 let task = task_mgr.add_task("Test task", None, None).await.unwrap();
264
265 event_mgr
267 .add_event(task.id, "decision", "Decision 1")
268 .await
269 .unwrap();
270 event_mgr
271 .add_event(task.id, "blocker", "Blocker 1")
272 .await
273 .unwrap();
274 event_mgr
275 .add_event(task.id, "milestone", "Milestone 1")
276 .await
277 .unwrap();
278
279 let events = event_mgr
280 .list_events(Some(task.id), None, None, None)
281 .await
282 .unwrap();
283 assert_eq!(events.len(), 3);
284
285 assert_eq!(events[0].log_type, "milestone");
287 assert_eq!(events[1].log_type, "blocker");
288 assert_eq!(events[2].log_type, "decision");
289 }
290
291 #[tokio::test]
292 async fn test_list_events_with_limit() {
293 let ctx = TestContext::new().await;
294 let task_mgr = TaskManager::new(ctx.pool());
295 let event_mgr = EventManager::new(ctx.pool());
296
297 let task = task_mgr.add_task("Test task", None, None).await.unwrap();
298
299 for i in 0..5 {
301 event_mgr
302 .add_event(task.id, "test", &format!("Event {}", i))
303 .await
304 .unwrap();
305 }
306
307 let events = event_mgr
308 .list_events(Some(task.id), Some(3), None, None)
309 .await
310 .unwrap();
311 assert_eq!(events.len(), 3);
312 }
313
314 #[tokio::test]
315 async fn test_list_events_nonexistent_task() {
316 let ctx = TestContext::new().await;
317 let event_mgr = EventManager::new(ctx.pool());
318
319 let result = event_mgr.list_events(Some(999), None, None, None).await;
320 assert!(matches!(result, Err(IntentError::TaskNotFound(999))));
321 }
322
323 #[tokio::test]
324 async fn test_list_events_empty() {
325 let ctx = TestContext::new().await;
326 let task_mgr = TaskManager::new(ctx.pool());
327 let event_mgr = EventManager::new(ctx.pool());
328
329 let task = task_mgr.add_task("Test task", None, None).await.unwrap();
330
331 let events = event_mgr
332 .list_events(Some(task.id), None, None, None)
333 .await
334 .unwrap();
335 assert_eq!(events.len(), 0);
336 }
337}