Skip to main content

aven_core/operations/
epics.rs

1use crate::ids::WorkspaceId;
2use anyhow::{Result, bail};
3use sqlx::SqliteConnection;
4
5use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
6use crate::db::{Database, begin_immediate, field_version, insert_change, set_field_version};
7use crate::ids::{TaskId, now};
8use crate::refs::get_task_in_workspace;
9use crate::task_fields::TaskField;
10use crate::types::Task;
11use crate::undo::{UndoCommand, UndoContext, UndoPayload, record_tui_undo};
12use crate::workspaces::Workspace;
13
14impl Database {
15    pub async fn add_task_to_epic(
16        &self,
17        workspace: &Workspace,
18        child_id: &TaskId,
19        epic_id: &TaskId,
20    ) -> Result<EpicLinkOutcome> {
21        self.add_task_to_epic_with_undo(workspace, child_id, epic_id, UndoContext::None)
22            .await
23    }
24
25    pub async fn add_task_to_epic_with_undo(
26        &self,
27        workspace: &Workspace,
28        child_id: &TaskId,
29        epic_id: &TaskId,
30        undo: UndoContext,
31    ) -> Result<EpicLinkOutcome> {
32        let mut conn = self.acquire().await?;
33        let mut tx = begin_immediate(&mut conn).await?;
34        let outcome =
35            add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
36        if outcome.changed
37            && let UndoContext::Tui { summary } = undo
38        {
39            record_tui_undo(
40                &mut tx,
41                &workspace.id,
42                &summary,
43                UndoPayload {
44                    commands: vec![UndoCommand::AddEpicChild {
45                        epic_id: outcome.epic.id.clone(),
46                        child_id: outcome.child.id.clone(),
47                    }],
48                },
49            )
50            .await?;
51        }
52        tx.commit().await?;
53        Ok(outcome)
54    }
55
56    pub async fn remove_task_from_epic(
57        &self,
58        workspace: &Workspace,
59        child_id: &TaskId,
60        epic_id: &TaskId,
61    ) -> Result<EpicLinkOutcome> {
62        self.remove_task_from_epic_with_undo(workspace, child_id, epic_id, UndoContext::None)
63            .await
64    }
65
66    pub async fn remove_task_from_epic_with_undo(
67        &self,
68        workspace: &Workspace,
69        child_id: &TaskId,
70        epic_id: &TaskId,
71        undo: UndoContext,
72    ) -> Result<EpicLinkOutcome> {
73        let mut conn = self.acquire().await?;
74        let mut tx = begin_immediate(&mut conn).await?;
75        let outcome =
76            remove_task_from_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
77        if outcome.changed
78            && let UndoContext::Tui { summary } = undo
79        {
80            record_tui_undo(
81                &mut tx,
82                &workspace.id,
83                &summary,
84                UndoPayload {
85                    commands: vec![UndoCommand::RemoveEpicChild {
86                        epic_id: outcome.epic.id.clone(),
87                        child_id: outcome.child.id.clone(),
88                    }],
89                },
90            )
91            .await?;
92        }
93        tx.commit().await?;
94        Ok(outcome)
95    }
96}
97
98pub struct EpicLinkOutcome {
99    pub epic: Task,
100    pub child: Task,
101    pub changed: bool,
102}
103
104struct EpicPair {
105    epic: Task,
106    child: Task,
107}
108
109async fn load_epic_pair(
110    conn: &mut SqliteConnection,
111    workspace: &Workspace,
112    child_id: &crate::ids::TaskId,
113    epic_id: &crate::ids::TaskId,
114) -> Result<EpicPair> {
115    if child_id == epic_id {
116        bail!("error epic-self task_id={child_id}");
117    }
118
119    let child = get_task_in_workspace(conn, workspace, child_id).await?;
120    let epic = get_task_in_workspace(conn, workspace, epic_id).await?;
121    Ok(EpicPair { epic, child })
122}
123
124async fn record_epic_change(
125    conn: &mut SqliteConnection,
126    workspace: &Workspace,
127    pair: &EpicPair,
128    op_type: &'static str,
129) -> Result<()> {
130    append_change(
131        conn,
132        ChangeEntity::Task,
133        &pair.child.id,
134        Some("epics"),
135        op_type,
136        ChangePayload::workspace(workspace)
137            .set("epic_task_id", pair.epic.id.clone())
138            .set("created_at", now()),
139    )
140    .await?;
141    Ok(())
142}
143
144async fn mark_task_as_epic(
145    conn: &mut SqliteConnection,
146    workspace: &Workspace,
147    task: &Task,
148) -> Result<()> {
149    if task.is_epic {
150        return Ok(());
151    }
152    let field = TaskField::IsEpic.as_str();
153    let base = field_version(conn, &task.id, field).await?;
154    let ts = now();
155    sqlx::query("UPDATE tasks SET is_epic = 1, updated_at = ? WHERE workspace_id = ? AND id = ?")
156        .bind(&ts)
157        .bind(&task.workspace_id)
158        .bind(&task.id)
159        .execute(&mut *conn)
160        .await?;
161    let change_id = insert_change(
162        conn,
163        ChangeEntity::Task.as_str(),
164        &task.id,
165        Some(field),
166        op_type::SET_FIELD,
167        TaskField::IsEpic.scalar_payload(&workspace.id, &workspace.key, "1")?,
168        base.as_deref(),
169    )
170    .await?;
171    set_field_version(conn, &task.id, field, &change_id).await?;
172    Ok(())
173}
174
175#[cfg(any(test, feature = "test-support"))]
176pub async fn add_task_to_epic(
177    conn: &mut SqliteConnection,
178    workspace: &Workspace,
179    child_id: &crate::ids::TaskId,
180    epic_id: &crate::ids::TaskId,
181) -> Result<EpicLinkOutcome> {
182    let mut tx = begin_immediate(conn).await?;
183    let outcome = add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
184    tx.commit().await?;
185    Ok(outcome)
186}
187
188pub(crate) async fn add_task_to_epic_in_transaction(
189    conn: &mut SqliteConnection,
190    workspace: &Workspace,
191    child_id: &crate::ids::TaskId,
192    epic_id: &crate::ids::TaskId,
193) -> Result<EpicLinkOutcome> {
194    let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
195    if pair.child.project_id != pair.epic.project_id {
196        bail!("error epic-cross-project child_task_id={child_id} epic_task_id={epic_id}");
197    }
198    if pair.child.is_epic {
199        bail!("error epic-child-is-epic child_task_id={child_id}");
200    }
201    if pair.child.deleted {
202        bail!("error epic-child-deleted child_task_id={child_id}");
203    }
204    if pair.epic.deleted {
205        bail!("error epic-parent-deleted epic_task_id={epic_id}");
206    }
207    let ts = now();
208    if !pair.epic.is_epic {
209        mark_task_as_epic(conn, workspace, &pair.epic).await?;
210    }
211    let existing_epic_id = sqlx::query_scalar::<_, crate::ids::TaskId>(
212        "SELECT epic_task_id FROM task_epic_links WHERE workspace_id = ? AND child_task_id = ?",
213    )
214    .bind(&pair.child.workspace_id)
215    .bind(&pair.child.id)
216    .fetch_optional(&mut *conn)
217    .await?;
218    if let Some(existing_epic_id) = existing_epic_id
219        && existing_epic_id != pair.epic.id
220    {
221        bail!(
222            "error epic-child-already-linked child_task_id={} epic_task_id={}",
223            pair.child.id,
224            existing_epic_id
225        );
226    }
227    let changed = sqlx::query(
228        "INSERT OR IGNORE INTO task_epic_links(workspace_id, epic_task_id, child_task_id, created_at)
229         VALUES (?, ?, ?, ?)",
230    )
231    .bind(&pair.child.workspace_id)
232    .bind(&pair.epic.id)
233    .bind(&pair.child.id)
234    .bind(&ts)
235    .execute(&mut *conn)
236    .await?
237    .rows_affected()
238        > 0;
239
240    if changed {
241        record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD).await?;
242    }
243
244    Ok(EpicLinkOutcome {
245        epic: get_task_in_workspace(conn, workspace, &pair.epic.id).await?,
246        child: pair.child,
247        changed,
248    })
249}
250
251pub(crate) async fn restore_task_to_epic_in_transaction(
252    conn: &mut SqliteConnection,
253    workspace: &Workspace,
254    child_id: &crate::ids::TaskId,
255    epic_id: &crate::ids::TaskId,
256) -> Result<EpicLinkOutcome> {
257    let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
258    let existing_epic_id = sqlx::query_scalar::<_, crate::ids::TaskId>(
259        "SELECT epic_task_id FROM task_epic_links WHERE workspace_id = ? AND child_task_id = ?",
260    )
261    .bind(&pair.child.workspace_id)
262    .bind(&pair.child.id)
263    .fetch_optional(&mut *conn)
264    .await?;
265    if let Some(existing_epic_id) = existing_epic_id
266        && existing_epic_id != pair.epic.id
267    {
268        bail!(
269            "error epic-child-already-linked child_task_id={} epic_task_id={}",
270            pair.child.id,
271            existing_epic_id
272        );
273    }
274    let changed = sqlx::query(
275        "INSERT OR IGNORE INTO task_epic_links(workspace_id, epic_task_id, child_task_id, created_at)
276         VALUES (?, ?, ?, ?)",
277    )
278    .bind(&pair.child.workspace_id)
279    .bind(&pair.epic.id)
280    .bind(&pair.child.id)
281    .bind(now())
282    .execute(&mut *conn)
283    .await?
284    .rows_affected()
285        > 0;
286    if changed {
287        record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD).await?;
288    }
289    Ok(EpicLinkOutcome {
290        epic: pair.epic,
291        child: pair.child,
292        changed,
293    })
294}
295
296pub(crate) async fn remove_task_from_epic_in_transaction(
297    conn: &mut SqliteConnection,
298    workspace: &Workspace,
299    child_id: &crate::ids::TaskId,
300    epic_id: &crate::ids::TaskId,
301) -> Result<EpicLinkOutcome> {
302    let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
303    let changed = sqlx::query(
304        "DELETE FROM task_epic_links
305         WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
306    )
307    .bind(&pair.child.workspace_id)
308    .bind(&pair.epic.id)
309    .bind(&pair.child.id)
310    .execute(&mut *conn)
311    .await?
312    .rows_affected()
313        > 0;
314
315    if changed {
316        record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_REMOVE).await?;
317    }
318
319    Ok(EpicLinkOutcome {
320        epic: pair.epic,
321        child: pair.child,
322        changed,
323    })
324}
325
326pub async fn task_has_epic_children(
327    conn: &mut SqliteConnection,
328    workspace_id: &WorkspaceId,
329    task_id: &crate::ids::TaskId,
330) -> Result<bool> {
331    Ok(sqlx::query_scalar::<_, i64>(
332        "SELECT count(*) FROM task_epic_links WHERE workspace_id = ? AND epic_task_id = ? LIMIT 1",
333    )
334    .bind(workspace_id)
335    .bind(task_id)
336    .fetch_one(&mut *conn)
337    .await?
338        > 0)
339}