1use crate::error::CoreError;
2use crate::ids::WorkspaceId;
3use crate::operations::{RecurrenceStructuralMutation, RecurrenceTaskMutation};
4use anyhow::Result;
5use sqlx::SqliteConnection;
6
7use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
8use crate::db::{Database, begin_immediate, field_version, insert_change, set_field_version};
9use crate::ids::{TaskId, now};
10use crate::refs::get_task_in_workspace;
11use crate::task_fields::TaskField;
12use crate::types::Task;
13use crate::undo::{UndoCommand, UndoContext, UndoPayload, record_tui_undo};
14use crate::workspaces::Workspace;
15
16impl Database {
17 pub async fn add_task_to_epic(
18 &self,
19 workspace: &Workspace,
20 child_id: &TaskId,
21 epic_id: &TaskId,
22 ) -> Result<EpicLinkOutcome> {
23 self.add_task_to_epic_with_undo(workspace, child_id, epic_id, UndoContext::None)
24 .await
25 }
26
27 pub async fn add_task_to_epic_with_undo(
28 &self,
29 workspace: &Workspace,
30 child_id: &TaskId,
31 epic_id: &TaskId,
32 undo: UndoContext,
33 ) -> Result<EpicLinkOutcome> {
34 let mut conn = self.acquire_writer().await?;
35 let mut tx = begin_immediate(&mut conn).await?;
36 let outcome =
37 add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
38 if outcome.changed
39 && let UndoContext::Tui { summary } = undo
40 {
41 let mut commands = vec![UndoCommand::AddEpicChild {
42 epic_id: outcome.epic.id.clone(),
43 child_id: outcome.child.id.clone(),
44 }];
45 if outcome.promoted {
46 commands.push(UndoCommand::SetTaskField {
47 task_id: outcome.epic.id.clone(),
48 field: TaskField::IsEpic.as_str().to_string(),
49 before: "0".to_string(),
50 after: "1".to_string(),
51 queue_activity_before: None,
52 queue_activity_after: None,
53 });
54 }
55 record_tui_undo(&mut tx, &workspace.id, &summary, UndoPayload { commands }).await?;
56 }
57 tx.commit().await?;
58 Ok(outcome)
59 }
60
61 pub async fn remove_task_from_epic(
62 &self,
63 workspace: &Workspace,
64 child_id: &TaskId,
65 epic_id: &TaskId,
66 ) -> Result<EpicLinkOutcome> {
67 self.remove_task_from_epic_with_undo(workspace, child_id, epic_id, UndoContext::None)
68 .await
69 }
70
71 pub async fn remove_task_from_epic_with_undo(
72 &self,
73 workspace: &Workspace,
74 child_id: &TaskId,
75 epic_id: &TaskId,
76 undo: UndoContext,
77 ) -> Result<EpicLinkOutcome> {
78 let mut conn = self.acquire_writer().await?;
79 let mut tx = begin_immediate(&mut conn).await?;
80 let outcome =
81 remove_task_from_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
82 if outcome.changed
83 && let UndoContext::Tui { summary } = undo
84 {
85 record_tui_undo(
86 &mut tx,
87 &workspace.id,
88 &summary,
89 UndoPayload {
90 commands: vec![UndoCommand::RemoveEpicChild {
91 epic_id: outcome.epic.id.clone(),
92 child_id: outcome.child.id.clone(),
93 }],
94 },
95 )
96 .await?;
97 }
98 tx.commit().await?;
99 Ok(outcome)
100 }
101}
102
103pub struct EpicLinkOutcome {
104 pub epic: Task,
105 pub child: Task,
106 pub changed: bool,
107 pub promoted: bool,
108}
109
110struct EpicPair {
111 epic: Task,
112 child: Task,
113}
114
115async fn load_epic_pair(
116 conn: &mut SqliteConnection,
117 workspace: &Workspace,
118 child_id: &crate::ids::TaskId,
119 epic_id: &crate::ids::TaskId,
120) -> Result<EpicPair> {
121 if child_id == epic_id {
122 return Err(CoreError::validation(format!("error epic-self task_id={child_id}")).into());
123 }
124
125 let child = get_task_in_workspace(conn, workspace, child_id).await?;
126 let epic = get_task_in_workspace(conn, workspace, epic_id).await?;
127 Ok(EpicPair { epic, child })
128}
129
130async fn insert_epic_link_if_absent(
131 conn: &mut SqliteConnection,
132 pair: &EpicPair,
133 created_at: &str,
134) -> Result<bool> {
135 let existing_epic_id = sqlx::query_scalar::<_, crate::ids::TaskId>(
136 "SELECT epic_task_id FROM task_epic_links WHERE workspace_id = ? AND child_task_id = ?",
137 )
138 .bind(&pair.child.workspace_id)
139 .bind(&pair.child.id)
140 .fetch_optional(&mut *conn)
141 .await?;
142 if let Some(existing_epic_id) = existing_epic_id
143 && existing_epic_id != pair.epic.id
144 {
145 return Err(CoreError::validation(format!(
146 "error epic-child-already-linked child_task_id={} epic_task_id={}",
147 pair.child.id, existing_epic_id
148 ))
149 .into());
150 }
151
152 Ok(sqlx::query(
153 "INSERT OR IGNORE INTO task_epic_links(workspace_id, epic_task_id, child_task_id, created_at)
154 VALUES (?, ?, ?, ?)",
155 )
156 .bind(&pair.child.workspace_id)
157 .bind(&pair.epic.id)
158 .bind(&pair.child.id)
159 .bind(created_at)
160 .execute(&mut *conn)
161 .await?
162 .rows_affected()
163 > 0)
164}
165
166async fn record_epic_change(
167 conn: &mut SqliteConnection,
168 workspace: &Workspace,
169 pair: &EpicPair,
170 op_type: &'static str,
171) -> Result<()> {
172 append_change(
173 conn,
174 ChangeEntity::Task,
175 &pair.child.id,
176 Some("epics"),
177 op_type,
178 ChangePayload::workspace(workspace)
179 .set("epic_task_id", pair.epic.id.clone())
180 .set("created_at", now()),
181 )
182 .await?;
183 Ok(())
184}
185
186async fn mark_task_as_epic(
187 conn: &mut SqliteConnection,
188 workspace: &Workspace,
189 task: &Task,
190) -> Result<()> {
191 if task.is_epic {
192 return Ok(());
193 }
194 let field = TaskField::IsEpic.as_str();
195 let base = field_version(conn, &task.id, field).await?;
196 let ts = now();
197 sqlx::query("UPDATE tasks SET is_epic = 1, updated_at = ? WHERE workspace_id = ? AND id = ?")
198 .bind(&ts)
199 .bind(&task.workspace_id)
200 .bind(&task.id)
201 .execute(&mut *conn)
202 .await?;
203 let change_id = insert_change(
204 conn,
205 ChangeEntity::Task.as_str(),
206 &task.id,
207 Some(field),
208 op_type::SET_FIELD,
209 TaskField::IsEpic.scalar_payload(&workspace.id, &workspace.key, "1")?,
210 base.as_deref(),
211 )
212 .await?;
213 set_field_version(conn, &task.id, field, &change_id).await?;
214 Ok(())
215}
216
217#[cfg(any(test, feature = "test-support"))]
218pub async fn add_task_to_epic(
219 conn: &mut SqliteConnection,
220 workspace: &Workspace,
221 child_id: &crate::ids::TaskId,
222 epic_id: &crate::ids::TaskId,
223) -> Result<EpicLinkOutcome> {
224 let mut tx = begin_immediate(conn).await?;
225 let outcome = add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
226 tx.commit().await?;
227 Ok(outcome)
228}
229
230pub(crate) async fn add_task_to_epic_in_transaction(
231 conn: &mut SqliteConnection,
232 workspace: &Workspace,
233 child_id: &crate::ids::TaskId,
234 epic_id: &crate::ids::TaskId,
235) -> Result<EpicLinkOutcome> {
236 let ts = now();
237 crate::operations::route_recurrence_task_mutation(
238 conn,
239 workspace,
240 child_id,
241 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::EpicMembership),
242 &ts,
243 )
244 .await?;
245 crate::operations::route_recurrence_task_mutation(
246 conn,
247 workspace,
248 epic_id,
249 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::EpicMembership),
250 &ts,
251 )
252 .await?;
253 let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
254 if pair.child.project_id != pair.epic.project_id {
255 return Err(CoreError::validation(format!(
256 "error epic-cross-project child_task_id={child_id} epic_task_id={epic_id}"
257 ))
258 .into());
259 }
260 if pair.child.is_epic {
261 return Err(CoreError::validation(format!(
262 "error epic-child-is-epic child_task_id={child_id}"
263 ))
264 .into());
265 }
266 if pair.child.deleted {
267 return Err(CoreError::validation(format!(
268 "error epic-child-deleted child_task_id={child_id}"
269 ))
270 .into());
271 }
272 if pair.epic.deleted {
273 return Err(CoreError::validation(format!(
274 "error epic-parent-deleted epic_task_id={epic_id}"
275 ))
276 .into());
277 }
278 crate::epic_membership::capture_snapshot_baseline(conn, workspace.id.as_str(), child_id)
279 .await?;
280 let promoted = !pair.epic.is_epic;
281 if promoted {
282 mark_task_as_epic(conn, workspace, &pair.epic).await?;
283 }
284 let changed = insert_epic_link_if_absent(conn, &pair, &ts).await?;
285
286 if changed {
287 record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD).await?;
288 }
289
290 Ok(EpicLinkOutcome {
291 epic: get_task_in_workspace(conn, workspace, &pair.epic.id).await?,
292 child: pair.child,
293 changed,
294 promoted,
295 })
296}
297
298pub(crate) async fn restore_task_to_epic_in_transaction(
299 conn: &mut SqliteConnection,
300 workspace: &Workspace,
301 child_id: &crate::ids::TaskId,
302 epic_id: &crate::ids::TaskId,
303) -> Result<EpicLinkOutcome> {
304 let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
305 crate::epic_membership::capture_snapshot_baseline(conn, workspace.id.as_str(), child_id)
306 .await?;
307 let ts = now();
308 let changed = insert_epic_link_if_absent(conn, &pair, &ts).await?;
309 if changed {
310 record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD).await?;
311 }
312 Ok(EpicLinkOutcome {
313 epic: pair.epic,
314 child: pair.child,
315 changed,
316 promoted: false,
317 })
318}
319
320pub(crate) async fn remove_task_from_epic_in_transaction(
321 conn: &mut SqliteConnection,
322 workspace: &Workspace,
323 child_id: &crate::ids::TaskId,
324 epic_id: &crate::ids::TaskId,
325) -> Result<EpicLinkOutcome> {
326 let mutation_at = now();
327 crate::operations::route_recurrence_task_mutation(
328 conn,
329 workspace,
330 child_id,
331 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::EpicMembership),
332 &mutation_at,
333 )
334 .await?;
335 crate::operations::route_recurrence_task_mutation(
336 conn,
337 workspace,
338 epic_id,
339 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::EpicMembership),
340 &mutation_at,
341 )
342 .await?;
343 let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
344 crate::epic_membership::capture_snapshot_baseline(conn, workspace.id.as_str(), child_id)
345 .await?;
346 let changed = sqlx::query(
347 "DELETE FROM task_epic_links
348 WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
349 )
350 .bind(&pair.child.workspace_id)
351 .bind(&pair.epic.id)
352 .bind(&pair.child.id)
353 .execute(&mut *conn)
354 .await?
355 .rows_affected()
356 > 0;
357
358 if changed {
359 record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_REMOVE).await?;
360 }
361
362 Ok(EpicLinkOutcome {
363 epic: pair.epic,
364 child: pair.child,
365 changed,
366 promoted: false,
367 })
368}
369
370pub async fn task_has_epic_children(
371 conn: &mut SqliteConnection,
372 workspace_id: &WorkspaceId,
373 task_id: &crate::ids::TaskId,
374) -> Result<bool> {
375 Ok(sqlx::query_scalar::<_, i64>(
376 "SELECT count(*) FROM task_epic_links WHERE workspace_id = ? AND epic_task_id = ? LIMIT 1",
377 )
378 .bind(workspace_id)
379 .bind(task_id)
380 .fetch_one(&mut *conn)
381 .await?
382 > 0)
383}
384
385pub(crate) async fn require_consumer_epic(
386 conn: &mut SqliteConnection,
387 workspace: &Workspace,
388 epic_id: &TaskId,
389) -> Result<()> {
390 let epic = get_task_in_workspace(conn, workspace, epic_id).await?;
391 if epic.deleted || !epic.is_epic {
392 return Err(CoreError::validation("epic is unavailable".to_string()).into());
393 }
394 Ok(())
395}
396
397impl Database {
398 pub(crate) async fn set_epic_child(
399 &self,
400 workspace: &Workspace,
401 epic_id: &TaskId,
402 child_id: &TaskId,
403 linked: bool,
404 ) -> Result<bool> {
405 let mut conn = self.acquire_writer().await?;
406 let mut tx = begin_immediate(&mut conn).await?;
407 require_consumer_epic(&mut tx, workspace, epic_id).await?;
408 let outcome = if linked {
409 add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?
410 } else {
411 remove_task_from_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?
412 };
413 tx.commit().await?;
414 Ok(outcome.changed)
415 }
416}