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 = 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, 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 = undo
84 {
85 record_tui_undo(
86 &mut tx,
87 &workspace.id,
88 UndoPayload {
89 commands: vec![UndoCommand::RemoveEpicChild {
90 epic_id: outcome.epic.id.clone(),
91 child_id: outcome.child.id.clone(),
92 }],
93 },
94 )
95 .await?;
96 }
97 tx.commit().await?;
98 Ok(outcome)
99 }
100}
101
102pub struct EpicLinkOutcome {
103 pub epic: Task,
104 pub child: Task,
105 pub changed: bool,
106 pub promoted: bool,
107}
108
109struct EpicPair {
110 epic: Task,
111 child: Task,
112}
113
114async fn load_epic_pair(
115 conn: &mut SqliteConnection,
116 workspace: &Workspace,
117 child_id: &crate::ids::TaskId,
118 epic_id: &crate::ids::TaskId,
119) -> Result<EpicPair> {
120 if child_id == epic_id {
121 return Err(CoreError::validation(format!("error epic-self task_id={child_id}")).into());
122 }
123
124 let child = get_task_in_workspace(conn, workspace, child_id).await?;
125 let epic = get_task_in_workspace(conn, workspace, epic_id).await?;
126 Ok(EpicPair { epic, child })
127}
128
129async fn insert_epic_link_if_absent(
130 conn: &mut SqliteConnection,
131 pair: &EpicPair,
132 created_at: &str,
133) -> Result<bool> {
134 let existing_epic_id = sqlx::query_scalar::<_, crate::ids::TaskId>(
135 "SELECT epic_task_id FROM task_epic_links WHERE workspace_id = ? AND child_task_id = ?",
136 )
137 .bind(&pair.child.workspace_id)
138 .bind(&pair.child.id)
139 .fetch_optional(&mut *conn)
140 .await?;
141 if let Some(existing_epic_id) = existing_epic_id
142 && existing_epic_id != pair.epic.id
143 {
144 return Err(CoreError::validation(format!(
145 "error epic-child-already-linked child_task_id={} epic_task_id={}",
146 pair.child.id, existing_epic_id
147 ))
148 .into());
149 }
150
151 Ok(sqlx::query(
152 "INSERT OR IGNORE INTO task_epic_links(workspace_id, epic_task_id, child_task_id, created_at)
153 VALUES (?, ?, ?, ?)",
154 )
155 .bind(&pair.child.workspace_id)
156 .bind(&pair.epic.id)
157 .bind(&pair.child.id)
158 .bind(created_at)
159 .execute(&mut *conn)
160 .await?
161 .rows_affected()
162 > 0)
163}
164
165async fn record_epic_change(
166 conn: &mut SqliteConnection,
167 workspace: &Workspace,
168 pair: &EpicPair,
169 op_type: &'static str,
170 at: &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", at),
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, &ts).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, &ts).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(
360 conn,
361 workspace,
362 &pair,
363 op_type::EPIC_LINK_REMOVE,
364 &mutation_at,
365 )
366 .await?;
367 }
368
369 Ok(EpicLinkOutcome {
370 epic: pair.epic,
371 child: pair.child,
372 changed,
373 promoted: false,
374 })
375}
376
377pub async fn task_has_epic_children(
378 conn: &mut SqliteConnection,
379 workspace_id: &WorkspaceId,
380 task_id: &crate::ids::TaskId,
381) -> Result<bool> {
382 Ok(sqlx::query_scalar::<_, i64>(
383 "SELECT count(*) FROM task_epic_links WHERE workspace_id = ? AND epic_task_id = ? LIMIT 1",
384 )
385 .bind(workspace_id)
386 .bind(task_id)
387 .fetch_one(&mut *conn)
388 .await?
389 > 0)
390}
391
392pub(crate) async fn require_consumer_epic(
393 conn: &mut SqliteConnection,
394 workspace: &Workspace,
395 epic_id: &TaskId,
396) -> Result<()> {
397 let epic = get_task_in_workspace(conn, workspace, epic_id).await?;
398 if epic.deleted || !epic.is_epic {
399 return Err(CoreError::validation("epic is unavailable".to_string()).into());
400 }
401 Ok(())
402}
403
404impl Database {
405 pub(crate) async fn set_epic_child(
406 &self,
407 workspace: &Workspace,
408 epic_id: &TaskId,
409 child_id: &TaskId,
410 linked: bool,
411 ) -> Result<bool> {
412 let mut conn = self.acquire_writer().await?;
413 let mut tx = begin_immediate(&mut conn).await?;
414 require_consumer_epic(&mut tx, workspace, epic_id).await?;
415 let outcome = if linked {
416 add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?
417 } else {
418 remove_task_from_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?
419 };
420 tx.commit().await?;
421 Ok(outcome.changed)
422 }
423}