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}