Skip to main content

aven_core/operations/
epics.rs

1use crate::error::CoreError;
2use crate::ids::WorkspaceId;
3use crate::operations::{RecurrenceStructuralMutation, RecurrenceTaskMutation};
4use anyhow::Result;
5use sqlx::SqliteConnection;
6
7use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
8use crate::db::{Database, begin_immediate, field_version, insert_change, set_field_version};
9use crate::ids::{TaskId, now};
10use crate::refs::get_task_in_workspace;
11use crate::task_fields::TaskField;
12use crate::types::Task;
13use crate::undo::{UndoCommand, UndoContext, UndoPayload, record_tui_undo};
14use crate::workspaces::Workspace;
15
16impl Database {
17    pub async fn add_task_to_epic(
18        &self,
19        workspace: &Workspace,
20        child_id: &TaskId,
21        epic_id: &TaskId,
22    ) -> Result<EpicLinkOutcome> {
23        self.add_task_to_epic_with_undo(workspace, child_id, epic_id, UndoContext::None)
24            .await
25    }
26
27    pub async fn add_task_to_epic_with_undo(
28        &self,
29        workspace: &Workspace,
30        child_id: &TaskId,
31        epic_id: &TaskId,
32        undo: UndoContext,
33    ) -> Result<EpicLinkOutcome> {
34        let mut conn = self.acquire_writer().await?;
35        let mut tx = begin_immediate(&mut conn).await?;
36        let outcome =
37            add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
38        if outcome.changed
39            && let UndoContext::Tui = undo
40        {
41            let mut commands = vec![UndoCommand::AddEpicChild {
42                epic_id: outcome.epic.id.clone(),
43                child_id: outcome.child.id.clone(),
44            }];
45            if outcome.promoted {
46                commands.push(UndoCommand::SetTaskField {
47                    task_id: outcome.epic.id.clone(),
48                    field: TaskField::IsEpic.as_str().to_string(),
49                    before: "0".to_string(),
50                    after: "1".to_string(),
51                    queue_activity_before: None,
52                    queue_activity_after: None,
53                });
54            }
55            record_tui_undo(&mut tx, &workspace.id, UndoPayload { commands }).await?;
56        }
57        tx.commit().await?;
58        Ok(outcome)
59    }
60
61    pub async fn remove_task_from_epic(
62        &self,
63        workspace: &Workspace,
64        child_id: &TaskId,
65        epic_id: &TaskId,
66    ) -> Result<EpicLinkOutcome> {
67        self.remove_task_from_epic_with_undo(workspace, child_id, epic_id, UndoContext::None)
68            .await
69    }
70
71    pub async fn remove_task_from_epic_with_undo(
72        &self,
73        workspace: &Workspace,
74        child_id: &TaskId,
75        epic_id: &TaskId,
76        undo: UndoContext,
77    ) -> Result<EpicLinkOutcome> {
78        let mut conn = self.acquire_writer().await?;
79        let mut tx = begin_immediate(&mut conn).await?;
80        let outcome =
81            remove_task_from_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
82        if outcome.changed
83            && let UndoContext::Tui = undo
84        {
85            record_tui_undo(
86                &mut tx,
87                &workspace.id,
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        return Err(CoreError::validation(format!("error epic-self task_id={child_id}")).into());
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        return Err(CoreError::validation(format!(
145            "error epic-child-already-linked child_task_id={} epic_task_id={}",
146            pair.child.id, existing_epic_id
147        ))
148        .into());
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    at: &str,
171) -> Result<()> {
172    append_change(
173        conn,
174        ChangeEntity::Task,
175        &pair.child.id,
176        Some("epics"),
177        op_type,
178        ChangePayload::workspace(workspace)
179            .set("epic_task_id", pair.epic.id.clone())
180            .set("created_at", at),
181    )
182    .await?;
183    Ok(())
184}
185
186async fn mark_task_as_epic(
187    conn: &mut SqliteConnection,
188    workspace: &Workspace,
189    task: &Task,
190) -> Result<()> {
191    if task.is_epic {
192        return Ok(());
193    }
194    let field = TaskField::IsEpic.as_str();
195    let base = field_version(conn, &task.id, field).await?;
196    let ts = now();
197    sqlx::query("UPDATE tasks SET is_epic = 1, updated_at = ? WHERE workspace_id = ? AND id = ?")
198        .bind(&ts)
199        .bind(&task.workspace_id)
200        .bind(&task.id)
201        .execute(&mut *conn)
202        .await?;
203    let change_id = insert_change(
204        conn,
205        ChangeEntity::Task.as_str(),
206        &task.id,
207        Some(field),
208        op_type::SET_FIELD,
209        TaskField::IsEpic.scalar_payload(&workspace.id, &workspace.key, "1")?,
210        base.as_deref(),
211    )
212    .await?;
213    set_field_version(conn, &task.id, field, &change_id).await?;
214    Ok(())
215}
216
217#[cfg(any(test, feature = "test-support"))]
218pub async fn add_task_to_epic(
219    conn: &mut SqliteConnection,
220    workspace: &Workspace,
221    child_id: &crate::ids::TaskId,
222    epic_id: &crate::ids::TaskId,
223) -> Result<EpicLinkOutcome> {
224    let mut tx = begin_immediate(conn).await?;
225    let outcome = add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
226    tx.commit().await?;
227    Ok(outcome)
228}
229
230pub(crate) async fn add_task_to_epic_in_transaction(
231    conn: &mut SqliteConnection,
232    workspace: &Workspace,
233    child_id: &crate::ids::TaskId,
234    epic_id: &crate::ids::TaskId,
235) -> Result<EpicLinkOutcome> {
236    let ts = now();
237    crate::operations::route_recurrence_task_mutation(
238        conn,
239        workspace,
240        child_id,
241        RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::EpicMembership),
242        &ts,
243    )
244    .await?;
245    crate::operations::route_recurrence_task_mutation(
246        conn,
247        workspace,
248        epic_id,
249        RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::EpicMembership),
250        &ts,
251    )
252    .await?;
253    let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
254    if pair.child.project_id != pair.epic.project_id {
255        return Err(CoreError::validation(format!(
256            "error epic-cross-project child_task_id={child_id} epic_task_id={epic_id}"
257        ))
258        .into());
259    }
260    if pair.child.is_epic {
261        return Err(CoreError::validation(format!(
262            "error epic-child-is-epic child_task_id={child_id}"
263        ))
264        .into());
265    }
266    if pair.child.deleted {
267        return Err(CoreError::validation(format!(
268            "error epic-child-deleted child_task_id={child_id}"
269        ))
270        .into());
271    }
272    if pair.epic.deleted {
273        return Err(CoreError::validation(format!(
274            "error epic-parent-deleted epic_task_id={epic_id}"
275        ))
276        .into());
277    }
278    crate::epic_membership::capture_snapshot_baseline(conn, workspace.id.as_str(), child_id)
279        .await?;
280    let promoted = !pair.epic.is_epic;
281    if promoted {
282        mark_task_as_epic(conn, workspace, &pair.epic).await?;
283    }
284    let changed = insert_epic_link_if_absent(conn, &pair, &ts).await?;
285
286    if changed {
287        record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD, &ts).await?;
288    }
289
290    Ok(EpicLinkOutcome {
291        epic: get_task_in_workspace(conn, workspace, &pair.epic.id).await?,
292        child: pair.child,
293        changed,
294        promoted,
295    })
296}
297
298pub(crate) async fn restore_task_to_epic_in_transaction(
299    conn: &mut SqliteConnection,
300    workspace: &Workspace,
301    child_id: &crate::ids::TaskId,
302    epic_id: &crate::ids::TaskId,
303) -> Result<EpicLinkOutcome> {
304    let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
305    crate::epic_membership::capture_snapshot_baseline(conn, workspace.id.as_str(), child_id)
306        .await?;
307    let ts = now();
308    let changed = insert_epic_link_if_absent(conn, &pair, &ts).await?;
309    if changed {
310        record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD, &ts).await?;
311    }
312    Ok(EpicLinkOutcome {
313        epic: pair.epic,
314        child: pair.child,
315        changed,
316        promoted: false,
317    })
318}
319
320pub(crate) async fn remove_task_from_epic_in_transaction(
321    conn: &mut SqliteConnection,
322    workspace: &Workspace,
323    child_id: &crate::ids::TaskId,
324    epic_id: &crate::ids::TaskId,
325) -> Result<EpicLinkOutcome> {
326    let mutation_at = now();
327    crate::operations::route_recurrence_task_mutation(
328        conn,
329        workspace,
330        child_id,
331        RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::EpicMembership),
332        &mutation_at,
333    )
334    .await?;
335    crate::operations::route_recurrence_task_mutation(
336        conn,
337        workspace,
338        epic_id,
339        RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::EpicMembership),
340        &mutation_at,
341    )
342    .await?;
343    let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
344    crate::epic_membership::capture_snapshot_baseline(conn, workspace.id.as_str(), child_id)
345        .await?;
346    let changed = sqlx::query(
347        "DELETE FROM task_epic_links
348         WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
349    )
350    .bind(&pair.child.workspace_id)
351    .bind(&pair.epic.id)
352    .bind(&pair.child.id)
353    .execute(&mut *conn)
354    .await?
355    .rows_affected()
356        > 0;
357
358    if changed {
359        record_epic_change(
360            conn,
361            workspace,
362            &pair,
363            op_type::EPIC_LINK_REMOVE,
364            &mutation_at,
365        )
366        .await?;
367    }
368
369    Ok(EpicLinkOutcome {
370        epic: pair.epic,
371        child: pair.child,
372        changed,
373        promoted: false,
374    })
375}
376
377pub async fn task_has_epic_children(
378    conn: &mut SqliteConnection,
379    workspace_id: &WorkspaceId,
380    task_id: &crate::ids::TaskId,
381) -> Result<bool> {
382    Ok(sqlx::query_scalar::<_, i64>(
383        "SELECT count(*) FROM task_epic_links WHERE workspace_id = ? AND epic_task_id = ? LIMIT 1",
384    )
385    .bind(workspace_id)
386    .bind(task_id)
387    .fetch_one(&mut *conn)
388    .await?
389        > 0)
390}
391
392pub(crate) async fn require_consumer_epic(
393    conn: &mut SqliteConnection,
394    workspace: &Workspace,
395    epic_id: &TaskId,
396) -> Result<()> {
397    let epic = get_task_in_workspace(conn, workspace, epic_id).await?;
398    if epic.deleted || !epic.is_epic {
399        return Err(CoreError::validation("epic is unavailable".to_string()).into());
400    }
401    Ok(())
402}
403
404impl Database {
405    pub(crate) async fn set_epic_child(
406        &self,
407        workspace: &Workspace,
408        epic_id: &TaskId,
409        child_id: &TaskId,
410        linked: bool,
411    ) -> Result<bool> {
412        let mut conn = self.acquire_writer().await?;
413        let mut tx = begin_immediate(&mut conn).await?;
414        require_consumer_epic(&mut tx, workspace, epic_id).await?;
415        let outcome = if linked {
416            add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?
417        } else {
418            remove_task_from_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?
419        };
420        tx.commit().await?;
421        Ok(outcome.changed)
422    }
423}