1use crate::ids::WorkspaceId;
2use crate::operations::{RecurrenceStructuralMutation, RecurrenceTaskMutation};
3use anyhow::{Result, bail};
4use sqlx::SqliteConnection;
5
6use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
7use crate::db::{Database, begin_immediate, field_version, insert_change, set_field_version};
8use crate::ids::{TaskId, now};
9use crate::refs::get_task_in_workspace;
10use crate::task_fields::TaskField;
11use crate::types::Task;
12use crate::undo::{UndoCommand, UndoContext, UndoPayload, record_tui_undo};
13use crate::workspaces::Workspace;
14
15impl Database {
16 pub async fn add_task_to_epic(
17 &self,
18 workspace: &Workspace,
19 child_id: &TaskId,
20 epic_id: &TaskId,
21 ) -> Result<EpicLinkOutcome> {
22 self.add_task_to_epic_with_undo(workspace, child_id, epic_id, UndoContext::None)
23 .await
24 }
25
26 pub async fn add_task_to_epic_with_undo(
27 &self,
28 workspace: &Workspace,
29 child_id: &TaskId,
30 epic_id: &TaskId,
31 undo: UndoContext,
32 ) -> Result<EpicLinkOutcome> {
33 let mut conn = self.acquire_writer().await?;
34 let mut tx = begin_immediate(&mut conn).await?;
35 let outcome =
36 add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
37 if outcome.changed
38 && let UndoContext::Tui { summary } = undo
39 {
40 let mut commands = vec![UndoCommand::AddEpicChild {
41 epic_id: outcome.epic.id.clone(),
42 child_id: outcome.child.id.clone(),
43 }];
44 if outcome.promoted {
45 commands.push(UndoCommand::SetTaskField {
46 task_id: outcome.epic.id.clone(),
47 field: TaskField::IsEpic.as_str().to_string(),
48 before: "0".to_string(),
49 after: "1".to_string(),
50 queue_activity_before: None,
51 queue_activity_after: None,
52 });
53 }
54 record_tui_undo(&mut tx, &workspace.id, &summary, UndoPayload { commands }).await?;
55 }
56 tx.commit().await?;
57 Ok(outcome)
58 }
59
60 pub async fn remove_task_from_epic(
61 &self,
62 workspace: &Workspace,
63 child_id: &TaskId,
64 epic_id: &TaskId,
65 ) -> Result<EpicLinkOutcome> {
66 self.remove_task_from_epic_with_undo(workspace, child_id, epic_id, UndoContext::None)
67 .await
68 }
69
70 pub async fn remove_task_from_epic_with_undo(
71 &self,
72 workspace: &Workspace,
73 child_id: &TaskId,
74 epic_id: &TaskId,
75 undo: UndoContext,
76 ) -> Result<EpicLinkOutcome> {
77 let mut conn = self.acquire_writer().await?;
78 let mut tx = begin_immediate(&mut conn).await?;
79 let outcome =
80 remove_task_from_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
81 if outcome.changed
82 && let UndoContext::Tui { summary } = undo
83 {
84 record_tui_undo(
85 &mut tx,
86 &workspace.id,
87 &summary,
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 bail!("error epic-self task_id={child_id}");
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 bail!(
145 "error epic-child-already-linked child_task_id={} epic_task_id={}",
146 pair.child.id,
147 existing_epic_id
148 );
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) -> Result<()> {
171 append_change(
172 conn,
173 ChangeEntity::Task,
174 &pair.child.id,
175 Some("epics"),
176 op_type,
177 ChangePayload::workspace(workspace)
178 .set("epic_task_id", pair.epic.id.clone())
179 .set("created_at", now()),
180 )
181 .await?;
182 Ok(())
183}
184
185async fn mark_task_as_epic(
186 conn: &mut SqliteConnection,
187 workspace: &Workspace,
188 task: &Task,
189) -> Result<()> {
190 if task.is_epic {
191 return Ok(());
192 }
193 let field = TaskField::IsEpic.as_str();
194 let base = field_version(conn, &task.id, field).await?;
195 let ts = now();
196 sqlx::query("UPDATE tasks SET is_epic = 1, updated_at = ? WHERE workspace_id = ? AND id = ?")
197 .bind(&ts)
198 .bind(&task.workspace_id)
199 .bind(&task.id)
200 .execute(&mut *conn)
201 .await?;
202 let change_id = insert_change(
203 conn,
204 ChangeEntity::Task.as_str(),
205 &task.id,
206 Some(field),
207 op_type::SET_FIELD,
208 TaskField::IsEpic.scalar_payload(&workspace.id, &workspace.key, "1")?,
209 base.as_deref(),
210 )
211 .await?;
212 set_field_version(conn, &task.id, field, &change_id).await?;
213 Ok(())
214}
215
216#[cfg(any(test, feature = "test-support"))]
217pub async fn add_task_to_epic(
218 conn: &mut SqliteConnection,
219 workspace: &Workspace,
220 child_id: &crate::ids::TaskId,
221 epic_id: &crate::ids::TaskId,
222) -> Result<EpicLinkOutcome> {
223 let mut tx = begin_immediate(conn).await?;
224 let outcome = add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
225 tx.commit().await?;
226 Ok(outcome)
227}
228
229pub(crate) async fn add_task_to_epic_in_transaction(
230 conn: &mut SqliteConnection,
231 workspace: &Workspace,
232 child_id: &crate::ids::TaskId,
233 epic_id: &crate::ids::TaskId,
234) -> Result<EpicLinkOutcome> {
235 let ts = now();
236 crate::operations::route_recurrence_task_mutation(
237 conn,
238 workspace,
239 child_id,
240 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::EpicMembership),
241 &ts,
242 )
243 .await?;
244 crate::operations::route_recurrence_task_mutation(
245 conn,
246 workspace,
247 epic_id,
248 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::EpicMembership),
249 &ts,
250 )
251 .await?;
252 let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
253 if pair.child.project_id != pair.epic.project_id {
254 bail!("error epic-cross-project child_task_id={child_id} epic_task_id={epic_id}");
255 }
256 if pair.child.is_epic {
257 bail!("error epic-child-is-epic child_task_id={child_id}");
258 }
259 if pair.child.deleted {
260 bail!("error epic-child-deleted child_task_id={child_id}");
261 }
262 if pair.epic.deleted {
263 bail!("error epic-parent-deleted epic_task_id={epic_id}");
264 }
265 crate::epic_membership::capture_snapshot_baseline(conn, workspace.id.as_str(), child_id)
266 .await?;
267 let promoted = !pair.epic.is_epic;
268 if promoted {
269 mark_task_as_epic(conn, workspace, &pair.epic).await?;
270 }
271 let changed = insert_epic_link_if_absent(conn, &pair, &ts).await?;
272
273 if changed {
274 record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD).await?;
275 }
276
277 Ok(EpicLinkOutcome {
278 epic: get_task_in_workspace(conn, workspace, &pair.epic.id).await?,
279 child: pair.child,
280 changed,
281 promoted,
282 })
283}
284
285pub(crate) async fn restore_task_to_epic_in_transaction(
286 conn: &mut SqliteConnection,
287 workspace: &Workspace,
288 child_id: &crate::ids::TaskId,
289 epic_id: &crate::ids::TaskId,
290) -> Result<EpicLinkOutcome> {
291 let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
292 crate::epic_membership::capture_snapshot_baseline(conn, workspace.id.as_str(), child_id)
293 .await?;
294 let ts = now();
295 let changed = insert_epic_link_if_absent(conn, &pair, &ts).await?;
296 if changed {
297 record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD).await?;
298 }
299 Ok(EpicLinkOutcome {
300 epic: pair.epic,
301 child: pair.child,
302 changed,
303 promoted: false,
304 })
305}
306
307pub(crate) async fn remove_task_from_epic_in_transaction(
308 conn: &mut SqliteConnection,
309 workspace: &Workspace,
310 child_id: &crate::ids::TaskId,
311 epic_id: &crate::ids::TaskId,
312) -> Result<EpicLinkOutcome> {
313 let mutation_at = now();
314 crate::operations::route_recurrence_task_mutation(
315 conn,
316 workspace,
317 child_id,
318 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::EpicMembership),
319 &mutation_at,
320 )
321 .await?;
322 crate::operations::route_recurrence_task_mutation(
323 conn,
324 workspace,
325 epic_id,
326 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::EpicMembership),
327 &mutation_at,
328 )
329 .await?;
330 let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
331 crate::epic_membership::capture_snapshot_baseline(conn, workspace.id.as_str(), child_id)
332 .await?;
333 let changed = sqlx::query(
334 "DELETE FROM task_epic_links
335 WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
336 )
337 .bind(&pair.child.workspace_id)
338 .bind(&pair.epic.id)
339 .bind(&pair.child.id)
340 .execute(&mut *conn)
341 .await?
342 .rows_affected()
343 > 0;
344
345 if changed {
346 record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_REMOVE).await?;
347 }
348
349 Ok(EpicLinkOutcome {
350 epic: pair.epic,
351 child: pair.child,
352 changed,
353 promoted: false,
354 })
355}
356
357pub async fn task_has_epic_children(
358 conn: &mut SqliteConnection,
359 workspace_id: &WorkspaceId,
360 task_id: &crate::ids::TaskId,
361) -> Result<bool> {
362 Ok(sqlx::query_scalar::<_, i64>(
363 "SELECT count(*) FROM task_epic_links WHERE workspace_id = ? AND epic_task_id = ? LIMIT 1",
364 )
365 .bind(workspace_id)
366 .bind(task_id)
367 .fetch_one(&mut *conn)
368 .await?
369 > 0)
370}