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