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