1use crate::ids::{ProjectId, WorkspaceId};
2use crate::operations::{RecurrenceStructuralMutation, RecurrenceTaskMutation};
3use std::collections::{BTreeMap, BTreeSet};
4
5use anyhow::{Result, bail, ensure};
6use sqlx::{Row, SqliteConnection};
7
8use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
9use crate::db::{
10 Database, begin_immediate, conflict_exists, field_version, insert_change, set_field_version,
11 task_from_row,
12};
13use crate::error::CoreError;
14use crate::ids::{new_id, now};
15use crate::mutation::{apply_field_value_in_workspace, apply_project_id_in_workspace};
16use crate::operations::{
17 ProjectMetadata, insert_project_metadata_change, set_project_metadata,
18 update_task_labels_in_workspace,
19};
20use crate::projects::resolve_project_for_stored_value;
21use crate::task_fields::TaskField;
22use crate::workspaces::workspace_key_for_id;
23
24tokio::task_local! {
25 static APPLYING_UNDO: ();
26}
27
28pub fn is_applying_undo() -> bool {
29 APPLYING_UNDO.try_with(|_| ()).is_ok()
30}
31
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33pub struct UndoPayload {
34 pub commands: Vec<UndoCommand>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PendingUndoPresentation {
39 pub id: String,
40 pub operation: String,
41 pub task_ids: Vec<crate::ids::TaskId>,
42}
43
44impl PendingUndoPresentation {
45 pub fn operation_phrase(&self) -> &str {
46 &self.operation
47 }
48}
49
50#[derive(Debug, Clone, Default)]
51pub enum UndoContext {
52 #[default]
53 None,
54 Tui {
55 summary: String,
56 },
57 TuiTaskMutation {
58 single_summary: Option<String>,
59 batch_action: String,
60 },
61}
62
63impl UndoContext {
64 pub fn tui(summary: impl Into<String>) -> Self {
65 Self::Tui {
66 summary: summary.into(),
67 }
68 }
69
70 pub fn tui_task_mutation(
71 single_summary: Option<String>,
72 batch_action: impl Into<String>,
73 ) -> Self {
74 Self::TuiTaskMutation {
75 single_summary,
76 batch_action: batch_action.into(),
77 }
78 }
79
80 pub(crate) fn task_mutation_summary(self, changed_count: usize) -> Option<String> {
81 match self {
82 Self::None => None,
83 Self::Tui { summary } => Some(summary),
84 Self::TuiTaskMutation {
85 single_summary,
86 batch_action,
87 } => single_summary.filter(|_| changed_count == 1).or_else(|| {
88 let noun = if changed_count == 1 { "task" } else { "tasks" };
89 Some(format!("{batch_action} {changed_count} {noun}"))
90 }),
91 }
92 }
93}
94
95#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
96#[serde(tag = "op", rename_all = "snake_case")]
97pub enum UndoCommand {
98 SetTaskField {
99 task_id: crate::ids::TaskId,
100 field: String,
101 before: String,
102 after: String,
103 #[serde(default)]
104 queue_activity_before: Option<String>,
105 #[serde(default)]
106 queue_activity_after: Option<String>,
107 },
108 SetTaskLabels {
109 task_id: crate::ids::TaskId,
110 before: Vec<String>,
111 after: Vec<String>,
112 },
113 SetTaskMetadata {
114 task_id: crate::ids::TaskId,
115 field_id: crate::ids::MetadataFieldId,
116 before: Option<String>,
117 after: Option<String>,
118 },
119 DeleteCreatedTask {
120 task_id: crate::ids::TaskId,
121 create_change_id: Option<String>,
122 expected: TaskUndoSnapshot,
123 #[serde(default)]
124 attachment_ids: Vec<String>,
125 #[serde(default)]
126 attachment_change_ids: Vec<String>,
127 },
128 SetNoteBody {
129 task_id: crate::ids::TaskId,
130 note_id: String,
131 before: String,
132 after: String,
133 },
134 RestoreDeletedNote {
135 task_id: crate::ids::TaskId,
136 note_id: String,
137 body: String,
138 created_at: String,
139 note_add_change_id: String,
140 },
141 DeleteCreatedNote {
142 task_id: crate::ids::TaskId,
143 note_id: String,
144 note_add_change_id: String,
145 #[serde(default)]
146 restoration_change_ids: Vec<String>,
147 },
148 DeleteCreatedProject {
149 project_key: String,
150 create_change_id: String,
151 expected_name: String,
152 expected_prefix: String,
153 },
154 SetProjectMetadata {
155 project_id: ProjectId,
156 before_key: String,
157 before_name: String,
158 before_prefix: String,
159 after_key: String,
160 after_name: String,
161 after_prefix: String,
162 },
163 DeleteCreatedLabel {
164 label: String,
165 create_change_id: String,
166 },
167 SetLabelName {
168 before: String,
169 after: String,
170 },
171 RestoreDeletedLabel {
172 name: String,
173 created_at: String,
174 task_ids: Vec<crate::ids::TaskId>,
175 series_ids: Vec<crate::recurrence::RecurrenceSeriesId>,
176 },
177 RestoreConflictResolution {
178 task_id: crate::ids::TaskId,
179 field: String,
180 before: String,
181 after: String,
182 conflict_id: i64,
183 },
184 AddTaskDependency {
185 task_id: crate::ids::TaskId,
186 depends_on_task_id: crate::ids::TaskId,
187 },
188 RemoveTaskDependency {
189 task_id: crate::ids::TaskId,
190 depends_on_task_id: crate::ids::TaskId,
191 },
192 SetTaskRelatedLink {
193 task_id: crate::ids::TaskId,
194 related_task_id: crate::ids::TaskId,
195 forward_change_id: String,
196 linked: bool,
197 },
198 AddEpicChild {
199 epic_id: crate::ids::TaskId,
200 child_id: crate::ids::TaskId,
201 },
202 RemoveEpicChild {
203 epic_id: crate::ids::TaskId,
204 child_id: crate::ids::TaskId,
205 },
206}
207
208#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
209pub struct TaskUndoSnapshot {
210 pub title: String,
211 pub description: String,
212 pub project_id: ProjectId,
213 pub project_key: String,
214 pub status: String,
215 pub priority: String,
216 #[serde(default)]
217 pub queue_activity_at: String,
218 pub available_at: String,
219 #[serde(default)]
220 pub due_on: String,
221 pub deleted: bool,
222 #[serde(default)]
223 pub is_epic: bool,
224 pub labels: Vec<String>,
225 #[serde(default)]
226 pub metadata: BTreeMap<String, String>,
227}
228
229pub struct UndoOutcome {
230 pub presentation: PendingUndoPresentation,
231 pub task_id: Option<crate::ids::TaskId>,
232 pub include_deleted: Option<bool>,
233 pub project_rename: Option<ProjectRenameUndoOutcome>,
234 pub label_rename: Option<LabelRenameUndoOutcome>,
235}
236
237pub struct LabelRenameUndoOutcome {
238 pub before: String,
239 pub after: String,
240}
241
242pub struct ProjectRenameUndoOutcome {
243 pub before_key: String,
244 pub after_key: String,
245}
246
247impl Database {
248 pub async fn task_field_value(
249 &self,
250 workspace_id: &WorkspaceId,
251 task_id: &crate::ids::TaskId,
252 field: &str,
253 ) -> Result<String> {
254 let mut conn = self.acquire_reader().await?;
255 task_field_value(&mut conn, workspace_id, task_id, field).await
256 }
257
258 pub async fn task_labels(
259 &self,
260 workspace_id: &WorkspaceId,
261 task_id: &crate::ids::TaskId,
262 ) -> Result<Vec<String>> {
263 let mut conn = self.acquire_reader().await?;
264 task_labels(&mut conn, workspace_id, task_id).await
265 }
266
267 pub async fn task_undo_snapshot(
268 &self,
269 workspace_id: &WorkspaceId,
270 task_id: &crate::ids::TaskId,
271 ) -> Result<TaskUndoSnapshot> {
272 let mut conn = self.acquire_reader().await?;
273 task_snapshot(&mut conn, workspace_id, task_id).await
274 }
275
276 pub async fn conflict_row_id(
277 &self,
278 workspace_id: &WorkspaceId,
279 task_id: &crate::ids::TaskId,
280 field: &str,
281 ) -> Result<i64> {
282 let mut conn = self.acquire_reader().await?;
283 conflict_row_id(&mut conn, workspace_id, task_id, field).await
284 }
285
286 pub async fn clear_pending_tui_undo_entries(&self) -> Result<()> {
287 let mut conn = self.acquire_writer().await?;
288 clear_pending_tui_undo_entries(&mut conn).await
289 }
290
291 pub async fn latest_tui_undo_presentation(
292 &self,
293 workspace_id: &WorkspaceId,
294 ) -> Result<Option<PendingUndoPresentation>> {
295 let mut conn = self.acquire_reader().await?;
296 latest_tui_undo_presentation(&mut conn, workspace_id).await
297 }
298
299 pub async fn apply_latest_tui_undo(
300 &self,
301 workspace_id: &WorkspaceId,
302 ) -> Result<Option<UndoOutcome>> {
303 let mut conn = self.acquire_writer().await?;
304 apply_latest_tui_undo(&mut conn, workspace_id).await
305 }
306}
307
308pub(crate) async fn task_field_value(
309 conn: &mut SqliteConnection,
310 workspace_id: &WorkspaceId,
311 task_id: &crate::ids::TaskId,
312 field: &str,
313) -> Result<String> {
314 let task_field = TaskField::parse_or_unknown(field)?;
315 task_field_value_for_field(conn, workspace_id, task_id, task_field).await
316}
317
318async fn task_field_value_for_field(
319 conn: &mut SqliteConnection,
320 workspace_id: &WorkspaceId,
321 task_id: &crate::ids::TaskId,
322 task_field: TaskField,
323) -> Result<String> {
324 let row = sqlx::query(
325 "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id, p.key AS project_key, p.prefix AS project_prefix, t.status, t.priority, t.source, t.created_at, t.updated_at, t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic
326 FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
327 WHERE t.workspace_id = ? AND t.id = ?",
328 )
329 .bind(workspace_id)
330 .bind(task_id)
331 .fetch_optional(&mut *conn)
332 .await?
333 .ok_or_else(|| CoreError::not_found(format!("error task-not-found task_id={task_id}")))?;
334 let task = task_from_row(&row)?;
335 Ok(task_field.current_value(&task))
336}
337
338pub(crate) async fn task_labels(
339 conn: &mut SqliteConnection,
340 workspace_id: &WorkspaceId,
341 task_id: &crate::ids::TaskId,
342) -> Result<Vec<String>> {
343 let rows = sqlx::query(
344 "SELECT label FROM task_labels WHERE workspace_id = ? AND task_id = ? ORDER BY label",
345 )
346 .bind(workspace_id)
347 .bind(task_id)
348 .fetch_all(&mut *conn)
349 .await?;
350 Ok(rows.into_iter().map(|row| row.get("label")).collect())
351}
352
353pub(crate) async fn task_snapshot(
354 conn: &mut SqliteConnection,
355 workspace_id: &WorkspaceId,
356 task_id: &crate::ids::TaskId,
357) -> Result<TaskUndoSnapshot> {
358 let row = sqlx::query(
359 "SELECT t.title, t.description, t.project_id, p.key AS project_key, t.status, t.priority, t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic
360 FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
361 WHERE t.workspace_id = ? AND t.id = ?",
362 )
363 .bind(workspace_id)
364 .bind(task_id)
365 .fetch_optional(&mut *conn)
366 .await?
367 .ok_or_else(|| CoreError::not_found(format!("error task-not-found task_id={task_id}")))?;
368 let labels = task_labels(conn, workspace_id, task_id).await?;
369 let metadata = sqlx::query_as::<_, (String, String)>(
370 "SELECT field_id, value FROM task_metadata
371 WHERE workspace_id = ? AND task_id = ? ORDER BY field_id",
372 )
373 .bind(workspace_id)
374 .bind(task_id)
375 .fetch_all(&mut *conn)
376 .await?
377 .into_iter()
378 .collect();
379 Ok(TaskUndoSnapshot {
380 title: row.get("title"),
381 description: row.get("description"),
382 project_id: row.get("project_id"),
383 project_key: row.get("project_key"),
384 status: row.get("status"),
385 priority: row.get("priority"),
386 queue_activity_at: row.get("queue_activity_at"),
387 available_at: row.get("available_at"),
388 due_on: row.get("due_on"),
389 deleted: row.get::<i64, _>("deleted") != 0,
390 is_epic: row.get::<i64, _>("is_epic") != 0,
391 labels,
392 metadata,
393 })
394}
395
396pub(crate) async fn conflict_row_id(
397 conn: &mut SqliteConnection,
398 workspace_id: &WorkspaceId,
399 task_id: &crate::ids::TaskId,
400 field: &str,
401) -> Result<i64> {
402 sqlx::query_scalar(
403 "SELECT id FROM conflicts
404 WHERE workspace_id = ? AND task_id = ? AND field = ? AND resolved = 0
405 ORDER BY id LIMIT 1",
406 )
407 .bind(workspace_id)
408 .bind(task_id)
409 .bind(field)
410 .fetch_optional(&mut *conn)
411 .await?
412 .ok_or_else(|| {
413 CoreError::not_found(format!(
414 "error conflict-not-found task_id={task_id} field={field}"
415 ))
416 })
417 .map_err(Into::into)
418}
419
420pub(crate) async fn record_tui_undo(
421 conn: &mut SqliteConnection,
422 workspace_id: &WorkspaceId,
423 summary: &str,
424 payload: UndoPayload,
425) -> Result<()> {
426 if is_applying_undo() || !undo_payload_has_effect(&payload) {
427 return Ok(());
428 }
429 let id = new_id();
430 let created_at = now();
431 let seq: i64 = sqlx::query_scalar(
432 "SELECT COALESCE(MAX(seq), 0) + 1 FROM tui_undo_entries WHERE workspace_id = ?",
433 )
434 .bind(workspace_id)
435 .fetch_one(&mut *conn)
436 .await?;
437 let payload = serde_json::to_string(&payload)?;
438 sqlx::query(
439 "INSERT INTO tui_undo_entries(id, workspace_id, summary, payload_version, payload, seq, created_at)
440 VALUES (?, ?, ?, 1, ?, ?, ?)",
441 )
442 .bind(&id)
443 .bind(workspace_id)
444 .bind(summary)
445 .bind(&payload)
446 .bind(seq)
447 .bind(&created_at)
448 .execute(&mut *conn)
449 .await?;
450 prune_consumed_undo_entries(conn, workspace_id).await?;
451 Ok(())
452}
453
454fn undo_payload_has_effect(payload: &UndoPayload) -> bool {
455 payload.commands.iter().any(|command| match command {
456 UndoCommand::SetTaskField { before, after, .. } => before != after,
457 UndoCommand::SetTaskLabels { before, after, .. } => !label_sets_equal(before, after),
458 UndoCommand::SetTaskMetadata { before, after, .. } => before != after,
459 UndoCommand::SetProjectMetadata {
460 before_key,
461 before_name,
462 before_prefix,
463 after_key,
464 after_name,
465 after_prefix,
466 ..
467 } => before_key != after_key || before_name != after_name || before_prefix != after_prefix,
468 UndoCommand::SetNoteBody { before, after, .. } => before != after,
469 UndoCommand::SetLabelName { before, after } => before != after,
470 UndoCommand::DeleteCreatedTask { .. }
471 | UndoCommand::RestoreDeletedNote { .. }
472 | UndoCommand::DeleteCreatedNote { .. }
473 | UndoCommand::DeleteCreatedProject { .. }
474 | UndoCommand::DeleteCreatedLabel { .. }
475 | UndoCommand::RestoreDeletedLabel { .. }
476 | UndoCommand::RestoreConflictResolution { .. }
477 | UndoCommand::AddTaskDependency { .. }
478 | UndoCommand::RemoveTaskDependency { .. }
479 | UndoCommand::SetTaskRelatedLink { .. }
480 | UndoCommand::AddEpicChild { .. }
481 | UndoCommand::RemoveEpicChild { .. } => true,
482 })
483}
484
485fn classify_undo_commands(id: String, commands: &[UndoCommand]) -> PendingUndoPresentation {
486 let mut task_ids = BTreeSet::new();
487 for command in commands {
488 match command {
489 UndoCommand::SetTaskField { task_id, .. }
490 | UndoCommand::SetTaskLabels { task_id, .. }
491 | UndoCommand::SetTaskMetadata { task_id, .. }
492 | UndoCommand::DeleteCreatedTask { task_id, .. }
493 | UndoCommand::SetNoteBody { task_id, .. }
494 | UndoCommand::RestoreDeletedNote { task_id, .. }
495 | UndoCommand::DeleteCreatedNote { task_id, .. }
496 | UndoCommand::RestoreConflictResolution { task_id, .. }
497 | UndoCommand::AddTaskDependency { task_id, .. }
498 | UndoCommand::RemoveTaskDependency { task_id, .. }
499 | UndoCommand::SetTaskRelatedLink { task_id, .. } => {
500 task_ids.insert(task_id.clone());
501 }
502 UndoCommand::AddEpicChild { child_id, .. }
503 | UndoCommand::RemoveEpicChild { child_id, .. } => {
504 task_ids.insert(child_id.clone());
505 }
506 UndoCommand::DeleteCreatedProject { .. }
507 | UndoCommand::SetProjectMetadata { .. }
508 | UndoCommand::DeleteCreatedLabel { .. }
509 | UndoCommand::SetLabelName { .. }
510 | UndoCommand::RestoreDeletedLabel { .. } => {}
511 }
512 }
513
514 let operation = if commands
515 .iter()
516 .any(|command| matches!(command, UndoCommand::DeleteCreatedTask { .. }))
517 {
518 "task creation"
519 } else if commands
520 .iter()
521 .any(|command| matches!(command, UndoCommand::SetTaskLabels { .. }))
522 {
523 "label change"
524 } else if let Some(command) = commands.iter().find(|command| {
525 matches!(
526 command,
527 UndoCommand::AddEpicChild { .. } | UndoCommand::RemoveEpicChild { .. }
528 )
529 }) {
530 match command {
531 UndoCommand::AddEpicChild { .. } => "epic membership addition",
532 UndoCommand::RemoveEpicChild { .. } => "epic membership removal",
533 _ => unreachable!(),
534 }
535 } else {
536 commands
537 .iter()
538 .find_map(undo_command_operation)
539 .unwrap_or("last TUI mutation")
540 };
541
542 PendingUndoPresentation {
543 id,
544 operation: operation.to_string(),
545 task_ids: task_ids.into_iter().collect(),
546 }
547}
548
549fn undo_command_operation(command: &UndoCommand) -> Option<&'static str> {
550 Some(match command {
551 UndoCommand::SetTaskField { field, after, .. } => match field.as_str() {
552 "title" => "title change",
553 "description" => "description change",
554 "project" => "project change",
555 "status" => "status change",
556 "priority" => "priority change",
557 "available_at" => "availability change",
558 "due_on" => "due date change",
559 "deleted" if after == "1" => "task deletion",
560 "deleted" => "task restoration",
561 "is_epic" => "epic status change",
562 _ => "task change",
563 },
564 UndoCommand::SetTaskLabels { .. } => "label change",
565 UndoCommand::SetTaskMetadata { .. } => "metadata change",
566 UndoCommand::DeleteCreatedTask { .. } => "task creation",
567 UndoCommand::SetNoteBody { .. } => "note edit",
568 UndoCommand::RestoreDeletedNote { .. } => "note deletion",
569 UndoCommand::DeleteCreatedNote { .. } => "note creation",
570 UndoCommand::DeleteCreatedProject { .. } => "project creation",
571 UndoCommand::SetProjectMetadata { .. } => "project change",
572 UndoCommand::DeleteCreatedLabel { .. } => "label creation",
573 UndoCommand::SetLabelName { .. } => "label rename",
574 UndoCommand::RestoreDeletedLabel { .. } => "label deletion",
575 UndoCommand::RestoreConflictResolution { .. } => "conflict resolution",
576 UndoCommand::AddTaskDependency { .. } => "dependency addition",
577 UndoCommand::RemoveTaskDependency { .. } => "dependency removal",
578 UndoCommand::SetTaskRelatedLink { linked, .. } => {
579 if *linked {
580 "related link addition"
581 } else {
582 "related link removal"
583 }
584 }
585 UndoCommand::AddEpicChild { .. } => "epic membership addition",
586 UndoCommand::RemoveEpicChild { .. } => "epic membership removal",
587 })
588}
589
590fn empty_command_outcome() -> CommandOutcome {
591 CommandOutcome {
592 task_id: None,
593 include_deleted: None,
594 project_rename: None,
595 label_rename: None,
596 }
597}
598
599async fn prune_consumed_undo_entries(
600 conn: &mut SqliteConnection,
601 workspace_id: &WorkspaceId,
602) -> Result<()> {
603 sqlx::query(
604 "DELETE FROM tui_undo_entries
605 WHERE workspace_id = ? AND undone_at IS NOT NULL AND id NOT IN (
606 SELECT id FROM tui_undo_entries
607 WHERE workspace_id = ? AND undone_at IS NOT NULL
608 ORDER BY undone_at DESC, seq DESC
609 LIMIT 20
610 )",
611 )
612 .bind(workspace_id)
613 .bind(workspace_id)
614 .execute(&mut *conn)
615 .await?;
616 Ok(())
617}
618
619pub(crate) async fn clear_pending_tui_undo_entries(conn: &mut SqliteConnection) -> Result<()> {
620 sqlx::query("DELETE FROM tui_undo_entries WHERE undone_at IS NULL")
621 .execute(&mut *conn)
622 .await?;
623 Ok(())
624}
625
626pub(crate) async fn latest_tui_undo_presentation(
627 conn: &mut SqliteConnection,
628 workspace_id: &WorkspaceId,
629) -> Result<Option<PendingUndoPresentation>> {
630 let row = sqlx::query(
631 "SELECT id, payload FROM tui_undo_entries
632 WHERE workspace_id = ? AND undone_at IS NULL
633 ORDER BY seq DESC
634 LIMIT 1",
635 )
636 .bind(workspace_id)
637 .fetch_optional(&mut *conn)
638 .await?;
639 let Some(row) = row else {
640 return Ok(None);
641 };
642 let id: String = row.get("id");
643 let payload: String = row.get("payload");
644 let payload: UndoPayload = serde_json::from_str(&payload)?;
645 Ok(Some(classify_undo_commands(id, &payload.commands)))
646}
647
648pub(crate) async fn apply_latest_tui_undo(
649 conn: &mut SqliteConnection,
650 workspace_id: &WorkspaceId,
651) -> Result<Option<UndoOutcome>> {
652 let mut tx = begin_immediate(conn).await?;
653 let row = sqlx::query(
654 "SELECT id, payload FROM tui_undo_entries
655 WHERE workspace_id = ? AND undone_at IS NULL
656 ORDER BY seq DESC
657 LIMIT 1",
658 )
659 .bind(workspace_id)
660 .fetch_optional(&mut *tx)
661 .await?;
662 let Some(row) = row else {
663 return Ok(None);
664 };
665 let entry_id: String = row.get("id");
666 let payload_text: String = row.get("payload");
667 let undone_at = now();
668 let claimed =
669 sqlx::query("UPDATE tui_undo_entries SET undone_at = ? WHERE id = ? AND undone_at IS NULL")
670 .bind(&undone_at)
671 .bind(&entry_id)
672 .execute(&mut *tx)
673 .await?;
674 ensure!(
675 claimed.rows_affected() == 1,
676 "error undo-entry-claim-failed id={entry_id}"
677 );
678 let payload: UndoPayload = serde_json::from_str(&payload_text)?;
679 let presentation = classify_undo_commands(entry_id.clone(), &payload.commands);
680 let apply_result = APPLYING_UNDO
681 .scope(
682 (),
683 apply_undo_commands(&mut tx, workspace_id, &payload.commands, &undone_at),
684 )
685 .await;
686 match apply_result {
687 Ok(outcome) => {
688 tx.commit().await?;
689 Ok(Some(UndoOutcome {
690 presentation,
691 task_id: outcome.task_id,
692 include_deleted: outcome.include_deleted,
693 project_rename: outcome.project_rename,
694 label_rename: outcome.label_rename,
695 }))
696 }
697 Err(error) => {
698 tx.rollback().await?;
699 Err(error)
700 }
701 }
702}
703
704struct CommandOutcome {
705 task_id: Option<crate::ids::TaskId>,
706 include_deleted: Option<bool>,
707 project_rename: Option<ProjectRenameUndoOutcome>,
708 label_rename: Option<LabelRenameUndoOutcome>,
709}
710
711async fn apply_undo_commands(
712 conn: &mut SqliteConnection,
713 workspace_id: &WorkspaceId,
714 commands: &[UndoCommand],
715 mutation_at: &str,
716) -> Result<CommandOutcome> {
717 let mut task_id = None;
718 let mut include_deleted = None;
719 let mut project_rename = None;
720 let mut label_rename = None;
721 let mut affected_attachment_hashes = BTreeSet::new();
722 for command in commands {
723 let outcome = apply_undo_command(
724 conn,
725 workspace_id,
726 command,
727 &mut affected_attachment_hashes,
728 mutation_at,
729 )
730 .await?;
731 if outcome.task_id.is_some() {
732 task_id = outcome.task_id;
733 }
734 if outcome.include_deleted.is_some() {
735 include_deleted = outcome.include_deleted;
736 }
737 if outcome.project_rename.is_some() {
738 project_rename = outcome.project_rename;
739 }
740 if outcome.label_rename.is_some() {
741 label_rename = outcome.label_rename;
742 }
743 }
744 let affected_attachment_hashes = affected_attachment_hashes.into_iter().collect::<Vec<_>>();
745 crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
746 conn,
747 &affected_attachment_hashes,
748 &crate::attachments::lifecycle::SystemClock,
749 )
750 .await?;
751 Ok(CommandOutcome {
752 task_id,
753 include_deleted,
754 project_rename,
755 label_rename,
756 })
757}
758
759async fn restore_queue_activity(
760 conn: &mut SqliteConnection,
761 workspace_id: &WorkspaceId,
762 task_id: &crate::ids::TaskId,
763 queue_activity_at: &str,
764) -> Result<()> {
765 sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
766 .bind(queue_activity_at)
767 .bind(workspace_id)
768 .bind(task_id)
769 .execute(&mut *conn)
770 .await?;
771 Ok(())
772}
773
774async fn apply_undo_command(
775 conn: &mut SqliteConnection,
776 workspace_id: &WorkspaceId,
777 command: &UndoCommand,
778 affected_attachment_hashes: &mut BTreeSet<String>,
779 mutation_at: &str,
780) -> Result<CommandOutcome> {
781 match command {
782 UndoCommand::SetTaskField {
783 task_id,
784 field,
785 before,
786 after,
787 queue_activity_before,
788 queue_activity_after,
789 } => {
790 let task_field = TaskField::parse_or_unknown(field)?;
791 let current =
792 task_field_value_for_field(conn, workspace_id, task_id, task_field).await?;
793 if current != *after {
794 bail!("error undo-state-changed task_id={task_id} field={field}");
795 }
796 let queue_activity_to_restore = if let (Some(before), Some(after)) =
797 (queue_activity_before, queue_activity_after)
798 {
799 let current: String = sqlx::query_scalar(
800 "SELECT queue_activity_at FROM tasks WHERE workspace_id = ? AND id = ?",
801 )
802 .bind(workspace_id)
803 .bind(task_id)
804 .fetch_one(&mut *conn)
805 .await?;
806 Some(if current == *after {
807 before.clone()
808 } else {
809 current
810 })
811 } else {
812 None
813 };
814 if before != after {
815 let recurrence_undone = if task_field == TaskField::Status {
816 crate::operations::undo_recurrence_resolution(
817 conn,
818 workspace_id,
819 task_id,
820 before,
821 after,
822 )
823 .await?
824 } else {
825 false
826 };
827 if recurrence_undone {
828 if let Some(queue_activity_at) = queue_activity_to_restore {
829 restore_queue_activity(conn, workspace_id, task_id, &queue_activity_at)
830 .await?;
831 }
832 return Ok(CommandOutcome {
833 task_id: Some(task_id.clone()),
834 include_deleted: None,
835 project_rename: None,
836 label_rename: None,
837 });
838 }
839 if task_field == TaskField::Project {
840 let project_id = before.parse().map_err(|_| {
841 anyhow::anyhow!("error undo-state-changed task_id={task_id} field={field}")
842 })?;
843 if !project_id_exists(conn, workspace_id, &project_id).await? {
844 bail!("error undo-state-changed task_id={task_id} field={field}");
845 }
846 }
847 if task_field == TaskField::Deleted {
848 collect_task_attachment_hashes(
849 conn,
850 workspace_id,
851 task_id,
852 affected_attachment_hashes,
853 )
854 .await?;
855 }
856 set_task_field_in_workspace(
857 conn,
858 workspace_id,
859 task_id,
860 task_field,
861 before,
862 mutation_at,
863 )
864 .await?;
865 if let Some(queue_activity_at) = queue_activity_to_restore {
866 restore_queue_activity(conn, workspace_id, task_id, &queue_activity_at).await?;
867 }
868 }
869 let include_deleted = if task_field == TaskField::Deleted {
870 Some(before == "1")
871 } else {
872 None
873 };
874 Ok(CommandOutcome {
875 task_id: Some(task_id.clone()),
876 include_deleted,
877 project_rename: None,
878 label_rename: None,
879 })
880 }
881 UndoCommand::SetTaskLabels {
882 task_id,
883 before,
884 after,
885 } => {
886 let current = task_labels(conn, workspace_id, task_id).await?;
887 if !label_sets_equal(¤t, after) {
888 bail!("error undo-state-changed task_id={task_id} field=labels");
889 }
890 let (add_labels, remove_labels) = label_delta(¤t, before);
891 update_task_labels_in_workspace(
892 conn,
893 workspace_id,
894 task_id,
895 &add_labels,
896 &remove_labels,
897 )
898 .await?;
899 Ok(CommandOutcome {
900 task_id: Some(task_id.clone()),
901 include_deleted: None,
902 project_rename: None,
903 label_rename: None,
904 })
905 }
906 UndoCommand::SetTaskMetadata {
907 task_id,
908 field_id,
909 before,
910 after,
911 } => {
912 let current: Option<String> = sqlx::query_scalar(
913 "SELECT value FROM task_metadata
914 WHERE workspace_id = ? AND task_id = ? AND field_id = ?",
915 )
916 .bind(workspace_id)
917 .bind(task_id)
918 .bind(field_id)
919 .fetch_optional(&mut *conn)
920 .await?;
921 if current != *after {
922 bail!("error undo-state-changed task_id={task_id} field=metadata");
923 }
924 let field = crate::metadata::metadata_field_by_id(conn, workspace_id, field_id)
925 .await?
926 .ok_or_else(|| anyhow::anyhow!("error metadata-field-not-found"))?;
927 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
928 if let Some(value) = before {
929 crate::metadata::set_task_metadata(
930 conn,
931 &workspace,
932 task_id,
933 &crate::metadata::TaskMetadataInput {
934 expected_field_id: None,
935 key: field.key,
936 value: value.clone(),
937 },
938 )
939 .await?;
940 } else {
941 crate::metadata::remove_task_metadata(conn, &workspace, task_id, &field.key)
942 .await?;
943 }
944 Ok(CommandOutcome {
945 task_id: Some(task_id.clone()),
946 include_deleted: None,
947 project_rename: None,
948 label_rename: None,
949 })
950 }
951 UndoCommand::DeleteCreatedTask {
952 task_id,
953 create_change_id,
954 expected,
955 attachment_ids,
956 attachment_change_ids,
957 } => {
958 let mut current = task_snapshot(conn, workspace_id, task_id).await?;
959 let has_independent_state =
960 task_has_independent_creation_state(conn, workspace_id, task_id).await?;
961 if has_independent_state {
963 current
964 .queue_activity_at
965 .clone_from(&expected.queue_activity_at);
966 }
967 if current != *expected {
968 bail!("error undo-state-changed task_id={task_id} field=task");
969 }
970 let current_attachment_ids: Vec<String> = sqlx::query_scalar(
971 "SELECT attachment_id FROM task_attachments
972 WHERE workspace_id = ? AND task_id = ? AND deleted = 0
973 ORDER BY created_at, attachment_id",
974 )
975 .bind(workspace_id)
976 .bind(task_id)
977 .fetch_all(&mut *conn)
978 .await?;
979 if let Some(change_id) = create_change_id {
980 let labels_clear = expected.labels.is_empty()
981 || labels_match_create_change(conn, change_id, &expected.labels).await?;
982 let attachment_changes_clear =
983 all_changes_unsynced(conn, attachment_change_ids).await?;
984 let related_state_clear =
985 !crate::operations::task_has_related_state(conn, workspace_id, task_id).await?;
986 if change_is_unsynced(conn, change_id).await?
987 && labels_clear
988 && attachment_changes_clear
989 && related_state_clear
990 && !has_independent_state
991 && current_attachment_ids == *attachment_ids
992 {
993 hard_delete_created_task(
994 conn,
995 workspace_id,
996 task_id,
997 change_id,
998 attachment_change_ids,
999 affected_attachment_hashes,
1000 )
1001 .await?;
1002 return Ok(CommandOutcome {
1003 task_id: Some(task_id.clone()),
1004 include_deleted: None,
1005 project_rename: None,
1006 label_rename: None,
1007 });
1008 }
1009 }
1010 collect_task_attachment_hashes(conn, workspace_id, task_id, affected_attachment_hashes)
1011 .await?;
1012 set_task_field_in_workspace(
1013 conn,
1014 workspace_id,
1015 task_id,
1016 TaskField::Deleted,
1017 "1",
1018 mutation_at,
1019 )
1020 .await?;
1021 Ok(CommandOutcome {
1022 task_id: Some(task_id.clone()),
1023 include_deleted: None,
1024 project_rename: None,
1025 label_rename: None,
1026 })
1027 }
1028 UndoCommand::SetNoteBody {
1029 task_id,
1030 note_id,
1031 before,
1032 after,
1033 } => {
1034 let current = sqlx::query_scalar::<_, String>(
1035 "SELECT body FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1036 )
1037 .bind(workspace_id)
1038 .bind(task_id)
1039 .bind(note_id)
1040 .fetch_optional(&mut *conn)
1041 .await?;
1042 if current.as_deref() != Some(after) {
1043 bail!("error undo-state-changed task_id={task_id} field=notes");
1044 }
1045 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1046 crate::operations::route_recurrence_task_mutation(
1047 conn,
1048 &workspace,
1049 task_id,
1050 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1051 mutation_at,
1052 )
1053 .await?;
1054 let edited_at = mutation_at;
1055 sqlx::query(
1056 "UPDATE notes SET body = ? WHERE workspace_id = ? AND task_id = ? AND id = ?",
1057 )
1058 .bind(before)
1059 .bind(workspace_id)
1060 .bind(task_id)
1061 .bind(note_id)
1062 .execute(&mut *conn)
1063 .await?;
1064 crate::change_log::append_change(
1065 conn,
1066 crate::change_log::ChangeEntity::Task,
1067 task_id,
1068 Some("notes"),
1069 crate::change_log::op_type::NOTE_EDIT,
1070 crate::change_log::ChangePayload::workspace(&workspace)
1071 .set("note_id", note_id)
1072 .set("body", before)
1073 .set("edited_at", edited_at),
1074 )
1075 .await?;
1076 sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1077 .bind(edited_at)
1078 .bind(workspace_id)
1079 .bind(task_id)
1080 .execute(&mut *conn)
1081 .await?;
1082 Ok(CommandOutcome {
1083 task_id: Some(task_id.clone()),
1084 include_deleted: None,
1085 project_rename: None,
1086 label_rename: None,
1087 })
1088 }
1089 UndoCommand::RestoreDeletedNote {
1090 task_id,
1091 note_id,
1092 body,
1093 created_at,
1094 note_add_change_id,
1095 } => {
1096 let exists: i64 = sqlx::query_scalar(
1097 "SELECT count(*) FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1098 )
1099 .bind(workspace_id)
1100 .bind(task_id)
1101 .bind(note_id)
1102 .fetch_one(&mut *conn)
1103 .await?;
1104 if exists != 0 {
1105 bail!("error undo-state-changed task_id={task_id} field=notes");
1106 }
1107 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1108 crate::operations::route_recurrence_task_mutation(
1109 conn,
1110 &workspace,
1111 task_id,
1112 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1113 mutation_at,
1114 )
1115 .await?;
1116 let change_id = crate::change_log::append_change(
1117 conn,
1118 crate::change_log::ChangeEntity::Task,
1119 task_id,
1120 Some("notes"),
1121 crate::change_log::op_type::NOTE_ADD,
1122 crate::change_log::ChangePayload::workspace(&workspace)
1123 .set("note_id", note_id)
1124 .set("body", body)
1125 .set("created_at", created_at),
1126 )
1127 .await?;
1128 sqlx::query(
1129 "INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id)
1130 VALUES (?, ?, ?, ?, ?, ?)",
1131 )
1132 .bind(workspace_id)
1133 .bind(note_id)
1134 .bind(task_id)
1135 .bind(body)
1136 .bind(created_at)
1137 .bind(&change_id)
1138 .execute(&mut *conn)
1139 .await?;
1140 extend_note_undo_lineage(
1141 conn,
1142 workspace_id,
1143 task_id,
1144 note_id,
1145 note_add_change_id,
1146 &change_id,
1147 )
1148 .await?;
1149 sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1150 .bind(now())
1151 .bind(workspace_id)
1152 .bind(task_id)
1153 .execute(&mut *conn)
1154 .await?;
1155 Ok(CommandOutcome {
1156 task_id: Some(task_id.clone()),
1157 include_deleted: None,
1158 project_rename: None,
1159 label_rename: None,
1160 })
1161 }
1162 UndoCommand::DeleteCreatedNote {
1163 task_id,
1164 note_id,
1165 note_add_change_id,
1166 restoration_change_ids,
1167 } => {
1168 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1169 crate::operations::route_recurrence_task_mutation(
1170 conn,
1171 &workspace,
1172 task_id,
1173 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1174 mutation_at,
1175 )
1176 .await?;
1177 delete_created_note(
1178 conn,
1179 workspace_id,
1180 task_id,
1181 note_id,
1182 note_add_change_id,
1183 restoration_change_ids,
1184 )
1185 .await?;
1186 Ok(CommandOutcome {
1187 task_id: Some(task_id.clone()),
1188 include_deleted: None,
1189 project_rename: None,
1190 label_rename: None,
1191 })
1192 }
1193 UndoCommand::DeleteCreatedProject {
1194 project_key,
1195 create_change_id,
1196 expected_name,
1197 expected_prefix,
1198 } => {
1199 delete_created_project(
1200 conn,
1201 workspace_id,
1202 project_key,
1203 create_change_id,
1204 expected_name,
1205 expected_prefix,
1206 )
1207 .await?;
1208 Ok(empty_command_outcome())
1209 }
1210 UndoCommand::SetProjectMetadata {
1211 project_id,
1212 before_key,
1213 before_name,
1214 before_prefix,
1215 after_key,
1216 after_name,
1217 after_prefix,
1218 } => {
1219 set_project_metadata_for_undo(
1220 conn,
1221 workspace_id,
1222 project_id,
1223 ProjectMetadata {
1224 key: before_key,
1225 name: before_name,
1226 prefix: before_prefix,
1227 },
1228 ProjectMetadata {
1229 key: after_key,
1230 name: after_name,
1231 prefix: after_prefix,
1232 },
1233 )
1234 .await?;
1235 Ok(CommandOutcome {
1236 task_id: None,
1237 include_deleted: None,
1238 project_rename: Some(ProjectRenameUndoOutcome {
1239 before_key: before_key.clone(),
1240 after_key: after_key.clone(),
1241 }),
1242 label_rename: None,
1243 })
1244 }
1245 UndoCommand::DeleteCreatedLabel {
1246 label,
1247 create_change_id,
1248 } => {
1249 delete_created_label(conn, workspace_id, label, create_change_id).await?;
1250 Ok(empty_command_outcome())
1251 }
1252 UndoCommand::SetLabelName { before, after } => {
1253 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1254 let after_exists = sqlx::query_scalar::<_, i64>(
1255 "SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?",
1256 )
1257 .bind(workspace_id)
1258 .bind(after)
1259 .fetch_one(&mut *conn)
1260 .await?
1261 == 1;
1262 let before_exists = sqlx::query_scalar::<_, i64>(
1263 "SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?",
1264 )
1265 .bind(workspace_id)
1266 .bind(before)
1267 .fetch_one(&mut *conn)
1268 .await?
1269 > 0;
1270 if !after_exists || before_exists {
1271 bail!("error undo-state-changed label={after}");
1272 }
1273 crate::operations::set_label_name(conn, &workspace, after, before).await?;
1274 Ok(CommandOutcome {
1275 task_id: None,
1276 include_deleted: None,
1277 project_rename: None,
1278 label_rename: Some(LabelRenameUndoOutcome {
1279 before: before.clone(),
1280 after: after.clone(),
1281 }),
1282 })
1283 }
1284 UndoCommand::RestoreDeletedLabel {
1285 name,
1286 created_at,
1287 task_ids,
1288 series_ids,
1289 } => {
1290 restore_deleted_label(conn, workspace_id, name, created_at, task_ids, series_ids)
1291 .await?;
1292 Ok(empty_command_outcome())
1293 }
1294 UndoCommand::RestoreConflictResolution {
1295 task_id,
1296 field,
1297 before,
1298 after,
1299 conflict_id,
1300 } => {
1301 let task_field = TaskField::parse_or_unknown(field)?;
1302 let current =
1303 task_field_value_for_field(conn, workspace_id, task_id, task_field).await?;
1304 if current != *after {
1305 bail!("error undo-state-changed task_id={task_id} field={field}");
1306 }
1307 if task_field == TaskField::Deleted && before != after {
1308 collect_task_attachment_hashes(
1309 conn,
1310 workspace_id,
1311 task_id,
1312 affected_attachment_hashes,
1313 )
1314 .await?;
1315 }
1316 set_task_field_in_workspace(
1317 conn,
1318 workspace_id,
1319 task_id,
1320 task_field,
1321 before,
1322 mutation_at,
1323 )
1324 .await?;
1325 let restored = sqlx::query(
1326 "UPDATE conflicts SET resolved = 0 WHERE id = ? AND workspace_id = ? AND resolved = 1",
1327 )
1328 .bind(conflict_id)
1329 .bind(workspace_id)
1330 .execute(&mut *conn)
1331 .await?;
1332 ensure!(
1333 restored.rows_affected() == 1,
1334 "error undo-state-changed task_id={task_id} field={field}"
1335 );
1336 Ok(CommandOutcome {
1337 task_id: Some(task_id.clone()),
1338 include_deleted: None,
1339 project_rename: None,
1340 label_rename: None,
1341 })
1342 }
1343 UndoCommand::AddTaskDependency {
1344 task_id,
1345 depends_on_task_id,
1346 } => {
1347 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1348 crate::operations::route_recurrence_task_mutation(
1349 conn,
1350 &workspace,
1351 task_id,
1352 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1353 mutation_at,
1354 )
1355 .await?;
1356 crate::operations::route_recurrence_task_mutation(
1357 conn,
1358 &workspace,
1359 depends_on_task_id,
1360 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1361 mutation_at,
1362 )
1363 .await?;
1364 ensure!(
1365 dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
1366 "error undo-state-changed task_id={task_id} field=dependency"
1367 );
1368 remove_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
1369 Ok(CommandOutcome {
1370 task_id: Some(task_id.clone()),
1371 include_deleted: None,
1372 project_rename: None,
1373 label_rename: None,
1374 })
1375 }
1376 UndoCommand::RemoveTaskDependency {
1377 task_id,
1378 depends_on_task_id,
1379 } => {
1380 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1381 crate::operations::route_recurrence_task_mutation(
1382 conn,
1383 &workspace,
1384 task_id,
1385 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1386 mutation_at,
1387 )
1388 .await?;
1389 crate::operations::route_recurrence_task_mutation(
1390 conn,
1391 &workspace,
1392 depends_on_task_id,
1393 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1394 mutation_at,
1395 )
1396 .await?;
1397 ensure!(
1398 !dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
1399 "error undo-state-changed task_id={task_id} field=dependency"
1400 );
1401 ensure!(
1402 !crate::operations::dependency_path_exists(
1403 conn,
1404 workspace_id,
1405 depends_on_task_id,
1406 task_id,
1407 )
1408 .await?,
1409 "error dependency-cycle task_id={task_id} depends_on_task_id={depends_on_task_id}"
1410 );
1411 add_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
1412 Ok(CommandOutcome {
1413 task_id: Some(task_id.clone()),
1414 include_deleted: None,
1415 project_rename: None,
1416 label_rename: None,
1417 })
1418 }
1419 UndoCommand::SetTaskRelatedLink {
1420 task_id,
1421 related_task_id,
1422 forward_change_id,
1423 linked,
1424 } => {
1425 let (task_a_id, task_b_id) =
1426 crate::operations::canonical_related_pair(task_id, related_task_id)?;
1427 let current_change_id = sqlx::query_scalar::<_, String>(
1428 "SELECT last_change_id FROM task_related_links
1429 WHERE workspace_id = ? AND task_a_id = ? AND task_b_id = ?",
1430 )
1431 .bind(workspace_id)
1432 .bind(task_a_id)
1433 .bind(task_b_id)
1434 .fetch_optional(&mut *conn)
1435 .await?;
1436 ensure!(
1437 current_change_id.as_deref() == Some(forward_change_id),
1438 "error undo-state-changed task_id={task_id} field=related"
1439 );
1440 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1441 crate::operations::set_task_related_link_in_transaction(
1442 conn,
1443 &workspace,
1444 task_id,
1445 related_task_id,
1446 !linked,
1447 )
1448 .await?;
1449 Ok(CommandOutcome {
1450 task_id: Some(task_id.clone()),
1451 include_deleted: None,
1452 project_rename: None,
1453 label_rename: None,
1454 })
1455 }
1456 UndoCommand::AddEpicChild { epic_id, child_id } => {
1457 ensure!(
1458 epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
1459 "error undo-state-changed task_id={child_id} field=epic"
1460 );
1461 set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, false).await?;
1462 Ok(CommandOutcome {
1463 task_id: Some(child_id.clone()),
1464 include_deleted: None,
1465 project_rename: None,
1466 label_rename: None,
1467 })
1468 }
1469 UndoCommand::RemoveEpicChild { epic_id, child_id } => {
1470 ensure!(
1471 !epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
1472 "error undo-state-changed task_id={child_id} field=epic"
1473 );
1474 set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, true).await?;
1475 Ok(CommandOutcome {
1476 task_id: Some(child_id.clone()),
1477 include_deleted: None,
1478 project_rename: None,
1479 label_rename: None,
1480 })
1481 }
1482 }
1483}
1484
1485async fn project_id_exists(
1486 conn: &mut SqliteConnection,
1487 workspace_id: &WorkspaceId,
1488 project_id: &ProjectId,
1489) -> Result<bool> {
1490 Ok(sqlx::query_scalar::<_, i64>(
1491 "SELECT count(*) FROM projects
1492 WHERE workspace_id = ? AND id = ? AND deleted = 0",
1493 )
1494 .bind(workspace_id)
1495 .bind(project_id)
1496 .fetch_one(&mut *conn)
1497 .await?
1498 > 0)
1499}
1500
1501async fn set_task_field_in_workspace(
1502 conn: &mut SqliteConnection,
1503 workspace_id: &WorkspaceId,
1504 task_id: &crate::ids::TaskId,
1505 task_field: TaskField,
1506 value: &str,
1507 mutation_at: &str,
1508) -> Result<()> {
1509 let field = task_field.as_str();
1510 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1511 match crate::operations::route_recurrence_task_mutation(
1512 conn,
1513 &workspace,
1514 task_id,
1515 RecurrenceTaskMutation::Scalar {
1516 field: task_field,
1517 value,
1518 },
1519 mutation_at,
1520 )
1521 .await?
1522 {
1523 crate::operations::RecurrenceMutationOutcome::Proceed => {}
1524 crate::operations::RecurrenceMutationOutcome::NoChange
1525 | crate::operations::RecurrenceMutationOutcome::Handled => return Ok(()),
1526 }
1527 if conflict_exists(conn, workspace_id, task_id, field).await? {
1528 bail!(
1529 "error conflicted-field ref={} field={} hint=\"use conflict resolve\"",
1530 task_id,
1531 field
1532 );
1533 }
1534 let base = field_version(conn, task_id, field).await?;
1535 let workspace_key = workspace_key_for_id(conn, workspace_id).await?;
1536 let payload = if task_field.is_project() {
1537 let project = resolve_project_for_stored_value(conn, workspace_id, value).await?;
1538 apply_project_id_in_workspace(conn, workspace_id, task_id, &project.id).await?;
1539 TaskField::project_payload(workspace_id, &workspace_key, &project)
1540 } else {
1541 apply_field_value_in_workspace(conn, workspace_id, task_id, field, value).await?;
1542 task_field.scalar_payload(workspace_id, &workspace_key, value)?
1543 };
1544 let change_id = insert_change(
1545 conn,
1546 "task",
1547 task_id,
1548 Some(field),
1549 "set_field",
1550 payload,
1551 base.as_deref(),
1552 )
1553 .await?;
1554 set_field_version(conn, task_id, field, &change_id).await?;
1555 Ok(())
1556}
1557
1558fn label_sets_equal(left: &[String], right: &[String]) -> bool {
1559 let left: BTreeSet<_> = left.iter().collect();
1560 let right: BTreeSet<_> = right.iter().collect();
1561 left == right
1562}
1563
1564fn label_delta(current: &[String], target: &[String]) -> (Vec<String>, Vec<String>) {
1565 let current_set: BTreeSet<_> = current.iter().collect();
1566 let target_set: BTreeSet<_> = target.iter().collect();
1567 let add = target
1568 .iter()
1569 .filter(|label| !current_set.contains(label))
1570 .cloned()
1571 .collect();
1572 let remove = current
1573 .iter()
1574 .filter(|label| !target_set.contains(label))
1575 .cloned()
1576 .collect();
1577 (add, remove)
1578}
1579
1580async fn change_is_unsynced(conn: &mut SqliteConnection, change_id: &str) -> Result<bool> {
1581 let server_seq =
1582 sqlx::query_scalar::<_, Option<i64>>("SELECT server_seq FROM changes WHERE change_id = ?")
1583 .bind(change_id)
1584 .fetch_optional(&mut *conn)
1585 .await?;
1586 Ok(matches!(server_seq, Some(None)))
1587}
1588
1589async fn all_changes_unsynced(conn: &mut SqliteConnection, change_ids: &[String]) -> Result<bool> {
1590 for change_id in change_ids {
1591 if !change_is_unsynced(conn, change_id).await? {
1592 return Ok(false);
1593 }
1594 }
1595 Ok(true)
1596}
1597
1598async fn labels_match_create_change(
1599 conn: &mut SqliteConnection,
1600 change_id: &str,
1601 labels: &[String],
1602) -> Result<bool> {
1603 let payload: String = sqlx::query_scalar("SELECT payload FROM changes WHERE change_id = ?")
1604 .bind(change_id)
1605 .fetch_one(&mut *conn)
1606 .await?;
1607 let payload: serde_json::Value = serde_json::from_str(&payload)?;
1608 let payload_labels = payload
1609 .get("labels")
1610 .and_then(|labels| labels.as_array())
1611 .map(|labels| {
1612 labels
1613 .iter()
1614 .filter_map(|label| label.as_str().map(str::to_string))
1615 .collect::<Vec<_>>()
1616 })
1617 .unwrap_or_default();
1618 Ok(label_sets_equal(labels, &payload_labels))
1619}
1620
1621async fn collect_task_attachment_hashes(
1622 conn: &mut SqliteConnection,
1623 workspace_id: &WorkspaceId,
1624 task_id: &crate::ids::TaskId,
1625 affected_attachment_hashes: &mut BTreeSet<String>,
1626) -> Result<()> {
1627 let hashes: Vec<String> = sqlx::query_scalar(
1628 "SELECT DISTINCT sha256 FROM task_attachments
1629 WHERE workspace_id = ? AND task_id = ?",
1630 )
1631 .bind(workspace_id)
1632 .bind(task_id)
1633 .fetch_all(&mut *conn)
1634 .await?;
1635 affected_attachment_hashes.extend(hashes);
1636 Ok(())
1637}
1638
1639async fn task_has_independent_creation_state(
1641 conn: &mut SqliteConnection,
1642 workspace_id: &WorkspaceId,
1643 task_id: &crate::ids::TaskId,
1644) -> Result<bool> {
1645 Ok(sqlx::query_scalar(
1646 "SELECT EXISTS(SELECT 1 FROM notes WHERE workspace_id = ?1 AND task_id = ?2)
1647 OR EXISTS(SELECT 1 FROM task_dependencies
1648 WHERE workspace_id = ?1 AND (task_id = ?2 OR depends_on_task_id = ?2))
1649 OR EXISTS(SELECT 1 FROM task_epic_links
1650 WHERE workspace_id = ?1 AND (epic_task_id = ?2 OR child_task_id = ?2))",
1651 )
1652 .bind(workspace_id)
1653 .bind(task_id)
1654 .fetch_one(&mut *conn)
1655 .await?)
1656}
1657
1658async fn hard_delete_created_task(
1659 conn: &mut SqliteConnection,
1660 workspace_id: &WorkspaceId,
1661 task_id: &crate::ids::TaskId,
1662 create_change_id: &str,
1663 attachment_change_ids: &[String],
1664 affected_attachment_hashes: &mut BTreeSet<String>,
1665) -> Result<()> {
1666 collect_task_attachment_hashes(conn, workspace_id, task_id, affected_attachment_hashes).await?;
1667 sqlx::query("DELETE FROM task_attachments WHERE workspace_id = ? AND task_id = ?")
1668 .bind(workspace_id)
1669 .bind(task_id)
1670 .execute(&mut *conn)
1671 .await?;
1672 sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ?")
1673 .bind(workspace_id)
1674 .bind(task_id)
1675 .execute(&mut *conn)
1676 .await?;
1677 sqlx::query("DELETE FROM task_metadata WHERE workspace_id = ? AND task_id = ?")
1678 .bind(workspace_id)
1679 .bind(task_id)
1680 .execute(&mut *conn)
1681 .await?;
1682 sqlx::query("DELETE FROM field_versions WHERE entity_id = ?")
1683 .bind(task_id)
1684 .execute(&mut *conn)
1685 .await?;
1686 sqlx::query("DELETE FROM tasks WHERE workspace_id = ? AND id = ?")
1687 .bind(workspace_id)
1688 .bind(task_id)
1689 .execute(&mut *conn)
1690 .await?;
1691 for change_id in attachment_change_ids {
1692 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1693 .bind(change_id)
1694 .execute(&mut *conn)
1695 .await?;
1696 }
1697 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1698 .bind(create_change_id)
1699 .execute(&mut *conn)
1700 .await?;
1701 Ok(())
1702}
1703
1704async fn extend_note_undo_lineage(
1707 conn: &mut SqliteConnection,
1708 workspace_id: &WorkspaceId,
1709 task_id: &crate::ids::TaskId,
1710 note_id: &str,
1711 previous_change_id: &str,
1712 restored_change_id: &str,
1713) -> Result<()> {
1714 let rows = sqlx::query(
1715 "SELECT id, payload FROM tui_undo_entries WHERE workspace_id = ? AND undone_at IS NULL",
1716 )
1717 .bind(workspace_id)
1718 .fetch_all(&mut *conn)
1719 .await?;
1720 for row in rows {
1721 let mut payload: UndoPayload = serde_json::from_str(row.get("payload"))?;
1722 let mut changed = false;
1723 for command in &mut payload.commands {
1724 if let UndoCommand::DeleteCreatedNote {
1725 task_id: expected_task,
1726 note_id: expected_note,
1727 note_add_change_id,
1728 restoration_change_ids,
1729 } = command
1730 {
1731 let expected = restoration_change_ids.last().unwrap_or(note_add_change_id);
1732 if expected_task == task_id
1733 && expected_note == note_id
1734 && expected == previous_change_id
1735 {
1736 restoration_change_ids.push(restored_change_id.to_string());
1737 changed = true;
1738 }
1739 }
1740 }
1741 if changed {
1742 sqlx::query("UPDATE tui_undo_entries SET payload = ? WHERE id = ?")
1743 .bind(serde_json::to_string(&payload)?)
1744 .bind(row.get::<String, _>("id"))
1745 .execute(&mut *conn)
1746 .await?;
1747 }
1748 }
1749 Ok(())
1750}
1751
1752async fn delete_created_note(
1753 conn: &mut SqliteConnection,
1754 workspace_id: &WorkspaceId,
1755 task_id: &crate::ids::TaskId,
1756 note_id: &str,
1757 note_add_change_id: &str,
1758 restoration_change_ids: &[String],
1759) -> Result<()> {
1760 let row = sqlx::query(
1761 "SELECT change_id FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?",
1762 )
1763 .bind(workspace_id)
1764 .bind(note_id)
1765 .bind(task_id)
1766 .fetch_optional(&mut *conn)
1767 .await?;
1768 let Some(row) = row else {
1769 bail!("error undo-state-changed task_id={task_id} field=note");
1770 };
1771 let stored_change_id: String = row.get("change_id");
1772 let expected_change_id = restoration_change_ids
1773 .last()
1774 .map(String::as_str)
1775 .unwrap_or(note_add_change_id);
1776 if stored_change_id != expected_change_id {
1777 bail!("error undo-state-changed task_id={task_id} field=note");
1778 }
1779 for change_id in
1780 std::iter::once(note_add_change_id).chain(restoration_change_ids.iter().map(String::as_str))
1781 {
1782 if !change_is_unsynced(conn, change_id).await? {
1783 bail!("error undo-state-changed task_id={task_id} field=note");
1784 }
1785 }
1786 sqlx::query("DELETE FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?")
1787 .bind(workspace_id)
1788 .bind(note_id)
1789 .bind(task_id)
1790 .execute(&mut *conn)
1791 .await?;
1792 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1793 .bind(expected_change_id)
1794 .execute(&mut *conn)
1795 .await?;
1796 Ok(())
1797}
1798
1799async fn delete_created_project(
1800 conn: &mut SqliteConnection,
1801 workspace_id: &WorkspaceId,
1802 project_key: &str,
1803 create_change_id: &str,
1804 expected_name: &str,
1805 expected_prefix: &str,
1806) -> Result<()> {
1807 let row = sqlx::query(
1808 "SELECT id, name, prefix FROM projects WHERE workspace_id = ? AND key = ? AND deleted = 0",
1809 )
1810 .bind(workspace_id)
1811 .bind(project_key)
1812 .fetch_optional(&mut *conn)
1813 .await?;
1814 let Some(row) = row else {
1815 bail!("error undo-state-changed project_key={project_key}");
1816 };
1817 let project_id: ProjectId = row.get("id");
1818 let name: String = row.get("name");
1819 let prefix: String = row.get("prefix");
1820 if name != expected_name || prefix != expected_prefix {
1821 bail!("error undo-state-changed project_key={project_key}");
1822 }
1823 if !change_is_unsynced(conn, create_change_id).await? {
1824 bail!("error undo-state-changed project_key={project_key}");
1825 }
1826 let task_refs: i64 =
1827 sqlx::query_scalar("SELECT count(*) FROM tasks WHERE workspace_id = ? AND project_id = ?")
1828 .bind(workspace_id)
1829 .bind(&project_id)
1830 .fetch_one(&mut *conn)
1831 .await?;
1832 if task_refs > 0 {
1833 bail!("error undo-state-changed project_key={project_key}");
1834 }
1835 let path_refs: i64 = sqlx::query_scalar(
1836 "SELECT count(*) FROM project_paths WHERE workspace_id = ? AND project_id = ?",
1837 )
1838 .bind(workspace_id)
1839 .bind(&project_id)
1840 .fetch_one(&mut *conn)
1841 .await?;
1842 if path_refs > 0 {
1843 bail!("error undo-state-changed project_key={project_key}");
1844 }
1845 sqlx::query("DELETE FROM projects WHERE workspace_id = ? AND key = ?")
1846 .bind(workspace_id)
1847 .bind(project_key)
1848 .execute(&mut *conn)
1849 .await?;
1850 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1851 .bind(create_change_id)
1852 .execute(&mut *conn)
1853 .await?;
1854 Ok(())
1855}
1856
1857async fn set_project_metadata_for_undo(
1858 conn: &mut SqliteConnection,
1859 workspace_id: &WorkspaceId,
1860 project_id: &ProjectId,
1861 before: ProjectMetadata<'_>,
1862 after: ProjectMetadata<'_>,
1863) -> Result<()> {
1864 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1865 let row = sqlx::query(
1866 "SELECT key, name, prefix
1867 FROM projects
1868 WHERE workspace_id = ? AND id = ? AND deleted = 0",
1869 )
1870 .bind(workspace_id)
1871 .bind(project_id)
1872 .fetch_optional(&mut *conn)
1873 .await?;
1874 let Some(row) = row else {
1875 bail!("error undo-state-changed project_id={project_id}");
1876 };
1877 let key: String = row.get("key");
1878 let name: String = row.get("name");
1879 let prefix: String = row.get("prefix");
1880 if key != after.key || name != after.name || prefix != after.prefix {
1881 bail!("error undo-state-changed project_id={project_id}");
1882 }
1883 let key_refs: i64 = sqlx::query_scalar(
1884 "SELECT count(*) FROM projects
1885 WHERE workspace_id = ? AND key = ? AND id != ? AND deleted = 0",
1886 )
1887 .bind(workspace_id)
1888 .bind(before.key)
1889 .bind(project_id)
1890 .fetch_one(&mut *conn)
1891 .await?;
1892 if key_refs > 0 {
1893 bail!("error undo-state-changed project_id={project_id}");
1894 }
1895 let prefix_refs: i64 = sqlx::query_scalar(
1896 "SELECT count(*) FROM projects
1897 WHERE workspace_id = ? AND prefix = ? AND id != ? AND deleted = 0",
1898 )
1899 .bind(workspace_id)
1900 .bind(before.prefix)
1901 .bind(project_id)
1902 .fetch_one(&mut *conn)
1903 .await?;
1904 if prefix_refs > 0 {
1905 bail!("error undo-state-changed project_id={project_id}");
1906 }
1907 set_project_metadata(conn, &workspace, project_id, before, false).await?;
1908 insert_project_metadata_change(conn, &workspace, project_id, before, &now()).await?;
1909 Ok(())
1910}
1911
1912async fn delete_created_label(
1913 conn: &mut SqliteConnection,
1914 workspace_id: &WorkspaceId,
1915 label: &str,
1916 create_change_id: &str,
1917) -> Result<()> {
1918 let exists: i64 =
1919 sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
1920 .bind(workspace_id)
1921 .bind(label)
1922 .fetch_one(&mut *conn)
1923 .await?;
1924 if exists == 0 || !change_is_unsynced(conn, create_change_id).await? {
1925 bail!("error undo-state-changed label={label}");
1926 }
1927 let task_refs: i64 =
1928 sqlx::query_scalar("SELECT count(*) FROM task_labels WHERE workspace_id = ? AND label = ?")
1929 .bind(workspace_id)
1930 .bind(label)
1931 .fetch_one(&mut *conn)
1932 .await?;
1933 let series_refs: i64 = sqlx::query_scalar(
1934 "SELECT count(*) FROM recurrence_series_labels WHERE workspace_id = ? AND label = ?",
1935 )
1936 .bind(workspace_id)
1937 .bind(label)
1938 .fetch_one(&mut *conn)
1939 .await?;
1940 if task_refs > 0 || series_refs > 0 {
1941 bail!("error undo-state-changed label={label}");
1942 }
1943 sqlx::query("DELETE FROM labels WHERE workspace_id = ? AND name = ?")
1944 .bind(workspace_id)
1945 .bind(label)
1946 .execute(&mut *conn)
1947 .await?;
1948 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1949 .bind(create_change_id)
1950 .execute(&mut *conn)
1951 .await?;
1952 Ok(())
1953}
1954
1955async fn restore_deleted_label(
1956 conn: &mut SqliteConnection,
1957 workspace_id: &WorkspaceId,
1958 name: &str,
1959 created_at: &str,
1960 task_ids: &[crate::ids::TaskId],
1961 series_ids: &[crate::recurrence::RecurrenceSeriesId],
1962) -> Result<()> {
1963 let exists: i64 =
1964 sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
1965 .bind(workspace_id)
1966 .bind(name)
1967 .fetch_one(&mut *conn)
1968 .await?;
1969 if exists > 0 {
1970 bail!("error undo-state-changed label={name}");
1971 }
1972 sqlx::query("INSERT INTO labels(workspace_id, name, created_at) VALUES (?, ?, ?)")
1973 .bind(workspace_id)
1974 .bind(name)
1975 .bind(created_at)
1976 .execute(&mut *conn)
1977 .await?;
1978 for task_id in task_ids {
1979 let inserted = sqlx::query(
1980 "INSERT INTO task_labels(workspace_id, task_id, label)
1981 SELECT ?, id, ? FROM tasks WHERE workspace_id = ? AND id = ?",
1982 )
1983 .bind(workspace_id)
1984 .bind(name)
1985 .bind(workspace_id)
1986 .bind(task_id)
1987 .execute(&mut *conn)
1988 .await?;
1989 if inserted.rows_affected() != 1 {
1990 bail!("error undo-state-changed label={name} task_id={task_id}");
1991 }
1992 }
1993 for series_id in series_ids {
1994 let inserted = sqlx::query(
1995 "INSERT INTO recurrence_series_labels(workspace_id, series_id, label)
1996 SELECT ?, id, ? FROM recurrence_series WHERE workspace_id = ? AND id = ?",
1997 )
1998 .bind(workspace_id)
1999 .bind(name)
2000 .bind(workspace_id)
2001 .bind(series_id)
2002 .execute(&mut *conn)
2003 .await?;
2004 if inserted.rows_affected() != 1 {
2005 bail!("error undo-state-changed label={name} series_id={series_id}");
2006 }
2007 }
2008 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2009 append_change(
2010 conn,
2011 ChangeEntity::Label,
2012 name,
2013 None,
2014 op_type::LABEL_RESTORE,
2015 ChangePayload::workspace(&workspace)
2016 .set("name", name)
2017 .set("created_at", created_at)
2018 .set("task_ids", task_ids)
2019 .set("series_ids", series_ids)
2020 .set("restored_at", now()),
2021 )
2022 .await?;
2023 Ok(())
2024}
2025
2026async fn dependency_task_exists(
2027 conn: &mut SqliteConnection,
2028 workspace_id: &WorkspaceId,
2029 task_id: &crate::ids::TaskId,
2030) -> Result<bool> {
2031 Ok(
2032 sqlx::query_scalar::<_, i64>(
2033 "SELECT count(*) FROM tasks WHERE workspace_id = ? AND id = ?",
2034 )
2035 .bind(workspace_id)
2036 .bind(task_id)
2037 .fetch_one(&mut *conn)
2038 .await?
2039 > 0,
2040 )
2041}
2042
2043async fn dependency_edge_exists(
2044 conn: &mut SqliteConnection,
2045 workspace_id: &WorkspaceId,
2046 task_id: &crate::ids::TaskId,
2047 depends_on_task_id: &crate::ids::TaskId,
2048) -> Result<bool> {
2049 ensure!(
2050 dependency_task_exists(conn, workspace_id, task_id).await?
2051 && dependency_task_exists(conn, workspace_id, depends_on_task_id).await?,
2052 "error undo-state-changed task_id={task_id} field=dependency"
2053 );
2054 Ok(sqlx::query_scalar::<_, i64>(
2055 "SELECT count(*) FROM task_dependencies
2056 WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
2057 )
2058 .bind(workspace_id)
2059 .bind(task_id)
2060 .bind(depends_on_task_id)
2061 .fetch_one(&mut *conn)
2062 .await?
2063 > 0)
2064}
2065
2066async fn epic_edge_exists(
2067 conn: &mut SqliteConnection,
2068 workspace_id: &WorkspaceId,
2069 epic_id: &crate::ids::TaskId,
2070 child_id: &crate::ids::TaskId,
2071) -> Result<bool> {
2072 Ok(sqlx::query_scalar::<_, i64>(
2073 "SELECT count(*) FROM task_epic_links
2074 WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
2075 )
2076 .bind(workspace_id)
2077 .bind(epic_id)
2078 .bind(child_id)
2079 .fetch_one(&mut *conn)
2080 .await?
2081 > 0)
2082}
2083
2084async fn set_epic_edge_for_undo(
2085 conn: &mut SqliteConnection,
2086 workspace_id: &WorkspaceId,
2087 epic_id: &crate::ids::TaskId,
2088 child_id: &crate::ids::TaskId,
2089 linked: bool,
2090) -> Result<()> {
2091 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2092 let outcome = if linked {
2093 crate::operations::restore_task_to_epic_in_transaction(conn, &workspace, child_id, epic_id)
2094 .await?
2095 } else {
2096 crate::operations::remove_task_from_epic_in_transaction(conn, &workspace, child_id, epic_id)
2097 .await?
2098 };
2099 ensure!(
2100 outcome.changed,
2101 "error undo-state-changed task_id={child_id} field=epic"
2102 );
2103 Ok(())
2104}
2105
2106async fn add_dependency_for_undo(
2107 conn: &mut SqliteConnection,
2108 workspace_id: &WorkspaceId,
2109 task_id: &crate::ids::TaskId,
2110 depends_on_task_id: &crate::ids::TaskId,
2111) -> Result<()> {
2112 let created_at = now();
2113 sqlx::query(
2114 "INSERT OR IGNORE INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
2115 VALUES (?, ?, ?, ?)",
2116 )
2117 .bind(workspace_id)
2118 .bind(task_id)
2119 .bind(depends_on_task_id)
2120 .bind(&created_at)
2121 .execute(&mut *conn)
2122 .await?;
2123 append_dependency_change(
2124 conn,
2125 workspace_id,
2126 task_id,
2127 depends_on_task_id,
2128 crate::change_log::op_type::DEPENDENCY_ADD,
2129 )
2130 .await
2131}
2132
2133async fn remove_dependency_for_undo(
2134 conn: &mut SqliteConnection,
2135 workspace_id: &WorkspaceId,
2136 task_id: &crate::ids::TaskId,
2137 depends_on_task_id: &crate::ids::TaskId,
2138) -> Result<()> {
2139 sqlx::query(
2140 "DELETE FROM task_dependencies
2141 WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
2142 )
2143 .bind(workspace_id)
2144 .bind(task_id)
2145 .bind(depends_on_task_id)
2146 .execute(&mut *conn)
2147 .await?;
2148 append_dependency_change(
2149 conn,
2150 workspace_id,
2151 task_id,
2152 depends_on_task_id,
2153 crate::change_log::op_type::DEPENDENCY_REMOVE,
2154 )
2155 .await
2156}
2157
2158async fn append_dependency_change(
2159 conn: &mut SqliteConnection,
2160 workspace_id: &WorkspaceId,
2161 task_id: &crate::ids::TaskId,
2162 depends_on_task_id: &crate::ids::TaskId,
2163 op_type: &'static str,
2164) -> Result<()> {
2165 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2166 crate::change_log::append_change(
2167 conn,
2168 crate::change_log::ChangeEntity::Task,
2169 task_id,
2170 Some("dependencies"),
2171 op_type,
2172 crate::change_log::ChangePayload::workspace(&workspace)
2173 .set("depends_on_task_id", depends_on_task_id.to_string()),
2174 )
2175 .await?;
2176 Ok(())
2177}
2178
2179#[cfg(test)]
2180mod presentation_tests {
2181 use super::*;
2182
2183 fn set_field(task_id: &crate::ids::TaskId, field: &str) -> UndoCommand {
2184 UndoCommand::SetTaskField {
2185 task_id: task_id.clone(),
2186 field: field.to_string(),
2187 before: "before".to_string(),
2188 after: "after".to_string(),
2189 queue_activity_before: None,
2190 queue_activity_after: None,
2191 }
2192 }
2193
2194 #[test]
2195 fn classifier_derives_operations_and_unique_task_scope() {
2196 let first = crate::ids::TaskId::new();
2197 let second = crate::ids::TaskId::new();
2198 let cases = [
2199 (vec![set_field(&first, "priority")], "priority change", 1),
2200 (
2201 vec![
2202 set_field(&first, "status"),
2203 set_field(&first, "status"),
2204 set_field(&second, "status"),
2205 ],
2206 "status change",
2207 2,
2208 ),
2209 (
2210 vec![UndoCommand::SetTaskMetadata {
2211 task_id: first.clone(),
2212 field_id: crate::ids::MetadataFieldId::new(),
2213 before: None,
2214 after: Some("value".to_string()),
2215 }],
2216 "metadata change",
2217 1,
2218 ),
2219 ];
2220
2221 for (commands, operation, task_count) in cases {
2222 let presentation = classify_undo_commands("entry".to_string(), &commands);
2223 assert_eq!(presentation.operation, operation);
2224 assert_eq!(presentation.task_ids.len(), task_count);
2225 }
2226 }
2227
2228 #[test]
2229 fn classifier_describes_related_link_changes_on_the_initiating_task() {
2230 let task_id = crate::ids::TaskId::new();
2231 let related_task_id = crate::ids::TaskId::new();
2232
2233 for (linked, operation) in [
2234 (true, "related link addition"),
2235 (false, "related link removal"),
2236 ] {
2237 let presentation = classify_undo_commands(
2238 "entry".to_string(),
2239 &[UndoCommand::SetTaskRelatedLink {
2240 task_id: task_id.clone(),
2241 related_task_id: related_task_id.clone(),
2242 forward_change_id: "change".to_string(),
2243 linked,
2244 }],
2245 );
2246
2247 assert_eq!(presentation.operation, operation);
2248 assert_eq!(presentation.task_ids, vec![task_id.clone()]);
2249 }
2250 }
2251
2252 #[test]
2253 fn classifier_uses_primary_intent_for_mixed_payloads() {
2254 let task_id = crate::ids::TaskId::new();
2255 let project_id = ProjectId::new();
2256 let creation = UndoCommand::DeleteCreatedTask {
2257 task_id: task_id.clone(),
2258 create_change_id: None,
2259 expected: TaskUndoSnapshot {
2260 title: "title".to_string(),
2261 description: String::new(),
2262 project_id,
2263 project_key: "project".to_string(),
2264 status: "inbox".to_string(),
2265 priority: "none".to_string(),
2266 queue_activity_at: String::new(),
2267 available_at: String::new(),
2268 due_on: String::new(),
2269 deleted: false,
2270 is_epic: false,
2271 labels: vec!["new".to_string()],
2272 metadata: BTreeMap::new(),
2273 },
2274 attachment_ids: Vec::new(),
2275 attachment_change_ids: Vec::new(),
2276 };
2277 let cleanup = UndoCommand::DeleteCreatedLabel {
2278 label: "new".to_string(),
2279 create_change_id: "change".to_string(),
2280 };
2281 let presentation =
2282 classify_undo_commands("entry".to_string(), &[creation, cleanup.clone()]);
2283 assert_eq!(presentation.operation, "task creation");
2284
2285 let label_change = UndoCommand::SetTaskLabels {
2286 task_id,
2287 before: Vec::new(),
2288 after: vec!["new".to_string()],
2289 };
2290 let presentation = classify_undo_commands("entry".to_string(), &[label_change, cleanup]);
2291 assert_eq!(presentation.operation, "label change");
2292
2293 let epic_id = crate::ids::TaskId::new();
2294 let child_id = crate::ids::TaskId::new();
2295 let presentation = classify_undo_commands(
2296 "entry".to_string(),
2297 &[
2298 set_field(&epic_id, "is_epic"),
2299 UndoCommand::AddEpicChild { epic_id, child_id },
2300 ],
2301 );
2302 assert_eq!(presentation.operation, "epic membership addition");
2303 }
2304}