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