Skip to main content

aven_core/operations/
related.rs

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}