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