Skip to main content

aven_core/operations/
epics.rs

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