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}