1use super::accounts::DEFAULT_ACCOUNT_ID;
7use super::DbPool;
8use crate::error::StorageError;
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)]
13pub struct ActionLogEntry {
14 pub id: i64,
16 pub action_type: String,
18 pub status: String,
20 pub message: Option<String>,
22 pub metadata: Option<String>,
24 pub created_at: String,
26}
27
28pub async fn log_action_for(
33 pool: &DbPool,
34 account_id: &str,
35 action_type: &str,
36 status: &str,
37 message: Option<&str>,
38 metadata: Option<&str>,
39) -> Result<(), StorageError> {
40 sqlx::query(
41 "INSERT INTO action_log (account_id, action_type, status, message, metadata) \
42 VALUES (?, ?, ?, ?, ?)",
43 )
44 .bind(account_id)
45 .bind(action_type)
46 .bind(status)
47 .bind(message)
48 .bind(metadata)
49 .execute(pool)
50 .await
51 .map_err(|e| StorageError::Query { source: e })?;
52
53 Ok(())
54}
55
56pub async fn log_action(
61 pool: &DbPool,
62 action_type: &str,
63 status: &str,
64 message: Option<&str>,
65 metadata: Option<&str>,
66) -> Result<(), StorageError> {
67 log_action_for(
68 pool,
69 DEFAULT_ACCOUNT_ID,
70 action_type,
71 status,
72 message,
73 metadata,
74 )
75 .await
76}
77
78pub async fn get_actions_since_for(
83 pool: &DbPool,
84 account_id: &str,
85 since: &str,
86 action_type: Option<&str>,
87) -> Result<Vec<ActionLogEntry>, StorageError> {
88 match action_type {
89 Some(at) => sqlx::query_as::<_, ActionLogEntry>(
90 "SELECT * FROM action_log WHERE created_at >= ? AND action_type = ? \
91 AND account_id = ? ORDER BY created_at ASC",
92 )
93 .bind(since)
94 .bind(at)
95 .bind(account_id)
96 .fetch_all(pool)
97 .await
98 .map_err(|e| StorageError::Query { source: e }),
99 None => sqlx::query_as::<_, ActionLogEntry>(
100 "SELECT * FROM action_log WHERE created_at >= ? \
101 AND account_id = ? ORDER BY created_at ASC",
102 )
103 .bind(since)
104 .bind(account_id)
105 .fetch_all(pool)
106 .await
107 .map_err(|e| StorageError::Query { source: e }),
108 }
109}
110
111pub async fn get_actions_since(
115 pool: &DbPool,
116 since: &str,
117 action_type: Option<&str>,
118) -> Result<Vec<ActionLogEntry>, StorageError> {
119 get_actions_since_for(pool, DEFAULT_ACCOUNT_ID, since, action_type).await
120}
121
122pub async fn get_action_counts_since_for(
126 pool: &DbPool,
127 account_id: &str,
128 since: &str,
129) -> Result<HashMap<String, i64>, StorageError> {
130 let rows: Vec<(String, i64)> = sqlx::query_as(
131 "SELECT action_type, COUNT(*) as count FROM action_log \
132 WHERE created_at >= ? AND account_id = ? GROUP BY action_type",
133 )
134 .bind(since)
135 .bind(account_id)
136 .fetch_all(pool)
137 .await
138 .map_err(|e| StorageError::Query { source: e })?;
139
140 Ok(rows.into_iter().collect())
141}
142
143pub async fn get_action_counts_since(
147 pool: &DbPool,
148 since: &str,
149) -> Result<HashMap<String, i64>, StorageError> {
150 get_action_counts_since_for(pool, DEFAULT_ACCOUNT_ID, since).await
151}
152
153pub async fn get_recent_actions_for(
155 pool: &DbPool,
156 account_id: &str,
157 limit: u32,
158) -> Result<Vec<ActionLogEntry>, StorageError> {
159 sqlx::query_as::<_, ActionLogEntry>(
160 "SELECT * FROM action_log WHERE account_id = ? ORDER BY created_at DESC LIMIT ?",
161 )
162 .bind(account_id)
163 .bind(limit)
164 .fetch_all(pool)
165 .await
166 .map_err(|e| StorageError::Query { source: e })
167}
168
169pub async fn get_recent_actions(
171 pool: &DbPool,
172 limit: u32,
173) -> Result<Vec<ActionLogEntry>, StorageError> {
174 get_recent_actions_for(pool, DEFAULT_ACCOUNT_ID, limit).await
175}
176
177pub async fn get_actions_paginated_for(
182 pool: &DbPool,
183 account_id: &str,
184 limit: u32,
185 offset: u32,
186 action_type: Option<&str>,
187 status: Option<&str>,
188) -> Result<Vec<ActionLogEntry>, StorageError> {
189 let mut sql = String::from("SELECT * FROM action_log WHERE 1=1 AND account_id = ?");
190 if action_type.is_some() {
191 sql.push_str(" AND action_type = ?");
192 }
193 if status.is_some() {
194 sql.push_str(" AND status = ?");
195 }
196 sql.push_str(" ORDER BY created_at DESC LIMIT ? OFFSET ?");
197
198 let mut query = sqlx::query_as::<_, ActionLogEntry>(&sql);
199 query = query.bind(account_id);
200 if let Some(at) = action_type {
201 query = query.bind(at);
202 }
203 if let Some(st) = status {
204 query = query.bind(st);
205 }
206 query = query.bind(limit).bind(offset);
207
208 query
209 .fetch_all(pool)
210 .await
211 .map_err(|e| StorageError::Query { source: e })
212}
213
214pub async fn get_actions_paginated(
218 pool: &DbPool,
219 limit: u32,
220 offset: u32,
221 action_type: Option<&str>,
222 status: Option<&str>,
223) -> Result<Vec<ActionLogEntry>, StorageError> {
224 get_actions_paginated_for(pool, DEFAULT_ACCOUNT_ID, limit, offset, action_type, status).await
225}
226
227pub async fn get_actions_count_for(
230 pool: &DbPool,
231 account_id: &str,
232 action_type: Option<&str>,
233 status: Option<&str>,
234) -> Result<i64, StorageError> {
235 let mut sql = String::from("SELECT COUNT(*) FROM action_log WHERE 1=1 AND account_id = ?");
236 if action_type.is_some() {
237 sql.push_str(" AND action_type = ?");
238 }
239 if status.is_some() {
240 sql.push_str(" AND status = ?");
241 }
242
243 let mut query = sqlx::query_as::<_, (i64,)>(&sql);
244 query = query.bind(account_id);
245 if let Some(at) = action_type {
246 query = query.bind(at);
247 }
248 if let Some(st) = status {
249 query = query.bind(st);
250 }
251
252 let (count,) = query
253 .fetch_one(pool)
254 .await
255 .map_err(|e| StorageError::Query { source: e })?;
256 Ok(count)
257}
258
259pub async fn get_actions_count(
261 pool: &DbPool,
262 action_type: Option<&str>,
263 status: Option<&str>,
264) -> Result<i64, StorageError> {
265 get_actions_count_for(pool, DEFAULT_ACCOUNT_ID, action_type, status).await
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::storage::init_test_db;
272
273 #[tokio::test]
274 async fn log_and_retrieve_action() {
275 let pool = init_test_db().await.expect("init db");
276
277 log_action(&pool, "search", "success", Some("Found 10 tweets"), None)
278 .await
279 .expect("log");
280
281 let actions = get_actions_since(&pool, "2000-01-01T00:00:00Z", None)
282 .await
283 .expect("get");
284
285 assert_eq!(actions.len(), 1);
286 assert_eq!(actions[0].action_type, "search");
287 assert_eq!(actions[0].status, "success");
288 assert_eq!(actions[0].message.as_deref(), Some("Found 10 tweets"));
289 }
290
291 #[tokio::test]
292 async fn filter_by_action_type() {
293 let pool = init_test_db().await.expect("init db");
294
295 log_action(&pool, "search", "success", None, None)
296 .await
297 .expect("log");
298 log_action(&pool, "reply", "success", None, None)
299 .await
300 .expect("log");
301 log_action(&pool, "search", "failure", None, None)
302 .await
303 .expect("log");
304
305 let searches = get_actions_since(&pool, "2000-01-01T00:00:00Z", Some("search"))
306 .await
307 .expect("get");
308 assert_eq!(searches.len(), 2);
309
310 let replies = get_actions_since(&pool, "2000-01-01T00:00:00Z", Some("reply"))
311 .await
312 .expect("get");
313 assert_eq!(replies.len(), 1);
314 }
315
316 #[tokio::test]
317 async fn action_counts_aggregation() {
318 let pool = init_test_db().await.expect("init db");
319
320 log_action(&pool, "search", "success", None, None)
321 .await
322 .expect("log");
323 log_action(&pool, "search", "success", None, None)
324 .await
325 .expect("log");
326 log_action(&pool, "reply", "success", None, None)
327 .await
328 .expect("log");
329 log_action(&pool, "tweet", "failure", None, None)
330 .await
331 .expect("log");
332
333 let counts = get_action_counts_since(&pool, "2000-01-01T00:00:00Z")
334 .await
335 .expect("get counts");
336
337 assert_eq!(counts.get("search"), Some(&2));
338 assert_eq!(counts.get("reply"), Some(&1));
339 assert_eq!(counts.get("tweet"), Some(&1));
340 }
341
342 #[tokio::test]
343 async fn log_with_metadata() {
344 let pool = init_test_db().await.expect("init db");
345
346 let metadata = r#"{"tweet_id": "123", "score": 85}"#;
347 log_action(
348 &pool,
349 "reply",
350 "success",
351 Some("Replied to tweet"),
352 Some(metadata),
353 )
354 .await
355 .expect("log");
356
357 let actions = get_actions_since(&pool, "2000-01-01T00:00:00Z", Some("reply"))
358 .await
359 .expect("get");
360
361 assert_eq!(actions[0].metadata.as_deref(), Some(metadata));
362 }
363
364 #[tokio::test]
365 async fn empty_counts_returns_empty_map() {
366 let pool = init_test_db().await.expect("init db");
367
368 let counts = get_action_counts_since(&pool, "2000-01-01T00:00:00Z")
369 .await
370 .expect("get counts");
371
372 assert!(counts.is_empty());
373 }
374
375 #[tokio::test]
376 async fn paginated_actions_with_offset() {
377 let pool = init_test_db().await.expect("init db");
378
379 for i in 0..10 {
380 log_action(
381 &pool,
382 "search",
383 "success",
384 Some(&format!("Action {i}")),
385 None,
386 )
387 .await
388 .expect("log");
389 }
390
391 let page1 = get_actions_paginated(&pool, 3, 0, None, None)
392 .await
393 .expect("page 1");
394 assert_eq!(page1.len(), 3);
395
396 let page2 = get_actions_paginated(&pool, 3, 3, None, None)
397 .await
398 .expect("page 2");
399 assert_eq!(page2.len(), 3);
400
401 let ids1: Vec<i64> = page1.iter().map(|a| a.id).collect();
403 let ids2: Vec<i64> = page2.iter().map(|a| a.id).collect();
404 assert!(ids1.iter().all(|id| !ids2.contains(id)));
405 }
406
407 #[tokio::test]
408 async fn paginated_actions_with_type_filter() {
409 let pool = init_test_db().await.expect("init db");
410
411 log_action(&pool, "search", "success", None, None)
412 .await
413 .expect("log");
414 log_action(&pool, "reply", "success", None, None)
415 .await
416 .expect("log");
417 log_action(&pool, "search", "success", None, None)
418 .await
419 .expect("log");
420
421 let searches = get_actions_paginated(&pool, 10, 0, Some("search"), None)
422 .await
423 .expect("get");
424 assert_eq!(searches.len(), 2);
425
426 let count = get_actions_count(&pool, Some("search"), None)
427 .await
428 .expect("count");
429 assert_eq!(count, 2);
430 }
431
432 #[tokio::test]
433 async fn paginated_actions_with_status_filter() {
434 let pool = init_test_db().await.expect("init db");
435
436 log_action(&pool, "search", "success", None, None)
437 .await
438 .expect("log");
439 log_action(&pool, "reply", "failure", Some("Rate limited"), None)
440 .await
441 .expect("log");
442 log_action(&pool, "tweet", "failure", Some("API error"), None)
443 .await
444 .expect("log");
445
446 let failures = get_actions_paginated(&pool, 10, 0, None, Some("failure"))
447 .await
448 .expect("get");
449 assert_eq!(failures.len(), 2);
450
451 let count = get_actions_count(&pool, None, Some("failure"))
452 .await
453 .expect("count");
454 assert_eq!(count, 2);
455 }
456
457 #[tokio::test]
458 async fn paginated_actions_combined_filters() {
459 let pool = init_test_db().await.expect("init db");
460
461 log_action(&pool, "reply", "success", None, None)
462 .await
463 .expect("log");
464 log_action(&pool, "reply", "failure", None, None)
465 .await
466 .expect("log");
467 log_action(&pool, "tweet", "failure", None, None)
468 .await
469 .expect("log");
470
471 let reply_failures = get_actions_paginated(&pool, 10, 0, Some("reply"), Some("failure"))
472 .await
473 .expect("get");
474 assert_eq!(reply_failures.len(), 1);
475
476 let count = get_actions_count(&pool, Some("reply"), Some("failure"))
477 .await
478 .expect("count");
479 assert_eq!(count, 1);
480 }
481
482 #[tokio::test]
483 async fn actions_count_no_filter() {
484 let pool = init_test_db().await.expect("init db");
485
486 log_action(&pool, "search", "success", None, None)
487 .await
488 .expect("log");
489 log_action(&pool, "reply", "success", None, None)
490 .await
491 .expect("log");
492
493 let count = get_actions_count(&pool, None, None).await.expect("count");
494 assert_eq!(count, 2);
495 }
496
497 #[tokio::test]
498 async fn actions_count_empty_db() {
499 let pool = init_test_db().await.expect("init db");
500 let count = get_actions_count(&pool, None, None).await.expect("count");
501 assert_eq!(count, 0);
502 }
503
504 #[tokio::test]
505 async fn paginated_offset_beyond_data_returns_empty() {
506 let pool = init_test_db().await.expect("init db");
507
508 log_action(&pool, "search", "success", None, None)
509 .await
510 .expect("log");
511 log_action(&pool, "reply", "success", None, None)
512 .await
513 .expect("log");
514
515 let page = get_actions_paginated(&pool, 10, 100, None, None)
516 .await
517 .expect("page");
518 assert!(page.is_empty(), "offset past data should return empty");
519 }
520
521 #[tokio::test]
522 async fn get_recent_actions_returns_limited_set() {
523 let pool = init_test_db().await.expect("init db");
524
525 log_action(&pool, "search", "success", Some("first"), None)
526 .await
527 .expect("log");
528 log_action(&pool, "reply", "success", Some("second"), None)
529 .await
530 .expect("log");
531 log_action(&pool, "tweet", "success", Some("third"), None)
532 .await
533 .expect("log");
534
535 let recent = get_recent_actions(&pool, 2).await.expect("get");
536 assert_eq!(recent.len(), 2, "should respect limit");
537
538 let all = get_recent_actions(&pool, 10).await.expect("get all");
539 assert_eq!(all.len(), 3);
540 }
541
542 #[tokio::test]
543 async fn log_action_with_null_message_and_metadata() {
544 let pool = init_test_db().await.expect("init db");
545
546 log_action(&pool, "cleanup", "success", None, None)
547 .await
548 .expect("log");
549
550 let actions = get_actions_since(&pool, "2000-01-01T00:00:00Z", None)
551 .await
552 .expect("get");
553 assert_eq!(actions.len(), 1);
554 assert!(actions[0].message.is_none());
555 assert!(actions[0].metadata.is_none());
556 }
557
558 #[tokio::test]
559 async fn action_counts_since_future_returns_empty() {
560 let pool = init_test_db().await.expect("init db");
561
562 log_action(&pool, "search", "success", None, None)
563 .await
564 .expect("log");
565
566 let counts = get_action_counts_since(&pool, "2099-01-01T00:00:00Z")
567 .await
568 .expect("counts");
569 assert!(counts.is_empty());
570 }
571
572 #[tokio::test]
573 async fn paginated_type_and_status_combined_count() {
574 let pool = init_test_db().await.expect("init db");
575
576 log_action(&pool, "reply", "success", None, None)
577 .await
578 .expect("log");
579 log_action(&pool, "reply", "failure", None, None)
580 .await
581 .expect("log");
582 log_action(&pool, "reply", "success", None, None)
583 .await
584 .expect("log");
585 log_action(&pool, "tweet", "success", None, None)
586 .await
587 .expect("log");
588
589 let count = get_actions_count(&pool, Some("reply"), Some("success"))
590 .await
591 .expect("count");
592 assert_eq!(count, 2);
593
594 let page = get_actions_paginated(&pool, 10, 0, Some("reply"), Some("success"))
595 .await
596 .expect("page");
597 assert_eq!(page.len(), 2);
598 }
599
600 #[tokio::test]
601 async fn log_action_for_different_accounts() {
602 let pool = init_test_db().await.expect("init db");
603
604 log_action_for(&pool, "acct_a", "search", "success", Some("a"), None)
605 .await
606 .expect("log a");
607 log_action_for(&pool, "acct_b", "search", "success", Some("b"), None)
608 .await
609 .expect("log b");
610 log_action_for(&pool, "acct_a", "reply", "success", Some("a2"), None)
611 .await
612 .expect("log a2");
613
614 let actions_a = get_actions_since_for(&pool, "acct_a", "2000-01-01T00:00:00Z", None)
615 .await
616 .expect("get a");
617 assert_eq!(actions_a.len(), 2);
618
619 let actions_b = get_actions_since_for(&pool, "acct_b", "2000-01-01T00:00:00Z", None)
620 .await
621 .expect("get b");
622 assert_eq!(actions_b.len(), 1);
623
624 let count_a = get_actions_count_for(&pool, "acct_a", None, None)
625 .await
626 .expect("count a");
627 assert_eq!(count_a, 2);
628
629 let count_b = get_actions_count_for(&pool, "acct_b", None, None)
630 .await
631 .expect("count b");
632 assert_eq!(count_b, 1);
633 }
634
635 #[tokio::test]
636 async fn get_recent_actions_respects_limit() {
637 let pool = init_test_db().await.expect("init db");
638
639 for i in 0..5 {
640 log_action(
641 &pool,
642 "search",
643 "success",
644 Some(&format!("Action {i}")),
645 None,
646 )
647 .await
648 .expect("log");
649 }
650
651 let recent = get_recent_actions(&pool, 0).await.expect("get");
652 assert!(recent.is_empty(), "limit 0 should return empty");
653
654 let recent = get_recent_actions(&pool, 1).await.expect("get");
655 assert_eq!(recent.len(), 1);
656 }
657
658 #[tokio::test]
663 async fn get_action_counts_since_for_account_isolation() {
664 let pool = init_test_db().await.expect("init db");
665
666 log_action_for(&pool, "acct_x", "search", "success", None, None)
667 .await
668 .expect("log x");
669 log_action_for(&pool, "acct_x", "search", "success", None, None)
670 .await
671 .expect("log x2");
672 log_action_for(&pool, "acct_x", "reply", "success", None, None)
673 .await
674 .expect("log x3");
675 log_action_for(&pool, "acct_y", "search", "success", None, None)
676 .await
677 .expect("log y");
678
679 let counts_x = get_action_counts_since_for(&pool, "acct_x", "2000-01-01T00:00:00Z")
680 .await
681 .expect("counts x");
682 assert_eq!(counts_x.get("search"), Some(&2));
683 assert_eq!(counts_x.get("reply"), Some(&1));
684
685 let counts_y = get_action_counts_since_for(&pool, "acct_y", "2000-01-01T00:00:00Z")
686 .await
687 .expect("counts y");
688 assert_eq!(counts_y.get("search"), Some(&1));
689 assert!(counts_y.get("reply").is_none());
690 }
691
692 #[tokio::test]
693 async fn get_actions_paginated_for_account_isolation() {
694 let pool = init_test_db().await.expect("init db");
695
696 for i in 0..5 {
697 log_action_for(
698 &pool,
699 "acct_p",
700 "search",
701 "success",
702 Some(&format!("P{i}")),
703 None,
704 )
705 .await
706 .expect("log p");
707 }
708 for i in 0..3 {
709 log_action_for(
710 &pool,
711 "acct_q",
712 "reply",
713 "failure",
714 Some(&format!("Q{i}")),
715 None,
716 )
717 .await
718 .expect("log q");
719 }
720
721 let page1 = get_actions_paginated_for(&pool, "acct_p", 3, 0, None, None)
723 .await
724 .expect("page1");
725 assert_eq!(page1.len(), 3);
726
727 let page2 = get_actions_paginated_for(&pool, "acct_p", 3, 3, None, None)
728 .await
729 .expect("page2");
730 assert_eq!(page2.len(), 2);
731
732 let q_all = get_actions_paginated_for(&pool, "acct_q", 10, 0, None, None)
734 .await
735 .expect("q all");
736 assert_eq!(q_all.len(), 3);
737 assert!(q_all.iter().all(|a| a.action_type == "reply"));
738
739 let q_filtered =
741 get_actions_paginated_for(&pool, "acct_q", 10, 0, Some("reply"), Some("failure"))
742 .await
743 .expect("q filtered");
744 assert_eq!(q_filtered.len(), 3);
745
746 let p_replies = get_actions_paginated_for(&pool, "acct_p", 10, 0, Some("reply"), None)
748 .await
749 .expect("p replies");
750 assert!(p_replies.is_empty());
751 }
752
753 #[tokio::test]
754 async fn get_recent_actions_for_account_isolation() {
755 let pool = init_test_db().await.expect("init db");
756
757 log_action_for(&pool, "acct_r", "search", "success", Some("R1"), None)
758 .await
759 .expect("log r1");
760 log_action_for(&pool, "acct_r", "reply", "success", Some("R2"), None)
761 .await
762 .expect("log r2");
763 log_action_for(&pool, "acct_s", "tweet", "success", Some("S1"), None)
764 .await
765 .expect("log s1");
766
767 let recent_r = get_recent_actions_for(&pool, "acct_r", 10)
768 .await
769 .expect("recent r");
770 assert_eq!(recent_r.len(), 2);
771
772 let recent_s = get_recent_actions_for(&pool, "acct_s", 10)
773 .await
774 .expect("recent s");
775 assert_eq!(recent_s.len(), 1);
776 assert_eq!(recent_s[0].message.as_deref(), Some("S1"));
777
778 let recent_r1 = get_recent_actions_for(&pool, "acct_r", 1)
780 .await
781 .expect("recent r limited");
782 assert_eq!(recent_r1.len(), 1);
783 }
784}