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::workspaces::Workspace;
12
13impl Database {
14    pub async fn add_task_to_epic(
15        &self,
16        workspace: &Workspace,
17        child_id: &TaskId,
18        epic_id: &TaskId,
19    ) -> Result<EpicLinkOutcome> {
20        let mut conn = self.acquire().await?;
21        add_task_to_epic(&mut conn, workspace, child_id, epic_id).await
22    }
23
24    pub async fn remove_task_from_epic(
25        &self,
26        workspace: &Workspace,
27        child_id: &TaskId,
28        epic_id: &TaskId,
29    ) -> Result<EpicLinkOutcome> {
30        let mut conn = self.acquire().await?;
31        remove_task_from_epic(&mut conn, workspace, child_id, epic_id).await
32    }
33}
34
35pub struct EpicLinkOutcome {
36    pub epic: Task,
37    pub child: Task,
38    pub changed: bool,
39}
40
41struct EpicPair {
42    epic: Task,
43    child: Task,
44}
45
46async fn load_epic_pair(
47    conn: &mut SqliteConnection,
48    workspace: &Workspace,
49    child_id: &crate::ids::TaskId,
50    epic_id: &crate::ids::TaskId,
51) -> Result<EpicPair> {
52    if child_id == epic_id {
53        bail!("error epic-self task_id={child_id}");
54    }
55
56    let child = get_task_in_workspace(conn, workspace, child_id).await?;
57    let epic = get_task_in_workspace(conn, workspace, epic_id).await?;
58    if child.project_id != epic.project_id {
59        bail!("error epic-cross-project child_task_id={child_id} epic_task_id={epic_id}");
60    }
61    if child.is_epic {
62        bail!("error epic-child-is-epic child_task_id={child_id}");
63    }
64
65    Ok(EpicPair { epic, child })
66}
67
68async fn record_epic_change(
69    conn: &mut SqliteConnection,
70    workspace: &Workspace,
71    pair: &EpicPair,
72    op_type: &'static str,
73) -> Result<()> {
74    append_change(
75        conn,
76        ChangeEntity::Task,
77        &pair.child.id,
78        Some("epics"),
79        op_type,
80        ChangePayload::workspace(workspace)
81            .set("epic_task_id", pair.epic.id.clone())
82            .set("created_at", now()),
83    )
84    .await?;
85    Ok(())
86}
87
88async fn mark_task_as_epic(
89    conn: &mut SqliteConnection,
90    workspace: &Workspace,
91    task: &Task,
92) -> Result<()> {
93    if task.is_epic {
94        return Ok(());
95    }
96    let field = TaskField::IsEpic.as_str();
97    let base = field_version(conn, &task.id, field).await?;
98    let ts = now();
99    sqlx::query("UPDATE tasks SET is_epic = 1, updated_at = ? WHERE workspace_id = ? AND id = ?")
100        .bind(&ts)
101        .bind(&task.workspace_id)
102        .bind(&task.id)
103        .execute(&mut *conn)
104        .await?;
105    let change_id = insert_change(
106        conn,
107        ChangeEntity::Task.as_str(),
108        &task.id,
109        Some(field),
110        op_type::SET_FIELD,
111        TaskField::IsEpic.scalar_payload(&workspace.id, &workspace.key, "1")?,
112        base.as_deref(),
113    )
114    .await?;
115    set_field_version(conn, &task.id, field, &change_id).await?;
116    Ok(())
117}
118
119pub async fn add_task_to_epic(
120    conn: &mut SqliteConnection,
121    workspace: &Workspace,
122    child_id: &crate::ids::TaskId,
123    epic_id: &crate::ids::TaskId,
124) -> Result<EpicLinkOutcome> {
125    let mut tx = begin_immediate(conn).await?;
126    let pair = load_epic_pair(&mut tx, workspace, child_id, epic_id).await?;
127    let ts = now();
128    if !pair.epic.is_epic {
129        mark_task_as_epic(&mut tx, workspace, &pair.epic).await?;
130    }
131    let existing_epic_id = sqlx::query_scalar::<_, crate::ids::TaskId>(
132        "SELECT epic_task_id FROM task_epic_links WHERE workspace_id = ? AND child_task_id = ?",
133    )
134    .bind(&pair.child.workspace_id)
135    .bind(&pair.child.id)
136    .fetch_optional(&mut *tx)
137    .await?;
138    if let Some(existing_epic_id) = existing_epic_id
139        && existing_epic_id != pair.epic.id
140    {
141        bail!(
142            "error epic-child-already-linked child_task_id={} epic_task_id={}",
143            pair.child.id,
144            existing_epic_id
145        );
146    }
147    let changed = sqlx::query(
148        "INSERT OR IGNORE INTO task_epic_links(workspace_id, epic_task_id, child_task_id, created_at)
149         VALUES (?, ?, ?, ?)",
150    )
151    .bind(&pair.child.workspace_id)
152    .bind(&pair.epic.id)
153    .bind(&pair.child.id)
154    .bind(&ts)
155    .execute(&mut *tx)
156    .await?
157    .rows_affected()
158        > 0;
159
160    if changed {
161        record_epic_change(&mut tx, workspace, &pair, op_type::EPIC_LINK_ADD).await?;
162    }
163
164    tx.commit().await?;
165    Ok(EpicLinkOutcome {
166        epic: get_task_in_workspace(conn, workspace, &pair.epic.id).await?,
167        child: pair.child,
168        changed,
169    })
170}
171
172pub async fn remove_task_from_epic(
173    conn: &mut SqliteConnection,
174    workspace: &Workspace,
175    child_id: &crate::ids::TaskId,
176    epic_id: &crate::ids::TaskId,
177) -> Result<EpicLinkOutcome> {
178    let mut tx = begin_immediate(conn).await?;
179    let pair = load_epic_pair(&mut tx, workspace, child_id, epic_id).await?;
180    let changed = sqlx::query(
181        "DELETE FROM task_epic_links
182         WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
183    )
184    .bind(&pair.child.workspace_id)
185    .bind(&pair.epic.id)
186    .bind(&pair.child.id)
187    .execute(&mut *tx)
188    .await?
189    .rows_affected()
190        > 0;
191
192    if changed {
193        record_epic_change(&mut tx, workspace, &pair, op_type::EPIC_LINK_REMOVE).await?;
194    }
195
196    tx.commit().await?;
197    Ok(EpicLinkOutcome {
198        epic: pair.epic,
199        child: pair.child,
200        changed,
201    })
202}
203
204pub async fn task_has_epic_children(
205    conn: &mut SqliteConnection,
206    workspace_id: &WorkspaceId,
207    task_id: &crate::ids::TaskId,
208) -> Result<bool> {
209    Ok(sqlx::query_scalar::<_, i64>(
210        "SELECT count(*) FROM task_epic_links WHERE workspace_id = ? AND epic_task_id = ? LIMIT 1",
211    )
212    .bind(workspace_id)
213    .bind(task_id)
214    .fetch_one(&mut *conn)
215    .await?
216        > 0)
217}