1use anyhow::Result;
2use sqlx::{Row, SqliteConnection};
3
4use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
5use crate::db::{Database, begin_immediate};
6use crate::error::CoreError;
7use crate::ids::{TaskId, WorkspaceId};
8use crate::types::Task;
9use crate::undo::{UndoCommand, UndoContext, UndoPayload, record_tui_undo};
10use crate::workspaces::Workspace;
11
12pub(crate) fn canonical_related_pair<'a>(
13 first: &'a TaskId,
14 second: &'a TaskId,
15) -> Result<(&'a TaskId, &'a TaskId)> {
16 if first == second {
17 return Err(CoreError::validation(format!("error related-self task_id={first}")).into());
18 }
19 Ok(if first < second {
20 (first, second)
21 } else {
22 (second, first)
23 })
24}
25
26#[derive(Debug)]
27pub struct RelatedOutcome {
28 pub task: Task,
29 pub related_task: Task,
30 pub changed: bool,
31 pub change_id: Option<String>,
32}
33
34impl Database {
35 pub async fn add_task_related_link(
36 &self,
37 workspace: &Workspace,
38 task_id: &TaskId,
39 related_task_id: &TaskId,
40 ) -> Result<RelatedOutcome> {
41 self.add_task_related_link_with_undo(workspace, task_id, related_task_id, UndoContext::None)
42 .await
43 }
44
45 pub async fn add_task_related_link_with_undo(
46 &self,
47 workspace: &Workspace,
48 task_id: &TaskId,
49 related_task_id: &TaskId,
50 undo: UndoContext,
51 ) -> Result<RelatedOutcome> {
52 self.set_task_related_link(workspace, task_id, related_task_id, true, undo)
53 .await
54 }
55
56 pub async fn remove_task_related_link(
57 &self,
58 workspace: &Workspace,
59 task_id: &TaskId,
60 related_task_id: &TaskId,
61 ) -> Result<RelatedOutcome> {
62 self.remove_task_related_link_with_undo(
63 workspace,
64 task_id,
65 related_task_id,
66 UndoContext::None,
67 )
68 .await
69 }
70
71 pub async fn remove_task_related_link_with_undo(
72 &self,
73 workspace: &Workspace,
74 task_id: &TaskId,
75 related_task_id: &TaskId,
76 undo: UndoContext,
77 ) -> Result<RelatedOutcome> {
78 self.set_task_related_link(workspace, task_id, related_task_id, false, undo)
79 .await
80 }
81
82 async fn set_task_related_link(
83 &self,
84 workspace: &Workspace,
85 task_id: &TaskId,
86 related_task_id: &TaskId,
87 linked: bool,
88 undo: UndoContext,
89 ) -> Result<RelatedOutcome> {
90 let mut conn = self.acquire_writer().await?;
91 let mut tx = begin_immediate(&mut conn).await?;
92 let outcome = set_task_related_link_in_transaction(
93 &mut tx,
94 workspace,
95 task_id,
96 related_task_id,
97 linked,
98 )
99 .await?;
100 if let (Some(change_id), UndoContext::Tui { summary }) = (&outcome.change_id, undo) {
101 record_tui_undo(
102 &mut tx,
103 &workspace.id,
104 &summary,
105 UndoPayload {
106 commands: vec![UndoCommand::SetTaskRelatedLink {
107 task_id: outcome.task.id.clone(),
108 related_task_id: outcome.related_task.id.clone(),
109 forward_change_id: change_id.clone(),
110 linked,
111 }],
112 },
113 )
114 .await?;
115 }
116 tx.commit().await?;
117 Ok(outcome)
118 }
119}
120
121async fn load_task_by_id(conn: &mut SqliteConnection, task_id: &TaskId) -> Result<Option<Task>> {
122 let row = sqlx::query(
123 "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id,
124 p.key AS project_key, p.prefix AS project_prefix, t.status, t.priority,
125 t.source, t.created_at, t.updated_at, t.queue_activity_at,
126 t.available_at, t.due_on, t.deleted, t.is_epic
127 FROM tasks t
128 JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
129 WHERE t.id = ?",
130 )
131 .bind(task_id)
132 .fetch_optional(&mut *conn)
133 .await?;
134 row.map(|row| crate::db::task_from_row(&row)).transpose()
135}
136
137async fn load_related_tasks(
138 conn: &mut SqliteConnection,
139 workspace: &Workspace,
140 task_id: &TaskId,
141 related_task_id: &TaskId,
142) -> Result<(Task, Task)> {
143 canonical_related_pair(task_id, related_task_id)?;
144 let task = load_task_by_id(conn, task_id).await?.ok_or_else(|| {
145 CoreError::not_found(format!("error related-task-not-found task_id={task_id}"))
146 })?;
147 let related_task = load_task_by_id(conn, related_task_id)
148 .await?
149 .ok_or_else(|| {
150 CoreError::not_found(format!(
151 "error related-task-not-found task_id={related_task_id}"
152 ))
153 })?;
154 if task.workspace_id != workspace.id || related_task.workspace_id != workspace.id {
155 return Err(CoreError::validation(format!(
156 "error related-cross-workspace task_id={task_id} related_task_id={related_task_id} workspace_id={}",
157 workspace.id
158 ))
159 .into());
160 }
161 Ok((task, related_task))
162}
163
164pub(crate) async fn set_task_related_link_in_transaction(
165 conn: &mut SqliteConnection,
166 workspace: &Workspace,
167 task_id: &TaskId,
168 related_task_id: &TaskId,
169 linked: bool,
170) -> Result<RelatedOutcome> {
171 let (task, related_task) =
172 load_related_tasks(conn, workspace, task_id, related_task_id).await?;
173 let (task_a_id, task_b_id) = canonical_related_pair(task_id, related_task_id)?;
174 let current = sqlx::query(
175 "SELECT linked FROM task_related_links
176 WHERE workspace_id = ? AND task_a_id = ? AND task_b_id = ?",
177 )
178 .bind(&workspace.id)
179 .bind(task_a_id)
180 .bind(task_b_id)
181 .fetch_optional(&mut *conn)
182 .await?
183 .map(|row| row.get::<i64, _>("linked") != 0)
184 .unwrap_or(false);
185 if current == linked {
186 return Ok(RelatedOutcome {
187 task,
188 related_task,
189 changed: false,
190 change_id: None,
191 });
192 }
193
194 let op = if linked {
195 op_type::RELATED_ADD
196 } else {
197 op_type::RELATED_REMOVE
198 };
199 let change_id = append_change(
200 conn,
201 ChangeEntity::Task,
202 task_id,
203 Some("related"),
204 op,
205 ChangePayload::workspace(workspace).set("related_task_id", related_task_id),
206 )
207 .await?;
208 sqlx::query(
209 "INSERT INTO task_related_links(
210 workspace_id, task_a_id, task_b_id, linked, last_change_id
211 ) VALUES (?, ?, ?, ?, ?)
212 ON CONFLICT(workspace_id, task_a_id, task_b_id) DO UPDATE SET
213 linked = excluded.linked,
214 last_change_id = excluded.last_change_id",
215 )
216 .bind(&workspace.id)
217 .bind(task_a_id)
218 .bind(task_b_id)
219 .bind(i64::from(linked))
220 .bind(&change_id)
221 .execute(&mut *conn)
222 .await?;
223
224 Ok(RelatedOutcome {
225 task,
226 related_task,
227 changed: true,
228 change_id: Some(change_id),
229 })
230}
231
232pub(crate) async fn task_has_related_state(
233 conn: &mut SqliteConnection,
234 workspace_id: &WorkspaceId,
235 task_id: &TaskId,
236) -> Result<bool> {
237 Ok(sqlx::query_scalar::<_, i64>(
238 "SELECT EXISTS(
239 SELECT 1 FROM task_related_links
240 WHERE workspace_id = ? AND (task_a_id = ? OR task_b_id = ?)
241 )",
242 )
243 .bind(workspace_id)
244 .bind(task_id)
245 .bind(task_id)
246 .fetch_one(&mut *conn)
247 .await?
248 != 0)
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 async fn seed(conn: &mut SqliteConnection) -> (Workspace, TaskId, TaskId) {
256 let workspace = Workspace::default();
257 let project = crate::projects::create_project(conn, &workspace, "related")
258 .await
259 .unwrap();
260 let first: TaskId = "AAAA000000000001".parse().unwrap();
261 let second: TaskId = "BBBB000000000002".parse().unwrap();
262 for (id, title) in [(&first, "first"), (&second, "second")] {
263 sqlx::query(
264 "INSERT INTO tasks(id, workspace_id, title, description, project_id, status, priority, created_at, updated_at)
265 VALUES (?, ?, ?, '', ?, 'todo', 'none', 't', 't')",
266 )
267 .bind(id)
268 .bind(&workspace.id)
269 .bind(title)
270 .bind(&project.id)
271 .execute(&mut *conn)
272 .await
273 .unwrap();
274 }
275 (workspace, first, second)
276 }
277
278 #[tokio::test]
279 async fn canonical_add_reverse_idempotency_and_symmetric_remove() {
280 let (_temp, mut conn) = crate::test_support::test_conn().await;
281 let (workspace, first, second) = seed(&mut conn).await;
282 let added =
283 set_task_related_link_in_transaction(&mut conn, &workspace, &second, &first, true)
284 .await
285 .unwrap();
286 assert!(added.changed);
287 let row: (TaskId, TaskId, i64) =
288 sqlx::query_as("SELECT task_a_id, task_b_id, linked FROM task_related_links")
289 .fetch_one(&mut *conn)
290 .await
291 .unwrap();
292 assert_eq!(row, (first.clone(), second.clone(), 1));
293 assert!(
294 !set_task_related_link_in_transaction(&mut conn, &workspace, &first, &second, true,)
295 .await
296 .unwrap()
297 .changed
298 );
299 assert!(
300 set_task_related_link_in_transaction(&mut conn, &workspace, &second, &first, false,)
301 .await
302 .unwrap()
303 .changed
304 );
305 let linked: i64 = sqlx::query_scalar("SELECT linked FROM task_related_links")
306 .fetch_one(&mut *conn)
307 .await
308 .unwrap();
309 assert_eq!(linked, 0);
310 }
311
312 #[tokio::test]
313 async fn rejects_self_links_with_typed_error() {
314 let (_temp, mut conn) = crate::test_support::test_conn().await;
315 let (workspace, first, _) = seed(&mut conn).await;
316 let error =
317 set_task_related_link_in_transaction(&mut conn, &workspace, &first, &first, true)
318 .await
319 .unwrap_err();
320 let core = error.downcast_ref::<CoreError>().unwrap();
321 assert_eq!(core.kind(), crate::error::ErrorKind::Validation);
322 assert!(error.to_string().contains("related-self"));
323 }
324
325 #[tokio::test]
326 async fn rejects_cross_workspace_links_with_typed_error() {
327 let (_temp, mut conn) = crate::test_support::test_conn().await;
328 let (workspace, first, _) = seed(&mut conn).await;
329 let other_workspace = Workspace {
330 id: "CCCC000000000003".parse().unwrap(),
331 key: "other".to_string(),
332 name: "Other".to_string(),
333 };
334 sqlx::query(
335 "INSERT INTO workspaces(id, key, name, created_at, updated_at)
336 VALUES (?, ?, ?, 't', 't')",
337 )
338 .bind(&other_workspace.id)
339 .bind(&other_workspace.key)
340 .bind(&other_workspace.name)
341 .execute(&mut *conn)
342 .await
343 .unwrap();
344 let project = crate::projects::create_project(&mut conn, &other_workspace, "other")
345 .await
346 .unwrap();
347 let other_task: TaskId = "DDDD000000000004".parse().unwrap();
348 sqlx::query(
349 "INSERT INTO tasks(id, workspace_id, title, description, project_id, status, priority, created_at, updated_at)
350 VALUES (?, ?, 'other', '', ?, 'todo', 'none', 't', 't')",
351 )
352 .bind(&other_task)
353 .bind(&other_workspace.id)
354 .bind(&project.id)
355 .execute(&mut *conn)
356 .await
357 .unwrap();
358
359 let error =
360 set_task_related_link_in_transaction(&mut conn, &workspace, &first, &other_task, true)
361 .await
362 .unwrap_err();
363 let core = error.downcast_ref::<CoreError>().unwrap();
364 assert_eq!(core.kind(), crate::error::ErrorKind::Validation);
365 assert!(error.to_string().contains("related-cross-workspace"));
366 }
367}