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