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) = (&outcome.change_id, undo) {
101 record_tui_undo(
102 &mut tx,
103 &workspace.id,
104 UndoPayload {
105 commands: vec![UndoCommand::SetTaskRelatedLink {
106 task_id: outcome.task.id.clone(),
107 related_task_id: outcome.related_task.id.clone(),
108 forward_change_id: change_id.clone(),
109 linked,
110 }],
111 },
112 )
113 .await?;
114 }
115 tx.commit().await?;
116 Ok(outcome)
117 }
118}
119
120async fn load_task_by_id(conn: &mut SqliteConnection, task_id: &TaskId) -> Result<Option<Task>> {
121 let row = sqlx::query(
122 "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id,
123 p.key AS project_key, p.prefix AS project_prefix, t.status, t.priority,
124 t.source, t.created_at, t.updated_at, t.queue_activity_at,
125 t.available_at, t.due_on, t.deleted, t.is_epic
126 FROM tasks t
127 JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
128 WHERE t.id = ?",
129 )
130 .bind(task_id)
131 .fetch_optional(&mut *conn)
132 .await?;
133 row.map(|row| crate::db::task_from_row(&row)).transpose()
134}
135
136async fn load_related_tasks(
137 conn: &mut SqliteConnection,
138 workspace: &Workspace,
139 task_id: &TaskId,
140 related_task_id: &TaskId,
141) -> Result<(Task, Task)> {
142 canonical_related_pair(task_id, related_task_id)?;
143 let task = load_task_by_id(conn, task_id).await?.ok_or_else(|| {
144 CoreError::not_found(format!("error related-task-not-found task_id={task_id}"))
145 })?;
146 let related_task = load_task_by_id(conn, related_task_id)
147 .await?
148 .ok_or_else(|| {
149 CoreError::not_found(format!(
150 "error related-task-not-found task_id={related_task_id}"
151 ))
152 })?;
153 if task.workspace_id != workspace.id || related_task.workspace_id != workspace.id {
154 return Err(CoreError::validation(format!(
155 "error related-cross-workspace task_id={task_id} related_task_id={related_task_id} workspace_id={}",
156 workspace.id
157 ))
158 .into());
159 }
160 Ok((task, related_task))
161}
162
163pub(crate) async fn set_task_related_link_in_transaction(
164 conn: &mut SqliteConnection,
165 workspace: &Workspace,
166 task_id: &TaskId,
167 related_task_id: &TaskId,
168 linked: bool,
169) -> Result<RelatedOutcome> {
170 let (task, related_task) =
171 load_related_tasks(conn, workspace, task_id, related_task_id).await?;
172 let (task_a_id, task_b_id) = canonical_related_pair(task_id, related_task_id)?;
173 let current = sqlx::query(
174 "SELECT linked FROM task_related_links
175 WHERE workspace_id = ? AND task_a_id = ? AND task_b_id = ?",
176 )
177 .bind(&workspace.id)
178 .bind(task_a_id)
179 .bind(task_b_id)
180 .fetch_optional(&mut *conn)
181 .await?
182 .map(|row| row.get::<i64, _>("linked") != 0)
183 .unwrap_or(false);
184 if current == linked {
185 return Ok(RelatedOutcome {
186 task,
187 related_task,
188 changed: false,
189 change_id: None,
190 });
191 }
192
193 let op = if linked {
194 op_type::RELATED_ADD
195 } else {
196 op_type::RELATED_REMOVE
197 };
198 let change_id = append_change(
199 conn,
200 ChangeEntity::Task,
201 task_id,
202 Some("related"),
203 op,
204 ChangePayload::workspace(workspace).set("related_task_id", related_task_id),
205 )
206 .await?;
207 sqlx::query(
208 "INSERT INTO task_related_links(
209 workspace_id, task_a_id, task_b_id, linked, last_change_id
210 ) VALUES (?, ?, ?, ?, ?)
211 ON CONFLICT(workspace_id, task_a_id, task_b_id) DO UPDATE SET
212 linked = excluded.linked,
213 last_change_id = excluded.last_change_id",
214 )
215 .bind(&workspace.id)
216 .bind(task_a_id)
217 .bind(task_b_id)
218 .bind(i64::from(linked))
219 .bind(&change_id)
220 .execute(&mut *conn)
221 .await?;
222
223 Ok(RelatedOutcome {
224 task,
225 related_task,
226 changed: true,
227 change_id: Some(change_id),
228 })
229}
230
231pub(crate) async fn task_has_related_state(
232 conn: &mut SqliteConnection,
233 workspace_id: &WorkspaceId,
234 task_id: &TaskId,
235) -> Result<bool> {
236 Ok(sqlx::query_scalar::<_, i64>(
237 "SELECT EXISTS(
238 SELECT 1 FROM task_related_links
239 WHERE workspace_id = ? AND (task_a_id = ? OR task_b_id = ?)
240 )",
241 )
242 .bind(workspace_id)
243 .bind(task_id)
244 .bind(task_id)
245 .fetch_one(&mut *conn)
246 .await?
247 != 0)
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 async fn seed(conn: &mut SqliteConnection) -> (Workspace, TaskId, TaskId) {
255 let workspace = Workspace::default();
256 let project = crate::projects::create_project(conn, &workspace, "related")
257 .await
258 .unwrap();
259 let first: TaskId = "AAAA000000000001".parse().unwrap();
260 let second: TaskId = "BBBB000000000002".parse().unwrap();
261 for (id, title) in [(&first, "first"), (&second, "second")] {
262 sqlx::query(
263 "INSERT INTO tasks(id, workspace_id, title, description, project_id, status, priority, created_at, updated_at)
264 VALUES (?, ?, ?, '', ?, 'todo', 'none', 't', 't')",
265 )
266 .bind(id)
267 .bind(&workspace.id)
268 .bind(title)
269 .bind(&project.id)
270 .execute(&mut *conn)
271 .await
272 .unwrap();
273 }
274 (workspace, first, second)
275 }
276
277 #[tokio::test]
278 async fn canonical_add_reverse_idempotency_and_symmetric_remove() {
279 let (_temp, mut conn) = crate::test_support::test_conn().await;
280 let (workspace, first, second) = seed(&mut conn).await;
281 let added =
282 set_task_related_link_in_transaction(&mut conn, &workspace, &second, &first, true)
283 .await
284 .unwrap();
285 assert!(added.changed);
286 let row: (TaskId, TaskId, i64) =
287 sqlx::query_as("SELECT task_a_id, task_b_id, linked FROM task_related_links")
288 .fetch_one(&mut *conn)
289 .await
290 .unwrap();
291 assert_eq!(row, (first.clone(), second.clone(), 1));
292 assert!(
293 !set_task_related_link_in_transaction(&mut conn, &workspace, &first, &second, true,)
294 .await
295 .unwrap()
296 .changed
297 );
298 assert!(
299 set_task_related_link_in_transaction(&mut conn, &workspace, &second, &first, false,)
300 .await
301 .unwrap()
302 .changed
303 );
304 let linked: i64 = sqlx::query_scalar("SELECT linked FROM task_related_links")
305 .fetch_one(&mut *conn)
306 .await
307 .unwrap();
308 assert_eq!(linked, 0);
309 }
310
311 #[tokio::test]
312 async fn rejects_self_links_with_typed_error() {
313 let (_temp, mut conn) = crate::test_support::test_conn().await;
314 let (workspace, first, _) = seed(&mut conn).await;
315 let error =
316 set_task_related_link_in_transaction(&mut conn, &workspace, &first, &first, true)
317 .await
318 .unwrap_err();
319 let core = error.downcast_ref::<CoreError>().unwrap();
320 assert_eq!(core.kind(), crate::error::ErrorKind::Validation);
321 assert!(error.to_string().contains("related-self"));
322 }
323
324 #[tokio::test]
325 async fn rejects_cross_workspace_links_with_typed_error() {
326 let (_temp, mut conn) = crate::test_support::test_conn().await;
327 let (workspace, first, _) = seed(&mut conn).await;
328 let other_workspace = Workspace {
329 id: "CCCC000000000003".parse().unwrap(),
330 key: "other".to_string(),
331 name: "Other".to_string(),
332 };
333 sqlx::query(
334 "INSERT INTO workspaces(id, key, name, created_at, updated_at)
335 VALUES (?, ?, ?, 't', 't')",
336 )
337 .bind(&other_workspace.id)
338 .bind(&other_workspace.key)
339 .bind(&other_workspace.name)
340 .execute(&mut *conn)
341 .await
342 .unwrap();
343 let project = crate::projects::create_project(&mut conn, &other_workspace, "other")
344 .await
345 .unwrap();
346 let other_task: TaskId = "DDDD000000000004".parse().unwrap();
347 sqlx::query(
348 "INSERT INTO tasks(id, workspace_id, title, description, project_id, status, priority, created_at, updated_at)
349 VALUES (?, ?, 'other', '', ?, 'todo', 'none', 't', 't')",
350 )
351 .bind(&other_task)
352 .bind(&other_workspace.id)
353 .bind(&project.id)
354 .execute(&mut *conn)
355 .await
356 .unwrap();
357
358 let error =
359 set_task_related_link_in_transaction(&mut conn, &workspace, &first, &other_task, true)
360 .await
361 .unwrap_err();
362 let core = error.downcast_ref::<CoreError>().unwrap();
363 assert_eq!(core.kind(), crate::error::ErrorKind::Validation);
364 assert!(error.to_string().contains("related-cross-workspace"));
365 }
366}