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