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