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) = (&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}