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 insert_epic_link_if_absent(
127 conn: &mut SqliteConnection,
128 pair: &EpicPair,
129 created_at: &str,
130) -> Result<bool> {
131 let existing_epic_id = sqlx::query_scalar::<_, crate::ids::TaskId>(
132 "SELECT epic_task_id FROM task_epic_links WHERE workspace_id = ? AND child_task_id = ?",
133 )
134 .bind(&pair.child.workspace_id)
135 .bind(&pair.child.id)
136 .fetch_optional(&mut *conn)
137 .await?;
138 if let Some(existing_epic_id) = existing_epic_id
139 && existing_epic_id != pair.epic.id
140 {
141 bail!(
142 "error epic-child-already-linked child_task_id={} epic_task_id={}",
143 pair.child.id,
144 existing_epic_id
145 );
146 }
147
148 Ok(sqlx::query(
149 "INSERT OR IGNORE INTO task_epic_links(workspace_id, epic_task_id, child_task_id, created_at)
150 VALUES (?, ?, ?, ?)",
151 )
152 .bind(&pair.child.workspace_id)
153 .bind(&pair.epic.id)
154 .bind(&pair.child.id)
155 .bind(created_at)
156 .execute(&mut *conn)
157 .await?
158 .rows_affected()
159 > 0)
160}
161
162async fn record_epic_change(
163 conn: &mut SqliteConnection,
164 workspace: &Workspace,
165 pair: &EpicPair,
166 op_type: &'static str,
167) -> Result<()> {
168 append_change(
169 conn,
170 ChangeEntity::Task,
171 &pair.child.id,
172 Some("epics"),
173 op_type,
174 ChangePayload::workspace(workspace)
175 .set("epic_task_id", pair.epic.id.clone())
176 .set("created_at", now()),
177 )
178 .await?;
179 Ok(())
180}
181
182async fn mark_task_as_epic(
183 conn: &mut SqliteConnection,
184 workspace: &Workspace,
185 task: &Task,
186) -> Result<()> {
187 if task.is_epic {
188 return Ok(());
189 }
190 let field = TaskField::IsEpic.as_str();
191 let base = field_version(conn, &task.id, field).await?;
192 let ts = now();
193 sqlx::query("UPDATE tasks SET is_epic = 1, updated_at = ? WHERE workspace_id = ? AND id = ?")
194 .bind(&ts)
195 .bind(&task.workspace_id)
196 .bind(&task.id)
197 .execute(&mut *conn)
198 .await?;
199 let change_id = insert_change(
200 conn,
201 ChangeEntity::Task.as_str(),
202 &task.id,
203 Some(field),
204 op_type::SET_FIELD,
205 TaskField::IsEpic.scalar_payload(&workspace.id, &workspace.key, "1")?,
206 base.as_deref(),
207 )
208 .await?;
209 set_field_version(conn, &task.id, field, &change_id).await?;
210 Ok(())
211}
212
213#[cfg(any(test, feature = "test-support"))]
214pub async fn add_task_to_epic(
215 conn: &mut SqliteConnection,
216 workspace: &Workspace,
217 child_id: &crate::ids::TaskId,
218 epic_id: &crate::ids::TaskId,
219) -> Result<EpicLinkOutcome> {
220 let mut tx = begin_immediate(conn).await?;
221 let outcome = add_task_to_epic_in_transaction(&mut tx, workspace, child_id, epic_id).await?;
222 tx.commit().await?;
223 Ok(outcome)
224}
225
226pub(crate) async fn add_task_to_epic_in_transaction(
227 conn: &mut SqliteConnection,
228 workspace: &Workspace,
229 child_id: &crate::ids::TaskId,
230 epic_id: &crate::ids::TaskId,
231) -> Result<EpicLinkOutcome> {
232 crate::operations::route_recurrence_task_field(
233 conn,
234 workspace,
235 child_id,
236 "epic_membership",
237 "",
238 )
239 .await?;
240 crate::operations::route_recurrence_task_field(conn, workspace, epic_id, "epic_membership", "")
241 .await?;
242 let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
243 if pair.child.project_id != pair.epic.project_id {
244 bail!("error epic-cross-project child_task_id={child_id} epic_task_id={epic_id}");
245 }
246 if pair.child.is_epic {
247 bail!("error epic-child-is-epic child_task_id={child_id}");
248 }
249 if pair.child.deleted {
250 bail!("error epic-child-deleted child_task_id={child_id}");
251 }
252 if pair.epic.deleted {
253 bail!("error epic-parent-deleted epic_task_id={epic_id}");
254 }
255 let ts = now();
256 let promoted = !pair.epic.is_epic;
257 if promoted {
258 mark_task_as_epic(conn, workspace, &pair.epic).await?;
259 }
260 let changed = insert_epic_link_if_absent(conn, &pair, &ts).await?;
261
262 if changed {
263 record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD).await?;
264 }
265
266 Ok(EpicLinkOutcome {
267 epic: get_task_in_workspace(conn, workspace, &pair.epic.id).await?,
268 child: pair.child,
269 changed,
270 promoted,
271 })
272}
273
274pub(crate) async fn restore_task_to_epic_in_transaction(
275 conn: &mut SqliteConnection,
276 workspace: &Workspace,
277 child_id: &crate::ids::TaskId,
278 epic_id: &crate::ids::TaskId,
279) -> Result<EpicLinkOutcome> {
280 let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
281 let ts = now();
282 let changed = insert_epic_link_if_absent(conn, &pair, &ts).await?;
283 if changed {
284 record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_ADD).await?;
285 }
286 Ok(EpicLinkOutcome {
287 epic: pair.epic,
288 child: pair.child,
289 changed,
290 promoted: false,
291 })
292}
293
294pub(crate) async fn remove_task_from_epic_in_transaction(
295 conn: &mut SqliteConnection,
296 workspace: &Workspace,
297 child_id: &crate::ids::TaskId,
298 epic_id: &crate::ids::TaskId,
299) -> Result<EpicLinkOutcome> {
300 crate::operations::route_recurrence_task_field(
301 conn,
302 workspace,
303 child_id,
304 "epic_membership",
305 "",
306 )
307 .await?;
308 crate::operations::route_recurrence_task_field(conn, workspace, epic_id, "epic_membership", "")
309 .await?;
310 let pair = load_epic_pair(conn, workspace, child_id, epic_id).await?;
311 let changed = sqlx::query(
312 "DELETE FROM task_epic_links
313 WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
314 )
315 .bind(&pair.child.workspace_id)
316 .bind(&pair.epic.id)
317 .bind(&pair.child.id)
318 .execute(&mut *conn)
319 .await?
320 .rows_affected()
321 > 0;
322
323 if changed {
324 record_epic_change(conn, workspace, &pair, op_type::EPIC_LINK_REMOVE).await?;
325 }
326
327 Ok(EpicLinkOutcome {
328 epic: pair.epic,
329 child: pair.child,
330 changed,
331 promoted: false,
332 })
333}
334
335pub async fn task_has_epic_children(
336 conn: &mut SqliteConnection,
337 workspace_id: &WorkspaceId,
338 task_id: &crate::ids::TaskId,
339) -> Result<bool> {
340 Ok(sqlx::query_scalar::<_, i64>(
341 "SELECT count(*) FROM task_epic_links WHERE workspace_id = ? AND epic_task_id = ? LIMIT 1",
342 )
343 .bind(workspace_id)
344 .bind(task_id)
345 .fetch_one(&mut *conn)
346 .await?
347 > 0)
348}