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 record_epic_change(
127    conn: &mut SqliteConnection,
128    workspace: &Workspace,
129    pair: &EpicPair,
130    op_type: &'static str,
131) -> Result<()> {
132    append_change(
133        conn,
134        ChangeEntity::Task,
135        &pair.child.id,
136        Some("epics"),
137        op_type,
138        ChangePayload::workspace(workspace)
139            .set("epic_task_id", pair.epic.id.clone())
140            .set("created_at", now()),
141    )
142    .await?;
143    Ok(())
144}
145
146async fn mark_task_as_epic(
147    conn: &mut SqliteConnection,
148    workspace: &Workspace,
149    task: &Task,
150) -> Result<()> {
151    if task.is_epic {
152        return Ok(());
153    }
154    let field = TaskField::IsEpic.as_str();
155    let base = field_version(conn, &task.id, field).await?;
156    let ts = now();
157    sqlx::query("UPDATE tasks SET is_epic = 1, updated_at = ? WHERE workspace_id = ? AND id = ?")
158        .bind(&ts)
159        .bind(&task.workspace_id)
160        .bind(&task.id)
161        .execute(&mut *conn)
162        .await?;
163    let change_id = insert_change(
164        conn,
165        ChangeEntity::Task.as_str(),
166        &task.id,
167        Some(field),
168        op_type::SET_FIELD,
169        TaskField::IsEpic.scalar_payload(&workspace.id, &workspace.key, "1")?,
170        base.as_deref(),
171    )
172    .await?;
173    set_field_version(conn, &task.id, field, &change_id).await?;
174    Ok(())
175}
176
177#[cfg(any(test, feature = "test-support"))]
178pub async fn add_task_to_epic(
179    conn: &mut SqliteConnection,
180    workspace: &Workspace,
181    child_id: &crate::ids::TaskId,
182    epic_id: &crate::ids::TaskId,
183) -> Result<EpicLinkOutcome> {
184    let mut tx = begin_immediate(conn).await?;
185    let outcome = add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
186    tx.commit().await?;
187    Ok(outcome)
188}
189
190pub(crate) async fn add_task_to_epic_in_transaction(
191    conn: &mut SqliteConnection,
192    workspace: &Workspace,
193    child_id: &crate::ids::TaskId,
194    epic_id: &crate::ids::TaskId,
195) -> Result<EpicLinkOutcome> {
196    crate::operations::route_recurrence_task_field(
197        conn,
198        workspace,
199        child_id,
200        "epic_membership",
201        "",
202    )
203    .await?;
204    crate::operations::route_recurrence_task_field(conn, workspace, epic_id, "epic_membership", "")
205        .await?;
206    let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
207    if pair.child.project_id != pair.epic.project_id {
208        bail!("error epic-cross-project child_task_id={child_id} epic_task_id={epic_id}");
209    }
210    if pair.child.is_epic {
211        bail!("error epic-child-is-epic child_task_id={child_id}");
212    }
213    if pair.child.deleted {
214        bail!("error epic-child-deleted child_task_id={child_id}");
215    }
216    if pair.epic.deleted {
217        bail!("error epic-parent-deleted epic_task_id={epic_id}");
218    }
219    let ts = now();
220    let promoted = !pair.epic.is_epic;
221    if promoted {
222        mark_task_as_epic(conn, workspace, &pair.epic).await?;
223    }
224    let existing_epic_id = sqlx::query_scalar::<_, crate::ids::TaskId>(
225        "SELECT epic_task_id FROM task_epic_links WHERE workspace_id = ? AND child_task_id = ?",
226    )
227    .bind(&pair.child.workspace_id)
228    .bind(&pair.child.id)
229    .fetch_optional(&mut *conn)
230    .await?;
231    if let Some(existing_epic_id) = existing_epic_id
232        && existing_epic_id != pair.epic.id
233    {
234        bail!(
235            "error epic-child-already-linked child_task_id={} epic_task_id={}",
236            pair.child.id,
237            existing_epic_id
238        );
239    }
240    let changed = sqlx::query(
241        "INSERT OR IGNORE INTO task_epic_links(workspace_id, epic_task_id, child_task_id, created_at)
242         VALUES (?, ?, ?, ?)",
243    )
244    .bind(&pair.child.workspace_id)
245    .bind(&pair.epic.id)
246    .bind(&pair.child.id)
247    .bind(&ts)
248    .execute(&mut *conn)
249    .await?
250    .rows_affected()
251        > 0;
252
253    if changed {
254        record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD).await?;
255    }
256
257    Ok(EpicLinkOutcome {
258        epic: get_task_in_workspace(conn, workspace, &pair.epic.id).await?,
259        child: pair.child,
260        changed,
261        promoted,
262    })
263}
264
265pub(crate) async fn restore_task_to_epic_in_transaction(
266    conn: &mut SqliteConnection,
267    workspace: &Workspace,
268    child_id: &crate::ids::TaskId,
269    epic_id: &crate::ids::TaskId,
270) -> Result<EpicLinkOutcome> {
271    let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
272    let existing_epic_id = sqlx::query_scalar::<_, crate::ids::TaskId>(
273        "SELECT epic_task_id FROM task_epic_links WHERE workspace_id = ? AND child_task_id = ?",
274    )
275    .bind(&pair.child.workspace_id)
276    .bind(&pair.child.id)
277    .fetch_optional(&mut *conn)
278    .await?;
279    if let Some(existing_epic_id) = existing_epic_id
280        && existing_epic_id != pair.epic.id
281    {
282        bail!(
283            "error epic-child-already-linked child_task_id={} epic_task_id={}",
284            pair.child.id,
285            existing_epic_id
286        );
287    }
288    let changed = sqlx::query(
289        "INSERT OR IGNORE INTO task_epic_links(workspace_id, epic_task_id, child_task_id, created_at)
290         VALUES (?, ?, ?, ?)",
291    )
292    .bind(&pair.child.workspace_id)
293    .bind(&pair.epic.id)
294    .bind(&pair.child.id)
295    .bind(now())
296    .execute(&mut *conn)
297    .await?
298    .rows_affected()
299        > 0;
300    if changed {
301        record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD).await?;
302    }
303    Ok(EpicLinkOutcome {
304        epic: pair.epic,
305        child: pair.child,
306        changed,
307        promoted: false,
308    })
309}
310
311pub(crate) async fn remove_task_from_epic_in_transaction(
312    conn: &mut SqliteConnection,
313    workspace: &Workspace,
314    child_id: &crate::ids::TaskId,
315    epic_id: &crate::ids::TaskId,
316) -> Result<EpicLinkOutcome> {
317    crate::operations::route_recurrence_task_field(
318        conn,
319        workspace,
320        child_id,
321        "epic_membership",
322        "",
323    )
324    .await?;
325    crate::operations::route_recurrence_task_field(conn, workspace, epic_id, "epic_membership", "")
326        .await?;
327    let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
328    let changed = sqlx::query(
329        "DELETE FROM task_epic_links
330         WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
331    )
332    .bind(&pair.child.workspace_id)
333    .bind(&pair.epic.id)
334    .bind(&pair.child.id)
335    .execute(&mut *conn)
336    .await?
337    .rows_affected()
338        > 0;
339
340    if changed {
341        record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_REMOVE).await?;
342    }
343
344    Ok(EpicLinkOutcome {
345        epic: pair.epic,
346        child: pair.child,
347        changed,
348        promoted: false,
349    })
350}
351
352pub async fn task_has_epic_children(
353    conn: &mut SqliteConnection,
354    workspace_id: &WorkspaceId,
355    task_id: &crate::ids::TaskId,
356) -> Result<bool> {
357    Ok(sqlx::query_scalar::<_, i64>(
358        "SELECT count(*) FROM task_epic_links WHERE workspace_id = ? AND epic_task_id = ? LIMIT 1",
359    )
360    .bind(workspace_id)
361    .bind(task_id)
362    .fetch_one(&mut *conn)
363    .await?
364        > 0)
365}