Skip to main content

max / goingson

4.0 KB · 158 lines History Blame Raw
1 //! Annotation repository methods for SqliteTaskRepository.
2 //!
3 //! Handles annotation CRUD: listing, adding, and deleting annotations on tasks.
4
5 use chrono::Utc;
6 use sqlx::SqlitePool;
7 use std::collections::HashMap;
8
9 use goingson_core::{AnnotationId, Annotation, CoreError, Result, TaskId, UserId};
10
11 use crate::utils::{bind_placeholders, format_datetime_now, parse_datetime, parse_uuid};
12
13 /// Row struct for annotations from SQLite.
14 #[derive(Debug, Clone, sqlx::FromRow)]
15 pub(crate) struct AnnotationRow {
16 pub id: String,
17 pub task_id: String,
18 pub timestamp: String,
19 pub note: String,
20 }
21
22 impl TryFrom<AnnotationRow> for Annotation {
23 type Error = CoreError;
24
25 fn try_from(row: AnnotationRow) -> std::result::Result<Self, Self::Error> {
26 Ok(Annotation {
27 id: parse_uuid(&row.id)?.into(),
28 task_id: parse_uuid(&row.task_id)?.into(),
29 timestamp: parse_datetime(&row.timestamp)?,
30 note: row.note,
31 })
32 }
33 }
34
35 /// Batch-fetch annotations for multiple tasks by their IDs.
36 pub(crate) async fn get_annotations_for_tasks(
37 pool: &SqlitePool,
38 task_ids: &[String],
39 ) -> Result<HashMap<TaskId, Vec<Annotation>>> {
40 if task_ids.is_empty() {
41 return Ok(HashMap::new());
42 }
43
44 // SQLite doesn't have ANY(), use IN with placeholder generation
45 let query = format!(
46 r#"
47 SELECT id, task_id, timestamp, note
48 FROM annotations
49 WHERE task_id IN ({})
50 ORDER BY timestamp DESC
51 "#,
52 bind_placeholders(task_ids.len())
53 );
54
55 let mut q = sqlx::query_as::<_, AnnotationRow>(&query);
56 for id in task_ids {
57 q = q.bind(id);
58 }
59
60 let rows = q.fetch_all(pool).await.map_err(CoreError::database)?;
61
62 let mut map: HashMap<TaskId, Vec<Annotation>> = HashMap::new();
63 for row in rows {
64 let annotation = Annotation::try_from(row)?;
65 map.entry(annotation.task_id).or_default().push(annotation);
66 }
67
68 Ok(map)
69 }
70
71 /// Get all annotations for a single task.
72 pub(crate) async fn get_annotations_for_task(
73 pool: &SqlitePool,
74 task_id: TaskId,
75 ) -> Result<Vec<Annotation>> {
76 let rows = sqlx::query_as::<_, AnnotationRow>(
77 r#"
78 SELECT id, task_id, timestamp, note
79 FROM annotations
80 WHERE task_id = ?
81 ORDER BY timestamp DESC
82 "#,
83 )
84 .bind(task_id.to_string())
85 .fetch_all(pool)
86 .await
87 .map_err(CoreError::database)?;
88
89 rows.into_iter().map(Annotation::try_from).collect()
90 }
91
92 /// Add an annotation to a task (verifies task ownership).
93 pub(crate) async fn add_annotation(
94 pool: &SqlitePool,
95 task_id: TaskId,
96 user_id: UserId,
97 note: &str,
98 ) -> Result<Option<Annotation>> {
99 let task_exists: (i64,) = sqlx::query_as(
100 "SELECT COUNT(*) FROM tasks WHERE id = ? AND user_id = ?"
101 )
102 .bind(task_id.to_string())
103 .bind(user_id.to_string())
104 .fetch_one(pool)
105 .await
106 .map_err(CoreError::database)?;
107
108 if task_exists.0 == 0 {
109 return Ok(None);
110 }
111
112 let id = AnnotationId::new();
113 let now = format_datetime_now();
114
115 sqlx::query(
116 r#"
117 INSERT INTO annotations (id, task_id, timestamp, note)
118 VALUES (?, ?, ?, ?)
119 "#,
120 )
121 .bind(id.to_string())
122 .bind(task_id.to_string())
123 .bind(&now)
124 .bind(note)
125 .execute(pool)
126 .await
127 .map_err(CoreError::database)?;
128
129 Ok(Some(Annotation {
130 id,
131 task_id,
132 timestamp: Utc::now(),
133 note: note.to_string(),
134 }))
135 }
136
137 /// Delete an annotation (verifies task ownership).
138 pub(crate) async fn delete_annotation(
139 pool: &SqlitePool,
140 annotation_id: AnnotationId,
141 user_id: UserId,
142 ) -> Result<bool> {
143 let result = sqlx::query(
144 r#"
145 DELETE FROM annotations
146 WHERE id = ?
147 AND task_id IN (SELECT id FROM tasks WHERE user_id = ?)
148 "#
149 )
150 .bind(annotation_id.to_string())
151 .bind(user_id.to_string())
152 .execute(pool)
153 .await
154 .map_err(CoreError::database)?;
155
156 Ok(result.rows_affected() > 0)
157 }
158