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(|| anyhow::anyhow!("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(|| anyhow::anyhow!("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 current = task_snapshot(conn, workspace_id, task_id).await?;
959 if current != *expected {
960 bail!("error undo-state-changed task_id={task_id} field=task");
961 }
962 let current_attachment_ids: Vec<String> = sqlx::query_scalar(
963 "SELECT attachment_id FROM task_attachments
964 WHERE workspace_id = ? AND task_id = ? AND deleted = 0
965 ORDER BY created_at, attachment_id",
966 )
967 .bind(workspace_id)
968 .bind(task_id)
969 .fetch_all(&mut *conn)
970 .await?;
971 if let Some(change_id) = create_change_id {
972 let labels_clear = expected.labels.is_empty()
973 || labels_match_create_change(conn, change_id, &expected.labels).await?;
974 let attachment_changes_clear =
975 all_changes_unsynced(conn, attachment_change_ids).await?;
976 let related_state_clear =
977 !crate::operations::task_has_related_state(conn, workspace_id, task_id).await?;
978 if change_is_unsynced(conn, change_id).await?
979 && labels_clear
980 && attachment_changes_clear
981 && related_state_clear
982 && !task_has_independent_creation_state(conn, workspace_id, task_id).await?
983 && current_attachment_ids == *attachment_ids
984 {
985 hard_delete_created_task(
986 conn,
987 workspace_id,
988 task_id,
989 change_id,
990 attachment_change_ids,
991 affected_attachment_hashes,
992 )
993 .await?;
994 return Ok(CommandOutcome {
995 task_id: Some(task_id.clone()),
996 include_deleted: None,
997 project_rename: None,
998 label_rename: None,
999 });
1000 }
1001 }
1002 collect_task_attachment_hashes(conn, workspace_id, task_id, affected_attachment_hashes)
1003 .await?;
1004 set_task_field_in_workspace(
1005 conn,
1006 workspace_id,
1007 task_id,
1008 TaskField::Deleted,
1009 "1",
1010 mutation_at,
1011 )
1012 .await?;
1013 Ok(CommandOutcome {
1014 task_id: Some(task_id.clone()),
1015 include_deleted: None,
1016 project_rename: None,
1017 label_rename: None,
1018 })
1019 }
1020 UndoCommand::SetNoteBody {
1021 task_id,
1022 note_id,
1023 before,
1024 after,
1025 } => {
1026 let current = sqlx::query_scalar::<_, String>(
1027 "SELECT body FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1028 )
1029 .bind(workspace_id)
1030 .bind(task_id)
1031 .bind(note_id)
1032 .fetch_optional(&mut *conn)
1033 .await?;
1034 if current.as_deref() != Some(after) {
1035 bail!("error undo-state-changed task_id={task_id} field=notes");
1036 }
1037 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1038 crate::operations::route_recurrence_task_mutation(
1039 conn,
1040 &workspace,
1041 task_id,
1042 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1043 mutation_at,
1044 )
1045 .await?;
1046 let edited_at = mutation_at;
1047 sqlx::query(
1048 "UPDATE notes SET body = ? WHERE workspace_id = ? AND task_id = ? AND id = ?",
1049 )
1050 .bind(before)
1051 .bind(workspace_id)
1052 .bind(task_id)
1053 .bind(note_id)
1054 .execute(&mut *conn)
1055 .await?;
1056 crate::change_log::append_change(
1057 conn,
1058 crate::change_log::ChangeEntity::Task,
1059 task_id,
1060 Some("notes"),
1061 crate::change_log::op_type::NOTE_EDIT,
1062 crate::change_log::ChangePayload::workspace(&workspace)
1063 .set("note_id", note_id)
1064 .set("body", before)
1065 .set("edited_at", edited_at),
1066 )
1067 .await?;
1068 sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1069 .bind(edited_at)
1070 .bind(workspace_id)
1071 .bind(task_id)
1072 .execute(&mut *conn)
1073 .await?;
1074 Ok(CommandOutcome {
1075 task_id: Some(task_id.clone()),
1076 include_deleted: None,
1077 project_rename: None,
1078 label_rename: None,
1079 })
1080 }
1081 UndoCommand::RestoreDeletedNote {
1082 task_id,
1083 note_id,
1084 body,
1085 created_at,
1086 note_add_change_id,
1087 } => {
1088 let exists: i64 = sqlx::query_scalar(
1089 "SELECT count(*) FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1090 )
1091 .bind(workspace_id)
1092 .bind(task_id)
1093 .bind(note_id)
1094 .fetch_one(&mut *conn)
1095 .await?;
1096 if exists != 0 {
1097 bail!("error undo-state-changed task_id={task_id} field=notes");
1098 }
1099 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1100 crate::operations::route_recurrence_task_mutation(
1101 conn,
1102 &workspace,
1103 task_id,
1104 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1105 mutation_at,
1106 )
1107 .await?;
1108 let change_id = crate::change_log::append_change(
1109 conn,
1110 crate::change_log::ChangeEntity::Task,
1111 task_id,
1112 Some("notes"),
1113 crate::change_log::op_type::NOTE_ADD,
1114 crate::change_log::ChangePayload::workspace(&workspace)
1115 .set("note_id", note_id)
1116 .set("body", body)
1117 .set("created_at", created_at),
1118 )
1119 .await?;
1120 sqlx::query(
1121 "INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id)
1122 VALUES (?, ?, ?, ?, ?, ?)",
1123 )
1124 .bind(workspace_id)
1125 .bind(note_id)
1126 .bind(task_id)
1127 .bind(body)
1128 .bind(created_at)
1129 .bind(&change_id)
1130 .execute(&mut *conn)
1131 .await?;
1132 extend_note_undo_lineage(
1133 conn,
1134 workspace_id,
1135 task_id,
1136 note_id,
1137 note_add_change_id,
1138 &change_id,
1139 )
1140 .await?;
1141 sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1142 .bind(now())
1143 .bind(workspace_id)
1144 .bind(task_id)
1145 .execute(&mut *conn)
1146 .await?;
1147 Ok(CommandOutcome {
1148 task_id: Some(task_id.clone()),
1149 include_deleted: None,
1150 project_rename: None,
1151 label_rename: None,
1152 })
1153 }
1154 UndoCommand::DeleteCreatedNote {
1155 task_id,
1156 note_id,
1157 note_add_change_id,
1158 restoration_change_ids,
1159 } => {
1160 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1161 crate::operations::route_recurrence_task_mutation(
1162 conn,
1163 &workspace,
1164 task_id,
1165 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1166 mutation_at,
1167 )
1168 .await?;
1169 delete_created_note(
1170 conn,
1171 workspace_id,
1172 task_id,
1173 note_id,
1174 note_add_change_id,
1175 restoration_change_ids,
1176 )
1177 .await?;
1178 Ok(CommandOutcome {
1179 task_id: Some(task_id.clone()),
1180 include_deleted: None,
1181 project_rename: None,
1182 label_rename: None,
1183 })
1184 }
1185 UndoCommand::DeleteCreatedProject {
1186 project_key,
1187 create_change_id,
1188 expected_name,
1189 expected_prefix,
1190 } => {
1191 delete_created_project(
1192 conn,
1193 workspace_id,
1194 project_key,
1195 create_change_id,
1196 expected_name,
1197 expected_prefix,
1198 )
1199 .await?;
1200 Ok(empty_command_outcome())
1201 }
1202 UndoCommand::SetProjectMetadata {
1203 project_id,
1204 before_key,
1205 before_name,
1206 before_prefix,
1207 after_key,
1208 after_name,
1209 after_prefix,
1210 } => {
1211 set_project_metadata_for_undo(
1212 conn,
1213 workspace_id,
1214 project_id,
1215 ProjectMetadata {
1216 key: before_key,
1217 name: before_name,
1218 prefix: before_prefix,
1219 },
1220 ProjectMetadata {
1221 key: after_key,
1222 name: after_name,
1223 prefix: after_prefix,
1224 },
1225 )
1226 .await?;
1227 Ok(CommandOutcome {
1228 task_id: None,
1229 include_deleted: None,
1230 project_rename: Some(ProjectRenameUndoOutcome {
1231 before_key: before_key.clone(),
1232 after_key: after_key.clone(),
1233 }),
1234 label_rename: None,
1235 })
1236 }
1237 UndoCommand::DeleteCreatedLabel {
1238 label,
1239 create_change_id,
1240 } => {
1241 delete_created_label(conn, workspace_id, label, create_change_id).await?;
1242 Ok(empty_command_outcome())
1243 }
1244 UndoCommand::SetLabelName { before, after } => {
1245 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1246 let after_exists = sqlx::query_scalar::<_, i64>(
1247 "SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?",
1248 )
1249 .bind(workspace_id)
1250 .bind(after)
1251 .fetch_one(&mut *conn)
1252 .await?
1253 == 1;
1254 let before_exists = sqlx::query_scalar::<_, i64>(
1255 "SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?",
1256 )
1257 .bind(workspace_id)
1258 .bind(before)
1259 .fetch_one(&mut *conn)
1260 .await?
1261 > 0;
1262 if !after_exists || before_exists {
1263 bail!("error undo-state-changed label={after}");
1264 }
1265 crate::operations::set_label_name(conn, &workspace, after, before).await?;
1266 Ok(CommandOutcome {
1267 task_id: None,
1268 include_deleted: None,
1269 project_rename: None,
1270 label_rename: Some(LabelRenameUndoOutcome {
1271 before: before.clone(),
1272 after: after.clone(),
1273 }),
1274 })
1275 }
1276 UndoCommand::RestoreDeletedLabel {
1277 name,
1278 created_at,
1279 task_ids,
1280 series_ids,
1281 } => {
1282 restore_deleted_label(conn, workspace_id, name, created_at, task_ids, series_ids)
1283 .await?;
1284 Ok(empty_command_outcome())
1285 }
1286 UndoCommand::RestoreConflictResolution {
1287 task_id,
1288 field,
1289 before,
1290 after,
1291 conflict_id,
1292 } => {
1293 let task_field = TaskField::parse_or_unknown(field)?;
1294 let current =
1295 task_field_value_for_field(conn, workspace_id, task_id, task_field).await?;
1296 if current != *after {
1297 bail!("error undo-state-changed task_id={task_id} field={field}");
1298 }
1299 if task_field == TaskField::Deleted && before != after {
1300 collect_task_attachment_hashes(
1301 conn,
1302 workspace_id,
1303 task_id,
1304 affected_attachment_hashes,
1305 )
1306 .await?;
1307 }
1308 set_task_field_in_workspace(
1309 conn,
1310 workspace_id,
1311 task_id,
1312 task_field,
1313 before,
1314 mutation_at,
1315 )
1316 .await?;
1317 let restored = sqlx::query(
1318 "UPDATE conflicts SET resolved = 0 WHERE id = ? AND workspace_id = ? AND resolved = 1",
1319 )
1320 .bind(conflict_id)
1321 .bind(workspace_id)
1322 .execute(&mut *conn)
1323 .await?;
1324 ensure!(
1325 restored.rows_affected() == 1,
1326 "error undo-state-changed task_id={task_id} field={field}"
1327 );
1328 Ok(CommandOutcome {
1329 task_id: Some(task_id.clone()),
1330 include_deleted: None,
1331 project_rename: None,
1332 label_rename: None,
1333 })
1334 }
1335 UndoCommand::AddTaskDependency {
1336 task_id,
1337 depends_on_task_id,
1338 } => {
1339 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1340 crate::operations::route_recurrence_task_mutation(
1341 conn,
1342 &workspace,
1343 task_id,
1344 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1345 mutation_at,
1346 )
1347 .await?;
1348 crate::operations::route_recurrence_task_mutation(
1349 conn,
1350 &workspace,
1351 depends_on_task_id,
1352 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1353 mutation_at,
1354 )
1355 .await?;
1356 ensure!(
1357 dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
1358 "error undo-state-changed task_id={task_id} field=dependency"
1359 );
1360 remove_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
1361 Ok(CommandOutcome {
1362 task_id: Some(task_id.clone()),
1363 include_deleted: None,
1364 project_rename: None,
1365 label_rename: None,
1366 })
1367 }
1368 UndoCommand::RemoveTaskDependency {
1369 task_id,
1370 depends_on_task_id,
1371 } => {
1372 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1373 crate::operations::route_recurrence_task_mutation(
1374 conn,
1375 &workspace,
1376 task_id,
1377 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1378 mutation_at,
1379 )
1380 .await?;
1381 crate::operations::route_recurrence_task_mutation(
1382 conn,
1383 &workspace,
1384 depends_on_task_id,
1385 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1386 mutation_at,
1387 )
1388 .await?;
1389 ensure!(
1390 !dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
1391 "error undo-state-changed task_id={task_id} field=dependency"
1392 );
1393 ensure!(
1394 !crate::operations::dependency_path_exists(
1395 conn,
1396 workspace_id,
1397 depends_on_task_id,
1398 task_id,
1399 )
1400 .await?,
1401 "error dependency-cycle task_id={task_id} depends_on_task_id={depends_on_task_id}"
1402 );
1403 add_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
1404 Ok(CommandOutcome {
1405 task_id: Some(task_id.clone()),
1406 include_deleted: None,
1407 project_rename: None,
1408 label_rename: None,
1409 })
1410 }
1411 UndoCommand::SetTaskRelatedLink {
1412 task_id,
1413 related_task_id,
1414 forward_change_id,
1415 linked,
1416 } => {
1417 let (task_a_id, task_b_id) =
1418 crate::operations::canonical_related_pair(task_id, related_task_id)?;
1419 let current_change_id = sqlx::query_scalar::<_, String>(
1420 "SELECT last_change_id FROM task_related_links
1421 WHERE workspace_id = ? AND task_a_id = ? AND task_b_id = ?",
1422 )
1423 .bind(workspace_id)
1424 .bind(task_a_id)
1425 .bind(task_b_id)
1426 .fetch_optional(&mut *conn)
1427 .await?;
1428 ensure!(
1429 current_change_id.as_deref() == Some(forward_change_id),
1430 "error undo-state-changed task_id={task_id} field=related"
1431 );
1432 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1433 crate::operations::set_task_related_link_in_transaction(
1434 conn,
1435 &workspace,
1436 task_id,
1437 related_task_id,
1438 !linked,
1439 )
1440 .await?;
1441 Ok(CommandOutcome {
1442 task_id: Some(task_id.clone()),
1443 include_deleted: None,
1444 project_rename: None,
1445 label_rename: None,
1446 })
1447 }
1448 UndoCommand::AddEpicChild { epic_id, child_id } => {
1449 ensure!(
1450 epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
1451 "error undo-state-changed task_id={child_id} field=epic"
1452 );
1453 set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, false).await?;
1454 Ok(CommandOutcome {
1455 task_id: Some(child_id.clone()),
1456 include_deleted: None,
1457 project_rename: None,
1458 label_rename: None,
1459 })
1460 }
1461 UndoCommand::RemoveEpicChild { epic_id, child_id } => {
1462 ensure!(
1463 !epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
1464 "error undo-state-changed task_id={child_id} field=epic"
1465 );
1466 set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, true).await?;
1467 Ok(CommandOutcome {
1468 task_id: Some(child_id.clone()),
1469 include_deleted: None,
1470 project_rename: None,
1471 label_rename: None,
1472 })
1473 }
1474 }
1475}
1476
1477async fn project_id_exists(
1478 conn: &mut SqliteConnection,
1479 workspace_id: &WorkspaceId,
1480 project_id: &ProjectId,
1481) -> Result<bool> {
1482 Ok(sqlx::query_scalar::<_, i64>(
1483 "SELECT count(*) FROM projects
1484 WHERE workspace_id = ? AND id = ? AND deleted = 0",
1485 )
1486 .bind(workspace_id)
1487 .bind(project_id)
1488 .fetch_one(&mut *conn)
1489 .await?
1490 > 0)
1491}
1492
1493async fn set_task_field_in_workspace(
1494 conn: &mut SqliteConnection,
1495 workspace_id: &WorkspaceId,
1496 task_id: &crate::ids::TaskId,
1497 task_field: TaskField,
1498 value: &str,
1499 mutation_at: &str,
1500) -> Result<()> {
1501 let field = task_field.as_str();
1502 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1503 match crate::operations::route_recurrence_task_mutation(
1504 conn,
1505 &workspace,
1506 task_id,
1507 RecurrenceTaskMutation::Scalar {
1508 field: task_field,
1509 value,
1510 },
1511 mutation_at,
1512 )
1513 .await?
1514 {
1515 crate::operations::RecurrenceMutationOutcome::Proceed => {}
1516 crate::operations::RecurrenceMutationOutcome::NoChange
1517 | crate::operations::RecurrenceMutationOutcome::Handled => return Ok(()),
1518 }
1519 if conflict_exists(conn, workspace_id, task_id, field).await? {
1520 bail!(
1521 "error conflicted-field ref={} field={} hint=\"use conflict resolve\"",
1522 task_id,
1523 field
1524 );
1525 }
1526 let base = field_version(conn, task_id, field).await?;
1527 let workspace_key = workspace_key_for_id(conn, workspace_id).await?;
1528 let payload = if task_field.is_project() {
1529 let project = resolve_project_for_stored_value(conn, workspace_id, value).await?;
1530 apply_project_id_in_workspace(conn, workspace_id, task_id, &project.id).await?;
1531 TaskField::project_payload(workspace_id, &workspace_key, &project)
1532 } else {
1533 apply_field_value_in_workspace(conn, workspace_id, task_id, field, value).await?;
1534 task_field.scalar_payload(workspace_id, &workspace_key, value)?
1535 };
1536 let change_id = insert_change(
1537 conn,
1538 "task",
1539 task_id,
1540 Some(field),
1541 "set_field",
1542 payload,
1543 base.as_deref(),
1544 )
1545 .await?;
1546 set_field_version(conn, task_id, field, &change_id).await?;
1547 Ok(())
1548}
1549
1550fn label_sets_equal(left: &[String], right: &[String]) -> bool {
1551 let left: BTreeSet<_> = left.iter().collect();
1552 let right: BTreeSet<_> = right.iter().collect();
1553 left == right
1554}
1555
1556fn label_delta(current: &[String], target: &[String]) -> (Vec<String>, Vec<String>) {
1557 let current_set: BTreeSet<_> = current.iter().collect();
1558 let target_set: BTreeSet<_> = target.iter().collect();
1559 let add = target
1560 .iter()
1561 .filter(|label| !current_set.contains(label))
1562 .cloned()
1563 .collect();
1564 let remove = current
1565 .iter()
1566 .filter(|label| !target_set.contains(label))
1567 .cloned()
1568 .collect();
1569 (add, remove)
1570}
1571
1572async fn change_is_unsynced(conn: &mut SqliteConnection, change_id: &str) -> Result<bool> {
1573 let server_seq =
1574 sqlx::query_scalar::<_, Option<i64>>("SELECT server_seq FROM changes WHERE change_id = ?")
1575 .bind(change_id)
1576 .fetch_optional(&mut *conn)
1577 .await?;
1578 Ok(matches!(server_seq, Some(None)))
1579}
1580
1581async fn all_changes_unsynced(conn: &mut SqliteConnection, change_ids: &[String]) -> Result<bool> {
1582 for change_id in change_ids {
1583 if !change_is_unsynced(conn, change_id).await? {
1584 return Ok(false);
1585 }
1586 }
1587 Ok(true)
1588}
1589
1590async fn labels_match_create_change(
1591 conn: &mut SqliteConnection,
1592 change_id: &str,
1593 labels: &[String],
1594) -> Result<bool> {
1595 let payload: String = sqlx::query_scalar("SELECT payload FROM changes WHERE change_id = ?")
1596 .bind(change_id)
1597 .fetch_one(&mut *conn)
1598 .await?;
1599 let payload: serde_json::Value = serde_json::from_str(&payload)?;
1600 let payload_labels = payload
1601 .get("labels")
1602 .and_then(|labels| labels.as_array())
1603 .map(|labels| {
1604 labels
1605 .iter()
1606 .filter_map(|label| label.as_str().map(str::to_string))
1607 .collect::<Vec<_>>()
1608 })
1609 .unwrap_or_default();
1610 Ok(label_sets_equal(labels, &payload_labels))
1611}
1612
1613async fn collect_task_attachment_hashes(
1614 conn: &mut SqliteConnection,
1615 workspace_id: &WorkspaceId,
1616 task_id: &crate::ids::TaskId,
1617 affected_attachment_hashes: &mut BTreeSet<String>,
1618) -> Result<()> {
1619 let hashes: Vec<String> = sqlx::query_scalar(
1620 "SELECT DISTINCT sha256 FROM task_attachments
1621 WHERE workspace_id = ? AND task_id = ?",
1622 )
1623 .bind(workspace_id)
1624 .bind(task_id)
1625 .fetch_all(&mut *conn)
1626 .await?;
1627 affected_attachment_hashes.extend(hashes);
1628 Ok(())
1629}
1630
1631async fn task_has_independent_creation_state(
1633 conn: &mut SqliteConnection,
1634 workspace_id: &WorkspaceId,
1635 task_id: &crate::ids::TaskId,
1636) -> Result<bool> {
1637 Ok(sqlx::query_scalar(
1638 "SELECT EXISTS(SELECT 1 FROM notes WHERE workspace_id = ?1 AND task_id = ?2)
1639 OR EXISTS(SELECT 1 FROM task_dependencies
1640 WHERE workspace_id = ?1 AND (task_id = ?2 OR depends_on_task_id = ?2))
1641 OR EXISTS(SELECT 1 FROM task_epic_links
1642 WHERE workspace_id = ?1 AND (epic_task_id = ?2 OR child_task_id = ?2))",
1643 )
1644 .bind(workspace_id)
1645 .bind(task_id)
1646 .fetch_one(&mut *conn)
1647 .await?)
1648}
1649
1650async fn hard_delete_created_task(
1651 conn: &mut SqliteConnection,
1652 workspace_id: &WorkspaceId,
1653 task_id: &crate::ids::TaskId,
1654 create_change_id: &str,
1655 attachment_change_ids: &[String],
1656 affected_attachment_hashes: &mut BTreeSet<String>,
1657) -> Result<()> {
1658 collect_task_attachment_hashes(conn, workspace_id, task_id, affected_attachment_hashes).await?;
1659 sqlx::query("DELETE FROM task_attachments WHERE workspace_id = ? AND task_id = ?")
1660 .bind(workspace_id)
1661 .bind(task_id)
1662 .execute(&mut *conn)
1663 .await?;
1664 sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ?")
1665 .bind(workspace_id)
1666 .bind(task_id)
1667 .execute(&mut *conn)
1668 .await?;
1669 sqlx::query("DELETE FROM task_metadata WHERE workspace_id = ? AND task_id = ?")
1670 .bind(workspace_id)
1671 .bind(task_id)
1672 .execute(&mut *conn)
1673 .await?;
1674 sqlx::query("DELETE FROM field_versions WHERE entity_id = ?")
1675 .bind(task_id)
1676 .execute(&mut *conn)
1677 .await?;
1678 sqlx::query("DELETE FROM tasks WHERE workspace_id = ? AND id = ?")
1679 .bind(workspace_id)
1680 .bind(task_id)
1681 .execute(&mut *conn)
1682 .await?;
1683 for change_id in attachment_change_ids {
1684 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1685 .bind(change_id)
1686 .execute(&mut *conn)
1687 .await?;
1688 }
1689 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1690 .bind(create_change_id)
1691 .execute(&mut *conn)
1692 .await?;
1693 Ok(())
1694}
1695
1696async fn extend_note_undo_lineage(
1699 conn: &mut SqliteConnection,
1700 workspace_id: &WorkspaceId,
1701 task_id: &crate::ids::TaskId,
1702 note_id: &str,
1703 previous_change_id: &str,
1704 restored_change_id: &str,
1705) -> Result<()> {
1706 let rows = sqlx::query(
1707 "SELECT id, payload FROM tui_undo_entries WHERE workspace_id = ? AND undone_at IS NULL",
1708 )
1709 .bind(workspace_id)
1710 .fetch_all(&mut *conn)
1711 .await?;
1712 for row in rows {
1713 let mut payload: UndoPayload = serde_json::from_str(row.get("payload"))?;
1714 let mut changed = false;
1715 for command in &mut payload.commands {
1716 if let UndoCommand::DeleteCreatedNote {
1717 task_id: expected_task,
1718 note_id: expected_note,
1719 note_add_change_id,
1720 restoration_change_ids,
1721 } = command
1722 {
1723 let expected = restoration_change_ids.last().unwrap_or(note_add_change_id);
1724 if expected_task == task_id
1725 && expected_note == note_id
1726 && expected == previous_change_id
1727 {
1728 restoration_change_ids.push(restored_change_id.to_string());
1729 changed = true;
1730 }
1731 }
1732 }
1733 if changed {
1734 sqlx::query("UPDATE tui_undo_entries SET payload = ? WHERE id = ?")
1735 .bind(serde_json::to_string(&payload)?)
1736 .bind(row.get::<String, _>("id"))
1737 .execute(&mut *conn)
1738 .await?;
1739 }
1740 }
1741 Ok(())
1742}
1743
1744async fn delete_created_note(
1745 conn: &mut SqliteConnection,
1746 workspace_id: &WorkspaceId,
1747 task_id: &crate::ids::TaskId,
1748 note_id: &str,
1749 note_add_change_id: &str,
1750 restoration_change_ids: &[String],
1751) -> Result<()> {
1752 let row = sqlx::query(
1753 "SELECT change_id FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?",
1754 )
1755 .bind(workspace_id)
1756 .bind(note_id)
1757 .bind(task_id)
1758 .fetch_optional(&mut *conn)
1759 .await?;
1760 let Some(row) = row else {
1761 bail!("error undo-state-changed task_id={task_id} field=note");
1762 };
1763 let stored_change_id: String = row.get("change_id");
1764 let expected_change_id = restoration_change_ids
1765 .last()
1766 .map(String::as_str)
1767 .unwrap_or(note_add_change_id);
1768 if stored_change_id != expected_change_id {
1769 bail!("error undo-state-changed task_id={task_id} field=note");
1770 }
1771 for change_id in
1772 std::iter::once(note_add_change_id).chain(restoration_change_ids.iter().map(String::as_str))
1773 {
1774 if !change_is_unsynced(conn, change_id).await? {
1775 bail!("error undo-state-changed task_id={task_id} field=note");
1776 }
1777 }
1778 sqlx::query("DELETE FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?")
1779 .bind(workspace_id)
1780 .bind(note_id)
1781 .bind(task_id)
1782 .execute(&mut *conn)
1783 .await?;
1784 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1785 .bind(expected_change_id)
1786 .execute(&mut *conn)
1787 .await?;
1788 Ok(())
1789}
1790
1791async fn delete_created_project(
1792 conn: &mut SqliteConnection,
1793 workspace_id: &WorkspaceId,
1794 project_key: &str,
1795 create_change_id: &str,
1796 expected_name: &str,
1797 expected_prefix: &str,
1798) -> Result<()> {
1799 let row = sqlx::query(
1800 "SELECT id, name, prefix FROM projects WHERE workspace_id = ? AND key = ? AND deleted = 0",
1801 )
1802 .bind(workspace_id)
1803 .bind(project_key)
1804 .fetch_optional(&mut *conn)
1805 .await?;
1806 let Some(row) = row else {
1807 bail!("error undo-state-changed project_key={project_key}");
1808 };
1809 let project_id: ProjectId = row.get("id");
1810 let name: String = row.get("name");
1811 let prefix: String = row.get("prefix");
1812 if name != expected_name || prefix != expected_prefix {
1813 bail!("error undo-state-changed project_key={project_key}");
1814 }
1815 if !change_is_unsynced(conn, create_change_id).await? {
1816 bail!("error undo-state-changed project_key={project_key}");
1817 }
1818 let task_refs: i64 =
1819 sqlx::query_scalar("SELECT count(*) FROM tasks WHERE workspace_id = ? AND project_id = ?")
1820 .bind(workspace_id)
1821 .bind(&project_id)
1822 .fetch_one(&mut *conn)
1823 .await?;
1824 if task_refs > 0 {
1825 bail!("error undo-state-changed project_key={project_key}");
1826 }
1827 let path_refs: i64 = sqlx::query_scalar(
1828 "SELECT count(*) FROM project_paths WHERE workspace_id = ? AND project_id = ?",
1829 )
1830 .bind(workspace_id)
1831 .bind(&project_id)
1832 .fetch_one(&mut *conn)
1833 .await?;
1834 if path_refs > 0 {
1835 bail!("error undo-state-changed project_key={project_key}");
1836 }
1837 sqlx::query("DELETE FROM projects WHERE workspace_id = ? AND key = ?")
1838 .bind(workspace_id)
1839 .bind(project_key)
1840 .execute(&mut *conn)
1841 .await?;
1842 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1843 .bind(create_change_id)
1844 .execute(&mut *conn)
1845 .await?;
1846 Ok(())
1847}
1848
1849async fn set_project_metadata_for_undo(
1850 conn: &mut SqliteConnection,
1851 workspace_id: &WorkspaceId,
1852 project_id: &ProjectId,
1853 before: ProjectMetadata<'_>,
1854 after: ProjectMetadata<'_>,
1855) -> Result<()> {
1856 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1857 let row = sqlx::query(
1858 "SELECT key, name, prefix
1859 FROM projects
1860 WHERE workspace_id = ? AND id = ? AND deleted = 0",
1861 )
1862 .bind(workspace_id)
1863 .bind(project_id)
1864 .fetch_optional(&mut *conn)
1865 .await?;
1866 let Some(row) = row else {
1867 bail!("error undo-state-changed project_id={project_id}");
1868 };
1869 let key: String = row.get("key");
1870 let name: String = row.get("name");
1871 let prefix: String = row.get("prefix");
1872 if key != after.key || name != after.name || prefix != after.prefix {
1873 bail!("error undo-state-changed project_id={project_id}");
1874 }
1875 let key_refs: i64 = sqlx::query_scalar(
1876 "SELECT count(*) FROM projects
1877 WHERE workspace_id = ? AND key = ? AND id != ? AND deleted = 0",
1878 )
1879 .bind(workspace_id)
1880 .bind(before.key)
1881 .bind(project_id)
1882 .fetch_one(&mut *conn)
1883 .await?;
1884 if key_refs > 0 {
1885 bail!("error undo-state-changed project_id={project_id}");
1886 }
1887 let prefix_refs: i64 = sqlx::query_scalar(
1888 "SELECT count(*) FROM projects
1889 WHERE workspace_id = ? AND prefix = ? AND id != ? AND deleted = 0",
1890 )
1891 .bind(workspace_id)
1892 .bind(before.prefix)
1893 .bind(project_id)
1894 .fetch_one(&mut *conn)
1895 .await?;
1896 if prefix_refs > 0 {
1897 bail!("error undo-state-changed project_id={project_id}");
1898 }
1899 set_project_metadata(conn, &workspace, project_id, before, false).await?;
1900 insert_project_metadata_change(conn, &workspace, project_id, before, &now()).await?;
1901 Ok(())
1902}
1903
1904async fn delete_created_label(
1905 conn: &mut SqliteConnection,
1906 workspace_id: &WorkspaceId,
1907 label: &str,
1908 create_change_id: &str,
1909) -> Result<()> {
1910 let exists: i64 =
1911 sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
1912 .bind(workspace_id)
1913 .bind(label)
1914 .fetch_one(&mut *conn)
1915 .await?;
1916 if exists == 0 || !change_is_unsynced(conn, create_change_id).await? {
1917 bail!("error undo-state-changed label={label}");
1918 }
1919 let task_refs: i64 =
1920 sqlx::query_scalar("SELECT count(*) FROM task_labels WHERE workspace_id = ? AND label = ?")
1921 .bind(workspace_id)
1922 .bind(label)
1923 .fetch_one(&mut *conn)
1924 .await?;
1925 let series_refs: i64 = sqlx::query_scalar(
1926 "SELECT count(*) FROM recurrence_series_labels WHERE workspace_id = ? AND label = ?",
1927 )
1928 .bind(workspace_id)
1929 .bind(label)
1930 .fetch_one(&mut *conn)
1931 .await?;
1932 if task_refs > 0 || series_refs > 0 {
1933 bail!("error undo-state-changed label={label}");
1934 }
1935 sqlx::query("DELETE FROM labels WHERE workspace_id = ? AND name = ?")
1936 .bind(workspace_id)
1937 .bind(label)
1938 .execute(&mut *conn)
1939 .await?;
1940 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1941 .bind(create_change_id)
1942 .execute(&mut *conn)
1943 .await?;
1944 Ok(())
1945}
1946
1947async fn restore_deleted_label(
1948 conn: &mut SqliteConnection,
1949 workspace_id: &WorkspaceId,
1950 name: &str,
1951 created_at: &str,
1952 task_ids: &[crate::ids::TaskId],
1953 series_ids: &[crate::recurrence::RecurrenceSeriesId],
1954) -> Result<()> {
1955 let exists: i64 =
1956 sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
1957 .bind(workspace_id)
1958 .bind(name)
1959 .fetch_one(&mut *conn)
1960 .await?;
1961 if exists > 0 {
1962 bail!("error undo-state-changed label={name}");
1963 }
1964 sqlx::query("INSERT INTO labels(workspace_id, name, created_at) VALUES (?, ?, ?)")
1965 .bind(workspace_id)
1966 .bind(name)
1967 .bind(created_at)
1968 .execute(&mut *conn)
1969 .await?;
1970 for task_id in task_ids {
1971 let inserted = sqlx::query(
1972 "INSERT INTO task_labels(workspace_id, task_id, label)
1973 SELECT ?, id, ? FROM tasks WHERE workspace_id = ? AND id = ?",
1974 )
1975 .bind(workspace_id)
1976 .bind(name)
1977 .bind(workspace_id)
1978 .bind(task_id)
1979 .execute(&mut *conn)
1980 .await?;
1981 if inserted.rows_affected() != 1 {
1982 bail!("error undo-state-changed label={name} task_id={task_id}");
1983 }
1984 }
1985 for series_id in series_ids {
1986 let inserted = sqlx::query(
1987 "INSERT INTO recurrence_series_labels(workspace_id, series_id, label)
1988 SELECT ?, id, ? FROM recurrence_series WHERE workspace_id = ? AND id = ?",
1989 )
1990 .bind(workspace_id)
1991 .bind(name)
1992 .bind(workspace_id)
1993 .bind(series_id)
1994 .execute(&mut *conn)
1995 .await?;
1996 if inserted.rows_affected() != 1 {
1997 bail!("error undo-state-changed label={name} series_id={series_id}");
1998 }
1999 }
2000 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2001 append_change(
2002 conn,
2003 ChangeEntity::Label,
2004 name,
2005 None,
2006 op_type::LABEL_RESTORE,
2007 ChangePayload::workspace(&workspace)
2008 .set("name", name)
2009 .set("created_at", created_at)
2010 .set("task_ids", task_ids)
2011 .set("series_ids", series_ids)
2012 .set("restored_at", now()),
2013 )
2014 .await?;
2015 Ok(())
2016}
2017
2018async fn dependency_task_exists(
2019 conn: &mut SqliteConnection,
2020 workspace_id: &WorkspaceId,
2021 task_id: &crate::ids::TaskId,
2022) -> Result<bool> {
2023 Ok(
2024 sqlx::query_scalar::<_, i64>(
2025 "SELECT count(*) FROM tasks WHERE workspace_id = ? AND id = ?",
2026 )
2027 .bind(workspace_id)
2028 .bind(task_id)
2029 .fetch_one(&mut *conn)
2030 .await?
2031 > 0,
2032 )
2033}
2034
2035async fn dependency_edge_exists(
2036 conn: &mut SqliteConnection,
2037 workspace_id: &WorkspaceId,
2038 task_id: &crate::ids::TaskId,
2039 depends_on_task_id: &crate::ids::TaskId,
2040) -> Result<bool> {
2041 ensure!(
2042 dependency_task_exists(conn, workspace_id, task_id).await?
2043 && dependency_task_exists(conn, workspace_id, depends_on_task_id).await?,
2044 "error undo-state-changed task_id={task_id} field=dependency"
2045 );
2046 Ok(sqlx::query_scalar::<_, i64>(
2047 "SELECT count(*) FROM task_dependencies
2048 WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
2049 )
2050 .bind(workspace_id)
2051 .bind(task_id)
2052 .bind(depends_on_task_id)
2053 .fetch_one(&mut *conn)
2054 .await?
2055 > 0)
2056}
2057
2058async fn epic_edge_exists(
2059 conn: &mut SqliteConnection,
2060 workspace_id: &WorkspaceId,
2061 epic_id: &crate::ids::TaskId,
2062 child_id: &crate::ids::TaskId,
2063) -> Result<bool> {
2064 Ok(sqlx::query_scalar::<_, i64>(
2065 "SELECT count(*) FROM task_epic_links
2066 WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
2067 )
2068 .bind(workspace_id)
2069 .bind(epic_id)
2070 .bind(child_id)
2071 .fetch_one(&mut *conn)
2072 .await?
2073 > 0)
2074}
2075
2076async fn set_epic_edge_for_undo(
2077 conn: &mut SqliteConnection,
2078 workspace_id: &WorkspaceId,
2079 epic_id: &crate::ids::TaskId,
2080 child_id: &crate::ids::TaskId,
2081 linked: bool,
2082) -> Result<()> {
2083 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2084 let outcome = if linked {
2085 crate::operations::restore_task_to_epic_in_transaction(conn, &workspace, child_id, epic_id)
2086 .await?
2087 } else {
2088 crate::operations::remove_task_from_epic_in_transaction(conn, &workspace, child_id, epic_id)
2089 .await?
2090 };
2091 ensure!(
2092 outcome.changed,
2093 "error undo-state-changed task_id={child_id} field=epic"
2094 );
2095 Ok(())
2096}
2097
2098async fn add_dependency_for_undo(
2099 conn: &mut SqliteConnection,
2100 workspace_id: &WorkspaceId,
2101 task_id: &crate::ids::TaskId,
2102 depends_on_task_id: &crate::ids::TaskId,
2103) -> Result<()> {
2104 let created_at = now();
2105 sqlx::query(
2106 "INSERT OR IGNORE INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
2107 VALUES (?, ?, ?, ?)",
2108 )
2109 .bind(workspace_id)
2110 .bind(task_id)
2111 .bind(depends_on_task_id)
2112 .bind(&created_at)
2113 .execute(&mut *conn)
2114 .await?;
2115 append_dependency_change(
2116 conn,
2117 workspace_id,
2118 task_id,
2119 depends_on_task_id,
2120 crate::change_log::op_type::DEPENDENCY_ADD,
2121 )
2122 .await
2123}
2124
2125async fn remove_dependency_for_undo(
2126 conn: &mut SqliteConnection,
2127 workspace_id: &WorkspaceId,
2128 task_id: &crate::ids::TaskId,
2129 depends_on_task_id: &crate::ids::TaskId,
2130) -> Result<()> {
2131 sqlx::query(
2132 "DELETE FROM task_dependencies
2133 WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
2134 )
2135 .bind(workspace_id)
2136 .bind(task_id)
2137 .bind(depends_on_task_id)
2138 .execute(&mut *conn)
2139 .await?;
2140 append_dependency_change(
2141 conn,
2142 workspace_id,
2143 task_id,
2144 depends_on_task_id,
2145 crate::change_log::op_type::DEPENDENCY_REMOVE,
2146 )
2147 .await
2148}
2149
2150async fn append_dependency_change(
2151 conn: &mut SqliteConnection,
2152 workspace_id: &WorkspaceId,
2153 task_id: &crate::ids::TaskId,
2154 depends_on_task_id: &crate::ids::TaskId,
2155 op_type: &'static str,
2156) -> Result<()> {
2157 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2158 crate::change_log::append_change(
2159 conn,
2160 crate::change_log::ChangeEntity::Task,
2161 task_id,
2162 Some("dependencies"),
2163 op_type,
2164 crate::change_log::ChangePayload::workspace(&workspace)
2165 .set("depends_on_task_id", depends_on_task_id.to_string()),
2166 )
2167 .await?;
2168 Ok(())
2169}
2170
2171#[cfg(test)]
2172mod presentation_tests {
2173 use super::*;
2174
2175 fn set_field(task_id: &crate::ids::TaskId, field: &str) -> UndoCommand {
2176 UndoCommand::SetTaskField {
2177 task_id: task_id.clone(),
2178 field: field.to_string(),
2179 before: "before".to_string(),
2180 after: "after".to_string(),
2181 queue_activity_before: None,
2182 queue_activity_after: None,
2183 }
2184 }
2185
2186 #[test]
2187 fn classifier_derives_operations_and_unique_task_scope() {
2188 let first = crate::ids::TaskId::new();
2189 let second = crate::ids::TaskId::new();
2190 let cases = [
2191 (vec![set_field(&first, "priority")], "priority change", 1),
2192 (
2193 vec![
2194 set_field(&first, "status"),
2195 set_field(&first, "status"),
2196 set_field(&second, "status"),
2197 ],
2198 "status change",
2199 2,
2200 ),
2201 (
2202 vec![UndoCommand::SetTaskMetadata {
2203 task_id: first.clone(),
2204 field_id: crate::ids::MetadataFieldId::new(),
2205 before: None,
2206 after: Some("value".to_string()),
2207 }],
2208 "metadata change",
2209 1,
2210 ),
2211 ];
2212
2213 for (commands, operation, task_count) in cases {
2214 let presentation = classify_undo_commands("entry".to_string(), &commands);
2215 assert_eq!(presentation.operation, operation);
2216 assert_eq!(presentation.task_ids.len(), task_count);
2217 }
2218 }
2219
2220 #[test]
2221 fn classifier_describes_related_link_changes_on_the_initiating_task() {
2222 let task_id = crate::ids::TaskId::new();
2223 let related_task_id = crate::ids::TaskId::new();
2224
2225 for (linked, operation) in [
2226 (true, "related link addition"),
2227 (false, "related link removal"),
2228 ] {
2229 let presentation = classify_undo_commands(
2230 "entry".to_string(),
2231 &[UndoCommand::SetTaskRelatedLink {
2232 task_id: task_id.clone(),
2233 related_task_id: related_task_id.clone(),
2234 forward_change_id: "change".to_string(),
2235 linked,
2236 }],
2237 );
2238
2239 assert_eq!(presentation.operation, operation);
2240 assert_eq!(presentation.task_ids, vec![task_id.clone()]);
2241 }
2242 }
2243
2244 #[test]
2245 fn classifier_uses_primary_intent_for_mixed_payloads() {
2246 let task_id = crate::ids::TaskId::new();
2247 let project_id = ProjectId::new();
2248 let creation = UndoCommand::DeleteCreatedTask {
2249 task_id: task_id.clone(),
2250 create_change_id: None,
2251 expected: TaskUndoSnapshot {
2252 title: "title".to_string(),
2253 description: String::new(),
2254 project_id,
2255 project_key: "project".to_string(),
2256 status: "inbox".to_string(),
2257 priority: "none".to_string(),
2258 queue_activity_at: String::new(),
2259 available_at: String::new(),
2260 due_on: String::new(),
2261 deleted: false,
2262 is_epic: false,
2263 labels: vec!["new".to_string()],
2264 metadata: BTreeMap::new(),
2265 },
2266 attachment_ids: Vec::new(),
2267 attachment_change_ids: Vec::new(),
2268 };
2269 let cleanup = UndoCommand::DeleteCreatedLabel {
2270 label: "new".to_string(),
2271 create_change_id: "change".to_string(),
2272 };
2273 let presentation =
2274 classify_undo_commands("entry".to_string(), &[creation, cleanup.clone()]);
2275 assert_eq!(presentation.operation, "task creation");
2276
2277 let label_change = UndoCommand::SetTaskLabels {
2278 task_id,
2279 before: Vec::new(),
2280 after: vec!["new".to_string()],
2281 };
2282 let presentation = classify_undo_commands("entry".to_string(), &[label_change, cleanup]);
2283 assert_eq!(presentation.operation, "label change");
2284
2285 let epic_id = crate::ids::TaskId::new();
2286 let child_id = crate::ids::TaskId::new();
2287 let presentation = classify_undo_commands(
2288 "entry".to_string(),
2289 &[
2290 set_field(&epic_id, "is_epic"),
2291 UndoCommand::AddEpicChild { epic_id, child_id },
2292 ],
2293 );
2294 assert_eq!(presentation.operation, "epic membership addition");
2295 }
2296}