Skip to main content

max / goingson

8.4 KB · 317 lines History Blame Raw
1 //! Subtask repository methods for SqliteTaskRepository.
2 //!
3 //! Handles subtask CRUD: listing, adding, toggling, updating, deleting,
4 //! and linking tasks as subtasks.
5
6 use sqlx::SqlitePool;
7 use std::collections::HashMap;
8
9 use goingson_core::{CoreError, Result, SubtaskId, Subtask, TaskId, TaskStatus, UserId};
10
11 use crate::utils::{parse_uuid, parse_uuid_opt};
12
13 /// Row struct for subtasks from SQLite.
14 #[derive(Debug, Clone, sqlx::FromRow)]
15 pub(crate) struct SubtaskRow {
16 pub id: String,
17 pub task_id: String,
18 pub text: String,
19 pub linked_task_id: Option<String>,
20 pub is_completed: i32,
21 pub position: i32,
22 }
23
24 impl TryFrom<SubtaskRow> for Subtask {
25 type Error = CoreError;
26
27 fn try_from(row: SubtaskRow) -> std::result::Result<Self, Self::Error> {
28 Ok(Subtask {
29 id: parse_uuid(&row.id)?.into(),
30 task_id: parse_uuid(&row.task_id)?.into(),
31 text: row.text,
32 linked_task_id: parse_uuid_opt(row.linked_task_id.as_deref())?.map(Into::into),
33 is_completed: row.is_completed != 0,
34 position: row.position,
35 })
36 }
37 }
38
39 /// Batch-fetch subtasks for multiple tasks by their IDs.
40 pub(crate) async fn get_subtasks_for_tasks(
41 pool: &SqlitePool,
42 task_ids: &[String],
43 ) -> Result<HashMap<TaskId, Vec<Subtask>>> {
44 if task_ids.is_empty() {
45 return Ok(HashMap::new());
46 }
47
48 let placeholders: Vec<String> = task_ids.iter().map(|_| "?".to_string()).collect();
49 let query = format!(
50 r#"
51 SELECT id, task_id, text, linked_task_id, is_completed, position
52 FROM subtasks
53 WHERE task_id IN ({})
54 ORDER BY position ASC, created_at ASC
55 "#,
56 placeholders.join(",")
57 );
58
59 let mut q = sqlx::query_as::<_, SubtaskRow>(&query);
60 for id in task_ids {
61 q = q.bind(id);
62 }
63
64 let rows = q.fetch_all(pool).await.map_err(CoreError::database)?;
65
66 let mut map: HashMap<TaskId, Vec<Subtask>> = HashMap::new();
67 for row in rows {
68 let subtask = Subtask::try_from(row)?;
69 map.entry(subtask.task_id).or_default().push(subtask);
70 }
71
72 Ok(map)
73 }
74
75 /// Get all subtasks for a single task.
76 pub(crate) async fn get_subtasks_for_task(
77 pool: &SqlitePool,
78 task_id: TaskId,
79 ) -> Result<Vec<Subtask>> {
80 let rows = sqlx::query_as::<_, SubtaskRow>(
81 r#"
82 SELECT id, task_id, text, linked_task_id, is_completed, position
83 FROM subtasks
84 WHERE task_id = ?
85 ORDER BY position ASC, created_at ASC
86 "#,
87 )
88 .bind(task_id.to_string())
89 .fetch_all(pool)
90 .await
91 .map_err(CoreError::database)?;
92
93 rows.into_iter().map(Subtask::try_from).collect()
94 }
95
96 /// Add a subtask to a task (verifies task ownership).
97 pub(crate) async fn add_subtask(
98 pool: &SqlitePool,
99 task_id: TaskId,
100 user_id: UserId,
101 text: &str,
102 ) -> Result<Option<Subtask>> {
103 let task_exists: (i64,) = sqlx::query_as(
104 "SELECT COUNT(*) FROM tasks WHERE id = ? AND user_id = ?"
105 )
106 .bind(task_id.to_string())
107 .bind(user_id.to_string())
108 .fetch_one(pool)
109 .await
110 .map_err(CoreError::database)?;
111
112 if task_exists.0 == 0 {
113 return Ok(None);
114 }
115
116 let max_position: (Option<i32>,) = sqlx::query_as(
117 "SELECT MAX(position) FROM subtasks WHERE task_id = ?"
118 )
119 .bind(task_id.to_string())
120 .fetch_one(pool)
121 .await
122 .map_err(CoreError::database)?;
123
124 let position = max_position.0.unwrap_or(-1) + 1;
125 let id = SubtaskId::new();
126
127 sqlx::query(
128 r#"
129 INSERT INTO subtasks (id, task_id, text, position)
130 VALUES (?, ?, ?, ?)
131 "#,
132 )
133 .bind(id.to_string())
134 .bind(task_id.to_string())
135 .bind(text)
136 .bind(position)
137 .execute(pool)
138 .await
139 .map_err(CoreError::database)?;
140
141 Ok(Some(Subtask {
142 id,
143 task_id,
144 text: text.to_string(),
145 linked_task_id: None,
146 is_completed: false,
147 position,
148 }))
149 }
150
151 /// Toggle a subtask's completion status.
152 pub(crate) async fn toggle_subtask(
153 pool: &SqlitePool,
154 subtask_id: SubtaskId,
155 user_id: UserId,
156 ) -> Result<Option<Subtask>> {
157 // First check if subtask exists and belongs to user's task
158 let row = sqlx::query_as::<_, SubtaskRow>(
159 r#"
160 SELECT s.id, s.task_id, s.text, s.linked_task_id, s.is_completed, s.position
161 FROM subtasks s
162 JOIN tasks t ON s.task_id = t.id
163 WHERE s.id = ? AND t.user_id = ?
164 "#
165 )
166 .bind(subtask_id.to_string())
167 .bind(user_id.to_string())
168 .fetch_optional(pool)
169 .await
170 .map_err(CoreError::database)?;
171
172 let Some(subtask) = row else {
173 return Ok(None);
174 };
175
176 let new_completed = if subtask.is_completed != 0 { 0 } else { 1 };
177
178 sqlx::query("UPDATE subtasks SET is_completed = ? WHERE id = ?")
179 .bind(new_completed)
180 .bind(subtask_id.to_string())
181 .execute(pool)
182 .await
183 .map_err(CoreError::database)?;
184
185 Ok(Some(Subtask {
186 id: parse_uuid(&subtask.id)?.into(),
187 task_id: parse_uuid(&subtask.task_id)?.into(),
188 text: subtask.text,
189 linked_task_id: parse_uuid_opt(subtask.linked_task_id.as_deref())?.map(Into::into),
190 is_completed: new_completed != 0,
191 position: subtask.position,
192 }))
193 }
194
195 /// Update a subtask's text.
196 pub(crate) async fn update_subtask(
197 pool: &SqlitePool,
198 subtask_id: SubtaskId,
199 user_id: UserId,
200 text: &str,
201 ) -> Result<Option<Subtask>> {
202 let result = sqlx::query(
203 r#"
204 UPDATE subtasks
205 SET text = ?
206 WHERE id = ?
207 AND task_id IN (SELECT id FROM tasks WHERE user_id = ?)
208 "#
209 )
210 .bind(text)
211 .bind(subtask_id.to_string())
212 .bind(user_id.to_string())
213 .execute(pool)
214 .await
215 .map_err(CoreError::database)?;
216
217 if result.rows_affected() == 0 {
218 return Ok(None);
219 }
220
221 let row = sqlx::query_as::<_, SubtaskRow>(
222 "SELECT id, task_id, text, linked_task_id, is_completed, position FROM subtasks WHERE id = ?"
223 )
224 .bind(subtask_id.to_string())
225 .fetch_optional(pool)
226 .await
227 .map_err(CoreError::database)?;
228
229 row.map(Subtask::try_from).transpose()
230 }
231
232 /// Delete a subtask (verifies task ownership).
233 pub(crate) async fn delete_subtask(
234 pool: &SqlitePool,
235 subtask_id: SubtaskId,
236 user_id: UserId,
237 ) -> Result<bool> {
238 let result = sqlx::query(
239 r#"
240 DELETE FROM subtasks
241 WHERE id = ?
242 AND task_id IN (SELECT id FROM tasks WHERE user_id = ?)
243 "#
244 )
245 .bind(subtask_id.to_string())
246 .bind(user_id.to_string())
247 .execute(pool)
248 .await
249 .map_err(CoreError::database)?;
250
251 Ok(result.rows_affected() > 0)
252 }
253
254 /// Link an existing task as a subtask of another task.
255 pub(crate) async fn add_subtask_link(
256 pool: &SqlitePool,
257 task_id: TaskId,
258 user_id: UserId,
259 linked_task_id: TaskId,
260 linked_task_description: &str,
261 linked_task_status: &TaskStatus,
262 ) -> Result<Option<Subtask>> {
263 // Verify parent task exists and belongs to user
264 let task_exists: (i64,) = sqlx::query_as(
265 "SELECT COUNT(*) FROM tasks WHERE id = ? AND user_id = ?"
266 )
267 .bind(task_id.to_string())
268 .bind(user_id.to_string())
269 .fetch_one(pool)
270 .await
271 .map_err(CoreError::database)?;
272
273 if task_exists.0 == 0 {
274 return Ok(None);
275 }
276
277 // Get next position
278 let max_position: (Option<i32>,) = sqlx::query_as(
279 "SELECT MAX(position) FROM subtasks WHERE task_id = ?"
280 )
281 .bind(task_id.to_string())
282 .fetch_one(pool)
283 .await
284 .map_err(CoreError::database)?;
285
286 let position = max_position.0.unwrap_or(-1) + 1;
287 let id = SubtaskId::new();
288
289 // Determine completion status based on linked task
290 let is_completed = *linked_task_status == TaskStatus::Completed;
291
292 sqlx::query(
293 r#"
294 INSERT INTO subtasks (id, task_id, text, linked_task_id, is_completed, position)
295 VALUES (?, ?, ?, ?, ?, ?)
296 "#,
297 )
298 .bind(id.to_string())
299 .bind(task_id.to_string())
300 .bind(linked_task_description)
301 .bind(linked_task_id.to_string())
302 .bind(is_completed as i32)
303 .bind(position)
304 .execute(pool)
305 .await
306 .map_err(CoreError::database)?;
307
308 Ok(Some(Subtask {
309 id,
310 task_id,
311 text: linked_task_description.to_string(),
312 linked_task_id: Some(linked_task_id),
313 is_completed,
314 position,
315 }))
316 }
317