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    crate::operations::route_recurrence_task_field(
195        conn,
196        workspace,
197        child_id,
198        "epic_membership",
199        "",
200    )
201    .await?;
202    crate::operations::route_recurrence_task_field(conn, workspace, epic_id, "epic_membership", "")
203        .await?;
204    let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
205    if pair.child.project_id != pair.epic.project_id {
206        bail!("error epic-cross-project child_task_id={child_id} epic_task_id={epic_id}");
207    }
208    if pair.child.is_epic {
209        bail!("error epic-child-is-epic child_task_id={child_id}");
210    }
211    if pair.child.deleted {
212        bail!("error epic-child-deleted child_task_id={child_id}");
213    }
214    if pair.epic.deleted {
215        bail!("error epic-parent-deleted epic_task_id={epic_id}");
216    }
217    let ts = now();
218    if !pair.epic.is_epic {
219        mark_task_as_epic(conn, workspace, &pair.epic).await?;
220    }
221    let existing_epic_id = sqlx::query_scalar::<_, crate::ids::TaskId>(
222        "SELECT epic_task_id FROM task_epic_links WHERE workspace_id = ? AND child_task_id = ?",
223    )
224    .bind(&pair.child.workspace_id)
225    .bind(&pair.child.id)
226    .fetch_optional(&mut *conn)
227    .await?;
228    if let Some(existing_epic_id) = existing_epic_id
229        && existing_epic_id != pair.epic.id
230    {
231        bail!(
232            "error epic-child-already-linked child_task_id={} epic_task_id={}",
233            pair.child.id,
234            existing_epic_id
235        );
236    }
237    let changed = sqlx::query(
238        "INSERT OR IGNORE INTO task_epic_links(workspace_id, epic_task_id, child_task_id, created_at)
239         VALUES (?, ?, ?, ?)",
240    )
241    .bind(&pair.child.workspace_id)
242    .bind(&pair.epic.id)
243    .bind(&pair.child.id)
244    .bind(&ts)
245    .execute(&mut *conn)
246    .await?
247    .rows_affected()
248        > 0;
249
250    if changed {
251        record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD).await?;
252    }
253
254    Ok(EpicLinkOutcome {
255        epic: get_task_in_workspace(conn, workspace, &pair.epic.id).await?,
256        child: pair.child,
257        changed,
258    })
259}
260
261pub(crate) async fn restore_task_to_epic_in_transaction(
262    conn: &mut SqliteConnection,
263    workspace: &Workspace,
264    child_id: &crate::ids::TaskId,
265    epic_id: &crate::ids::TaskId,
266) -> Result<EpicLinkOutcome> {
267    let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
268    let existing_epic_id = sqlx::query_scalar::<_, crate::ids::TaskId>(
269        "SELECT epic_task_id FROM task_epic_links WHERE workspace_id = ? AND child_task_id = ?",
270    )
271    .bind(&pair.child.workspace_id)
272    .bind(&pair.child.id)
273    .fetch_optional(&mut *conn)
274    .await?;
275    if let Some(existing_epic_id) = existing_epic_id
276        && existing_epic_id != pair.epic.id
277    {
278        bail!(
279            "error epic-child-already-linked child_task_id={} epic_task_id={}",
280            pair.child.id,
281            existing_epic_id
282        );
283    }
284    let changed = sqlx::query(
285        "INSERT OR IGNORE INTO task_epic_links(workspace_id, epic_task_id, child_task_id, created_at)
286         VALUES (?, ?, ?, ?)",
287    )
288    .bind(&pair.child.workspace_id)
289    .bind(&pair.epic.id)
290    .bind(&pair.child.id)
291    .bind(now())
292    .execute(&mut *conn)
293    .await?
294    .rows_affected()
295        > 0;
296    if changed {
297        record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD).await?;
298    }
299    Ok(EpicLinkOutcome {
300        epic: pair.epic,
301        child: pair.child,
302        changed,
303    })
304}
305
306pub(crate) async fn remove_task_from_epic_in_transaction(
307    conn: &mut SqliteConnection,
308    workspace: &Workspace,
309    child_id: &crate::ids::TaskId,
310    epic_id: &crate::ids::TaskId,
311) -> Result<EpicLinkOutcome> {
312    crate::operations::route_recurrence_task_field(
313        conn,
314        workspace,
315        child_id,
316        "epic_membership",
317        "",
318    )
319    .await?;
320    crate::operations::route_recurrence_task_field(conn, workspace, epic_id, "epic_membership", "")
321        .await?;
322    let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
323    let changed = sqlx::query(
324        "DELETE FROM task_epic_links
325         WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
326    )
327    .bind(&pair.child.workspace_id)
328    .bind(&pair.epic.id)
329    .bind(&pair.child.id)
330    .execute(&mut *conn)
331    .await?
332    .rows_affected()
333        > 0;
334
335    if changed {
336        record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_REMOVE).await?;
337    }
338
339    Ok(EpicLinkOutcome {
340        epic: pair.epic,
341        child: pair.child,
342        changed,
343    })
344}
345
346pub async fn task_has_epic_children(
347    conn: &mut SqliteConnection,
348    workspace_id: &WorkspaceId,
349    task_id: &crate::ids::TaskId,
350) -> Result<bool> {
351    Ok(sqlx::query_scalar::<_, i64>(
352        "SELECT count(*) FROM task_epic_links WHERE workspace_id = ? AND epic_task_id = ? LIMIT 1",
353    )
354    .bind(workspace_id)
355    .bind(task_id)
356    .fetch_one(&mut *conn)
357    .await?
358        > 0)
359}