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