Skip to main content

max / goingson

8.3 KB · 296 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::{bind_placeholders, 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 query = format!(
49 r#"
50 SELECT id, task_id, text, linked_task_id, is_completed, position
51 FROM subtasks
52 WHERE task_id IN ({})
53 ORDER BY position ASC, created_at ASC
54 "#,
55 bind_placeholders(task_ids.len())
56 );
57
58 let mut q = sqlx::query_as::<_, SubtaskRow>(&query);
59 for id in task_ids {
60 q = q.bind(id);
61 }
62
63 let rows = q.fetch_all(pool).await.map_err(CoreError::database)?;
64
65 let mut map: HashMap<TaskId, Vec<Subtask>> = HashMap::new();
66 for row in rows {
67 let subtask = Subtask::try_from(row)?;
68 map.entry(subtask.task_id).or_default().push(subtask);
69 }
70
71 Ok(map)
72 }
73
74 /// Get all subtasks for a single task.
75 pub(crate) async fn get_subtasks_for_task(
76 pool: &SqlitePool,
77 task_id: TaskId,
78 ) -> Result<Vec<Subtask>> {
79 let rows = sqlx::query_as::<_, SubtaskRow>(
80 r#"
81 SELECT id, task_id, text, linked_task_id, is_completed, position
82 FROM subtasks
83 WHERE task_id = ?
84 ORDER BY position ASC, created_at ASC
85 "#,
86 )
87 .bind(task_id.to_string())
88 .fetch_all(pool)
89 .await
90 .map_err(CoreError::database)?;
91
92 rows.into_iter().map(Subtask::try_from).collect()
93 }
94
95 /// Add a subtask to a task (verifies task ownership).
96 pub(crate) async fn add_subtask(
97 pool: &SqlitePool,
98 task_id: TaskId,
99 user_id: UserId,
100 text: &str,
101 ) -> Result<Option<Subtask>> {
102 let task_exists: (i64,) = sqlx::query_as(
103 "SELECT COUNT(*) FROM tasks WHERE id = ? AND user_id = ?"
104 )
105 .bind(task_id.to_string())
106 .bind(user_id.to_string())
107 .fetch_one(pool)
108 .await
109 .map_err(CoreError::database)?;
110
111 if task_exists.0 == 0 {
112 return Ok(None);
113 }
114
115 let id = SubtaskId::new();
116
117 // Compute the next position inside the INSERT so the read-modify-write can't
118 // race a concurrent add (SQLite serializes writers, so the subquery sees every
119 // committed sibling). RETURNING gives back the position actually stored.
120 let (position,): (i32,) = sqlx::query_as(
121 r#"
122 INSERT INTO subtasks (id, task_id, text, position)
123 SELECT ?, ?, ?, COALESCE(MAX(position), -1) + 1 FROM subtasks WHERE task_id = ?
124 RETURNING position
125 "#,
126 )
127 .bind(id.to_string())
128 .bind(task_id.to_string())
129 .bind(text)
130 .bind(task_id.to_string())
131 .fetch_one(pool)
132 .await
133 .map_err(CoreError::database)?;
134
135 Ok(Some(Subtask {
136 id,
137 task_id,
138 text: text.to_string(),
139 linked_task_id: None,
140 is_completed: false,
141 position,
142 }))
143 }
144
145 /// Toggle a subtask's completion status.
146 pub(crate) async fn toggle_subtask(
147 pool: &SqlitePool,
148 subtask_id: SubtaskId,
149 user_id: UserId,
150 ) -> Result<Option<Subtask>> {
151 // Flip atomically (1 - is_completed) and read back the new row in one
152 // statement, scoped to the user's own tasks. Avoids the read-then-write
153 // race where two concurrent toggles both read the old value and cancel out.
154 // No matching row (missing or not owned) yields None.
155 let row = sqlx::query_as::<_, SubtaskRow>(
156 r#"
157 UPDATE subtasks SET is_completed = 1 - is_completed
158 WHERE id = ? AND task_id IN (SELECT id FROM tasks WHERE user_id = ?)
159 RETURNING id, task_id, text, linked_task_id, is_completed, position
160 "#
161 )
162 .bind(subtask_id.to_string())
163 .bind(user_id.to_string())
164 .fetch_optional(pool)
165 .await
166 .map_err(CoreError::database)?;
167
168 let Some(subtask) = row else {
169 return Ok(None);
170 };
171
172 Ok(Some(Subtask {
173 id: parse_uuid(&subtask.id)?.into(),
174 task_id: parse_uuid(&subtask.task_id)?.into(),
175 text: subtask.text,
176 linked_task_id: parse_uuid_opt(subtask.linked_task_id.as_deref())?.map(Into::into),
177 is_completed: subtask.is_completed != 0,
178 position: subtask.position,
179 }))
180 }
181
182 /// Update a subtask's text.
183 pub(crate) async fn update_subtask(
184 pool: &SqlitePool,
185 subtask_id: SubtaskId,
186 user_id: UserId,
187 text: &str,
188 ) -> Result<Option<Subtask>> {
189 let result = sqlx::query(
190 r#"
191 UPDATE subtasks
192 SET text = ?
193 WHERE id = ?
194 AND task_id IN (SELECT id FROM tasks WHERE user_id = ?)
195 "#
196 )
197 .bind(text)
198 .bind(subtask_id.to_string())
199 .bind(user_id.to_string())
200 .execute(pool)
201 .await
202 .map_err(CoreError::database)?;
203
204 if result.rows_affected() == 0 {
205 return Ok(None);
206 }
207
208 let row = sqlx::query_as::<_, SubtaskRow>(
209 "SELECT id, task_id, text, linked_task_id, is_completed, position FROM subtasks WHERE id = ?"
210 )
211 .bind(subtask_id.to_string())
212 .fetch_optional(pool)
213 .await
214 .map_err(CoreError::database)?;
215
216 row.map(Subtask::try_from).transpose()
217 }
218
219 /// Delete a subtask (verifies task ownership).
220 pub(crate) async fn delete_subtask(
221 pool: &SqlitePool,
222 subtask_id: SubtaskId,
223 user_id: UserId,
224 ) -> Result<bool> {
225 let result = sqlx::query(
226 r#"
227 DELETE FROM subtasks
228 WHERE id = ?
229 AND task_id IN (SELECT id FROM tasks WHERE user_id = ?)
230 "#
231 )
232 .bind(subtask_id.to_string())
233 .bind(user_id.to_string())
234 .execute(pool)
235 .await
236 .map_err(CoreError::database)?;
237
238 Ok(result.rows_affected() > 0)
239 }
240
241 /// Link an existing task as a subtask of another task.
242 pub(crate) async fn add_subtask_link(
243 pool: &SqlitePool,
244 task_id: TaskId,
245 user_id: UserId,
246 linked_task_id: TaskId,
247 linked_task_description: &str,
248 linked_task_status: &TaskStatus,
249 ) -> Result<Option<Subtask>> {
250 // Verify parent task exists and belongs to user
251 let task_exists: (i64,) = sqlx::query_as(
252 "SELECT COUNT(*) FROM tasks WHERE id = ? AND user_id = ?"
253 )
254 .bind(task_id.to_string())
255 .bind(user_id.to_string())
256 .fetch_one(pool)
257 .await
258 .map_err(CoreError::database)?;
259
260 if task_exists.0 == 0 {
261 return Ok(None);
262 }
263
264 let id = SubtaskId::new();
265
266 // Determine completion status based on linked task
267 let is_completed = *linked_task_status == TaskStatus::Completed;
268
269 // Atomic position assignment + RETURNING (see add_subtask) — no MAX+1 race.
270 let (position,): (i32,) = sqlx::query_as(
271 r#"
272 INSERT INTO subtasks (id, task_id, text, linked_task_id, is_completed, position)
273 SELECT ?, ?, ?, ?, ?, COALESCE(MAX(position), -1) + 1 FROM subtasks WHERE task_id = ?
274 RETURNING position
275 "#,
276 )
277 .bind(id.to_string())
278 .bind(task_id.to_string())
279 .bind(linked_task_description)
280 .bind(linked_task_id.to_string())
281 .bind(is_completed as i32)
282 .bind(task_id.to_string())
283 .fetch_one(pool)
284 .await
285 .map_err(CoreError::database)?;
286
287 Ok(Some(Subtask {
288 id,
289 task_id,
290 text: linked_task_description.to_string(),
291 linked_task_id: Some(linked_task_id),
292 is_completed,
293 position,
294 }))
295 }
296