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 { summary } = 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, &summary, 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 { summary } = undo
84        {
85            record_tui_undo(
86                &mut tx,
87                &workspace.id,
88                &summary,
89                UndoPayload {
90                    commands: vec![UndoCommand::RemoveEpicChild {
91                        epic_id: outcome.epic.id.clone(),
92                        child_id: outcome.child.id.clone(),
93                    }],
94                },
95            )
96            .await?;
97        }
98        tx.commit().await?;
99        Ok(outcome)
100    }
101}
102
103pub struct EpicLinkOutcome {
104    pub epic: Task,
105    pub child: Task,
106    pub changed: bool,
107    pub promoted: bool,
108}
109
110struct EpicPair {
111    epic: Task,
112    child: Task,
113}
114
115async fn load_epic_pair(
116    conn: &mut SqliteConnection,
117    workspace: &Workspace,
118    child_id: &crate::ids::TaskId,
119    epic_id: &crate::ids::TaskId,
120) -> Result<EpicPair> {
121    if child_id == epic_id {
122        return Err(CoreError::validation(format!("error epic-self task_id={child_id}")).into());
123    }
124
125    let child = get_task_in_workspace(conn, workspace, child_id).await?;
126    let epic = get_task_in_workspace(conn, workspace, epic_id).await?;
127    Ok(EpicPair { epic, child })
128}
129
130async fn insert_epic_link_if_absent(
131    conn: &mut SqliteConnection,
132    pair: &EpicPair,
133    created_at: &str,
134) -> Result<bool> {
135    let existing_epic_id = sqlx::query_scalar::<_, crate::ids::TaskId>(
136        "SELECT epic_task_id FROM task_epic_links WHERE workspace_id = ? AND child_task_id = ?",
137    )
138    .bind(&pair.child.workspace_id)
139    .bind(&pair.child.id)
140    .fetch_optional(&mut *conn)
141    .await?;
142    if let Some(existing_epic_id) = existing_epic_id
143        && existing_epic_id != pair.epic.id
144    {
145        return Err(CoreError::validation(format!(
146            "error epic-child-already-linked child_task_id={} epic_task_id={}",
147            pair.child.id, existing_epic_id
148        ))
149        .into());
150    }
151
152    Ok(sqlx::query(
153        "INSERT OR IGNORE INTO task_epic_links(workspace_id, epic_task_id, child_task_id, created_at)
154         VALUES (?, ?, ?, ?)",
155    )
156    .bind(&pair.child.workspace_id)
157    .bind(&pair.epic.id)
158    .bind(&pair.child.id)
159    .bind(created_at)
160    .execute(&mut *conn)
161    .await?
162    .rows_affected()
163        > 0)
164}
165
166async fn record_epic_change(
167    conn: &mut SqliteConnection,
168    workspace: &Workspace,
169    pair: &EpicPair,
170    op_type: &'static 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", now()),
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).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).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(conn, workspace, &pair, op_type::EPIC_LINK_REMOVE).await?;
360    }
361
362    Ok(EpicLinkOutcome {
363        epic: pair.epic,
364        child: pair.child,
365        changed,
366        promoted: false,
367    })
368}
369
370pub async fn task_has_epic_children(
371    conn: &mut SqliteConnection,
372    workspace_id: &WorkspaceId,
373    task_id: &crate::ids::TaskId,
374) -> Result<bool> {
375    Ok(sqlx::query_scalar::<_, i64>(
376        "SELECT count(*) FROM task_epic_links WHERE workspace_id = ? AND epic_task_id = ? LIMIT 1",
377    )
378    .bind(workspace_id)
379    .bind(task_id)
380    .fetch_one(&mut *conn)
381    .await?
382        > 0)
383}
384
385pub(crate) async fn require_ios_epic(
386    conn: &mut SqliteConnection,
387    workspace: &Workspace,
388    epic_id: &TaskId,
389) -> Result<()> {
390    let epic = get_task_in_workspace(conn, workspace, epic_id).await?;
391    if epic.deleted || !epic.is_epic {
392        return Err(CoreError::validation("epic is unavailable".to_string()).into());
393    }
394    Ok(())
395}
396
397impl Database {
398    pub(crate) async fn set_ios_epic_child(
399        &self,
400        workspace: &Workspace,
401        epic_id: &TaskId,
402        child_id: &TaskId,
403        linked: bool,
404    ) -> Result<bool> {
405        let mut conn = self.acquire_writer().await?;
406        let mut tx = begin_immediate(&mut conn).await?;
407        require_ios_epic(&mut tx, workspace, epic_id).await?;
408        let outcome = if linked {
409            add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?
410        } else {
411            remove_task_from_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?
412        };
413        tx.commit().await?;
414        Ok(outcome.changed)
415    }
416}