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    Ok(EpicPair { epic, child })
59}
60
61async fn record_epic_change(
62    conn: &mut SqliteConnection,
63    workspace: &Workspace,
64    pair: &EpicPair,
65    op_type: &'static str,
66) -> Result<()> {
67    append_change(
68        conn,
69        ChangeEntity::Task,
70        &pair.child.id,
71        Some("epics"),
72        op_type,
73        ChangePayload::workspace(workspace)
74            .set("epic_task_id", pair.epic.id.clone())
75            .set("created_at", now()),
76    )
77    .await?;
78    Ok(())
79}
80
81async fn mark_task_as_epic(
82    conn: &mut SqliteConnection,
83    workspace: &Workspace,
84    task: &Task,
85) -> Result<()> {
86    if task.is_epic {
87        return Ok(());
88    }
89    let field = TaskField::IsEpic.as_str();
90    let base = field_version(conn, &task.id, field).await?;
91    let ts = now();
92    sqlx::query("UPDATE tasks SET is_epic = 1, updated_at = ? WHERE workspace_id = ? AND id = ?")
93        .bind(&ts)
94        .bind(&task.workspace_id)
95        .bind(&task.id)
96        .execute(&mut *conn)
97        .await?;
98    let change_id = insert_change(
99        conn,
100        ChangeEntity::Task.as_str(),
101        &task.id,
102        Some(field),
103        op_type::SET_FIELD,
104        TaskField::IsEpic.scalar_payload(&workspace.id, &workspace.key, "1")?,
105        base.as_deref(),
106    )
107    .await?;
108    set_field_version(conn, &task.id, field, &change_id).await?;
109    Ok(())
110}
111
112pub async fn add_task_to_epic(
113    conn: &mut SqliteConnection,
114    workspace: &Workspace,
115    child_id: &crate::ids::TaskId,
116    epic_id: &crate::ids::TaskId,
117) -> Result<EpicLinkOutcome> {
118    let mut tx = begin_immediate(conn).await?;
119    let outcome = add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
120    tx.commit().await?;
121    Ok(outcome)
122}
123
124pub(crate) async fn add_task_to_epic_in_transaction(
125    conn: &mut SqliteConnection,
126    workspace: &Workspace,
127    child_id: &crate::ids::TaskId,
128    epic_id: &crate::ids::TaskId,
129) -> Result<EpicLinkOutcome> {
130    let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
131    if pair.child.project_id != pair.epic.project_id {
132        bail!("error epic-cross-project child_task_id={child_id} epic_task_id={epic_id}");
133    }
134    if pair.child.is_epic {
135        bail!("error epic-child-is-epic child_task_id={child_id}");
136    }
137    if pair.child.deleted {
138        bail!("error epic-child-deleted child_task_id={child_id}");
139    }
140    if pair.epic.deleted {
141        bail!("error epic-parent-deleted epic_task_id={epic_id}");
142    }
143    let ts = now();
144    if !pair.epic.is_epic {
145        mark_task_as_epic(conn, workspace, &pair.epic).await?;
146    }
147    let existing_epic_id = sqlx::query_scalar::<_, crate::ids::TaskId>(
148        "SELECT epic_task_id FROM task_epic_links WHERE workspace_id = ? AND child_task_id = ?",
149    )
150    .bind(&pair.child.workspace_id)
151    .bind(&pair.child.id)
152    .fetch_optional(&mut *conn)
153    .await?;
154    if let Some(existing_epic_id) = existing_epic_id
155        && existing_epic_id != pair.epic.id
156    {
157        bail!(
158            "error epic-child-already-linked child_task_id={} epic_task_id={}",
159            pair.child.id,
160            existing_epic_id
161        );
162    }
163    let changed = sqlx::query(
164        "INSERT OR IGNORE INTO task_epic_links(workspace_id, epic_task_id, child_task_id, created_at)
165         VALUES (?, ?, ?, ?)",
166    )
167    .bind(&pair.child.workspace_id)
168    .bind(&pair.epic.id)
169    .bind(&pair.child.id)
170    .bind(&ts)
171    .execute(&mut *conn)
172    .await?
173    .rows_affected()
174        > 0;
175
176    if changed {
177        record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD).await?;
178    }
179
180    Ok(EpicLinkOutcome {
181        epic: get_task_in_workspace(conn, workspace, &pair.epic.id).await?,
182        child: pair.child,
183        changed,
184    })
185}
186
187pub(crate) async fn restore_task_to_epic_in_transaction(
188    conn: &mut SqliteConnection,
189    workspace: &Workspace,
190    child_id: &crate::ids::TaskId,
191    epic_id: &crate::ids::TaskId,
192) -> Result<EpicLinkOutcome> {
193    let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
194    let existing_epic_id = sqlx::query_scalar::<_, crate::ids::TaskId>(
195        "SELECT epic_task_id FROM task_epic_links WHERE workspace_id = ? AND child_task_id = ?",
196    )
197    .bind(&pair.child.workspace_id)
198    .bind(&pair.child.id)
199    .fetch_optional(&mut *conn)
200    .await?;
201    if let Some(existing_epic_id) = existing_epic_id
202        && existing_epic_id != pair.epic.id
203    {
204        bail!(
205            "error epic-child-already-linked child_task_id={} epic_task_id={}",
206            pair.child.id,
207            existing_epic_id
208        );
209    }
210    let changed = sqlx::query(
211        "INSERT OR IGNORE INTO task_epic_links(workspace_id, epic_task_id, child_task_id, created_at)
212         VALUES (?, ?, ?, ?)",
213    )
214    .bind(&pair.child.workspace_id)
215    .bind(&pair.epic.id)
216    .bind(&pair.child.id)
217    .bind(now())
218    .execute(&mut *conn)
219    .await?
220    .rows_affected()
221        > 0;
222    if changed {
223        record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD).await?;
224    }
225    Ok(EpicLinkOutcome {
226        epic: pair.epic,
227        child: pair.child,
228        changed,
229    })
230}
231
232pub async fn remove_task_from_epic(
233    conn: &mut SqliteConnection,
234    workspace: &Workspace,
235    child_id: &crate::ids::TaskId,
236    epic_id: &crate::ids::TaskId,
237) -> Result<EpicLinkOutcome> {
238    let mut tx = begin_immediate(conn).await?;
239    let outcome =
240        remove_task_from_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
241    tx.commit().await?;
242    Ok(outcome)
243}
244
245pub(crate) async fn remove_task_from_epic_in_transaction(
246    conn: &mut SqliteConnection,
247    workspace: &Workspace,
248    child_id: &crate::ids::TaskId,
249    epic_id: &crate::ids::TaskId,
250) -> Result<EpicLinkOutcome> {
251    let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
252    let changed = sqlx::query(
253        "DELETE FROM task_epic_links
254         WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
255    )
256    .bind(&pair.child.workspace_id)
257    .bind(&pair.epic.id)
258    .bind(&pair.child.id)
259    .execute(&mut *conn)
260    .await?
261    .rows_affected()
262        > 0;
263
264    if changed {
265        record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_REMOVE).await?;
266    }
267
268    Ok(EpicLinkOutcome {
269        epic: pair.epic,
270        child: pair.child,
271        changed,
272    })
273}
274
275pub async fn task_has_epic_children(
276    conn: &mut SqliteConnection,
277    workspace_id: &WorkspaceId,
278    task_id: &crate::ids::TaskId,
279) -> Result<bool> {
280    Ok(sqlx::query_scalar::<_, i64>(
281        "SELECT count(*) FROM task_epic_links WHERE workspace_id = ? AND epic_task_id = ? LIMIT 1",
282    )
283    .bind(workspace_id)
284    .bind(task_id)
285    .fetch_one(&mut *conn)
286    .await?
287        > 0)
288}