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 removable = sqlx::query_scalar::<_, bool>(
1611 "SELECT server_seq IS NULL
1612 AND NOT EXISTS (SELECT 1 FROM local_e2ee_outbox WHERE operation_id = changes.change_id)
1613 AND NOT EXISTS (SELECT 1 FROM local_e2ee_accepted WHERE operation_id = changes.change_id)
1614 FROM changes WHERE change_id = ?",
1615 )
1616 .bind(change_id)
1617 .fetch_optional(&mut *conn)
1618 .await?;
1619 Ok(removable.unwrap_or(false))
1620}
1621
1622async fn all_changes_unsynced(conn: &mut SqliteConnection, change_ids: &[String]) -> Result<bool> {
1623 for change_id in change_ids {
1624 if !change_is_unsynced(conn, change_id).await? {
1625 return Ok(false);
1626 }
1627 }
1628 Ok(true)
1629}
1630
1631async fn labels_match_create_change(
1632 conn: &mut SqliteConnection,
1633 change_id: &str,
1634 labels: &[String],
1635) -> Result<bool> {
1636 let payload: String = sqlx::query_scalar("SELECT payload FROM changes WHERE change_id = ?")
1637 .bind(change_id)
1638 .fetch_one(&mut *conn)
1639 .await?;
1640 let payload: serde_json::Value = serde_json::from_str(&payload)?;
1641 let payload_labels = payload
1642 .get("labels")
1643 .and_then(|labels| labels.as_array())
1644 .map(|labels| {
1645 labels
1646 .iter()
1647 .filter_map(|label| label.as_str().map(str::to_string))
1648 .collect::<Vec<_>>()
1649 })
1650 .unwrap_or_default();
1651 Ok(label_sets_equal(labels, &payload_labels))
1652}
1653
1654async fn collect_task_attachment_hashes(
1655 conn: &mut SqliteConnection,
1656 workspace_id: &WorkspaceId,
1657 task_id: &crate::ids::TaskId,
1658 affected_attachment_hashes: &mut BTreeSet<String>,
1659) -> Result<()> {
1660 let hashes: Vec<String> = sqlx::query_scalar(
1661 "SELECT DISTINCT sha256 FROM task_attachments
1662 WHERE workspace_id = ? AND task_id = ?",
1663 )
1664 .bind(workspace_id)
1665 .bind(task_id)
1666 .fetch_all(&mut *conn)
1667 .await?;
1668 affected_attachment_hashes.extend(hashes);
1669 Ok(())
1670}
1671
1672async fn task_has_independent_creation_state(
1674 conn: &mut SqliteConnection,
1675 workspace_id: &WorkspaceId,
1676 task_id: &crate::ids::TaskId,
1677) -> Result<bool> {
1678 Ok(sqlx::query_scalar(
1679 "SELECT EXISTS(SELECT 1 FROM notes WHERE workspace_id = ?1 AND task_id = ?2)
1680 OR EXISTS(SELECT 1 FROM task_dependencies
1681 WHERE workspace_id = ?1 AND (task_id = ?2 OR depends_on_task_id = ?2))
1682 OR EXISTS(SELECT 1 FROM task_epic_links
1683 WHERE workspace_id = ?1 AND (epic_task_id = ?2 OR child_task_id = ?2))",
1684 )
1685 .bind(workspace_id)
1686 .bind(task_id)
1687 .fetch_one(&mut *conn)
1688 .await?)
1689}
1690
1691async fn hard_delete_created_task(
1692 conn: &mut SqliteConnection,
1693 workspace_id: &WorkspaceId,
1694 task_id: &crate::ids::TaskId,
1695 create_change_id: &str,
1696 attachment_change_ids: &[String],
1697 affected_attachment_hashes: &mut BTreeSet<String>,
1698) -> Result<()> {
1699 let mut protected_ids = Vec::with_capacity(attachment_change_ids.len() + 1);
1700 protected_ids.push(create_change_id);
1701 protected_ids.extend(attachment_change_ids.iter().map(String::as_str));
1702 crate::sync::shared_state::ensure_changes_not_local_capture_protected(conn, &protected_ids)
1703 .await?;
1704 collect_task_attachment_hashes(conn, workspace_id, task_id, affected_attachment_hashes).await?;
1705 sqlx::query("DELETE FROM task_attachments 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_labels 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 task_metadata WHERE workspace_id = ? AND task_id = ?")
1716 .bind(workspace_id)
1717 .bind(task_id)
1718 .execute(&mut *conn)
1719 .await?;
1720 sqlx::query("DELETE FROM field_versions WHERE entity_id = ?")
1721 .bind(task_id)
1722 .execute(&mut *conn)
1723 .await?;
1724 sqlx::query("DELETE FROM tasks WHERE workspace_id = ? AND id = ?")
1725 .bind(workspace_id)
1726 .bind(task_id)
1727 .execute(&mut *conn)
1728 .await?;
1729 for change_id in attachment_change_ids {
1730 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1731 .bind(change_id)
1732 .execute(&mut *conn)
1733 .await?;
1734 }
1735 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1736 .bind(create_change_id)
1737 .execute(&mut *conn)
1738 .await?;
1739 Ok(())
1740}
1741
1742async fn extend_note_undo_lineage(
1745 conn: &mut SqliteConnection,
1746 workspace_id: &WorkspaceId,
1747 task_id: &crate::ids::TaskId,
1748 note_id: &str,
1749 previous_change_id: &str,
1750 restored_change_id: &str,
1751) -> Result<()> {
1752 let rows = sqlx::query(
1753 "SELECT id, payload FROM tui_undo_entries WHERE workspace_id = ? AND undone_at IS NULL",
1754 )
1755 .bind(workspace_id)
1756 .fetch_all(&mut *conn)
1757 .await?;
1758 for row in rows {
1759 let mut payload: UndoPayload = serde_json::from_str(row.get("payload"))?;
1760 let mut changed = false;
1761 for command in &mut payload.commands {
1762 if let UndoCommand::DeleteCreatedNote {
1763 task_id: expected_task,
1764 note_id: expected_note,
1765 note_add_change_id,
1766 restoration_change_ids,
1767 } = command
1768 {
1769 let expected = restoration_change_ids.last().unwrap_or(note_add_change_id);
1770 if expected_task == task_id
1771 && expected_note == note_id
1772 && expected == previous_change_id
1773 {
1774 restoration_change_ids.push(restored_change_id.to_string());
1775 changed = true;
1776 }
1777 }
1778 }
1779 if changed {
1780 sqlx::query("UPDATE tui_undo_entries SET payload = ? WHERE id = ?")
1781 .bind(serde_json::to_string(&payload)?)
1782 .bind(row.get::<String, _>("id"))
1783 .execute(&mut *conn)
1784 .await?;
1785 }
1786 }
1787 Ok(())
1788}
1789
1790async fn delete_created_note(
1791 conn: &mut SqliteConnection,
1792 workspace_id: &WorkspaceId,
1793 task_id: &crate::ids::TaskId,
1794 note_id: &str,
1795 note_add_change_id: &str,
1796 restoration_change_ids: &[String],
1797) -> Result<()> {
1798 let row = sqlx::query(
1799 "SELECT change_id, body FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?",
1800 )
1801 .bind(workspace_id)
1802 .bind(note_id)
1803 .bind(task_id)
1804 .fetch_optional(&mut *conn)
1805 .await?;
1806 let Some(row) = row else {
1807 bail!("error undo-state-changed task_id={task_id} field=note");
1808 };
1809 let stored_change_id: String = row.get("change_id");
1810 let expected_change_id = restoration_change_ids
1811 .last()
1812 .map(String::as_str)
1813 .unwrap_or(note_add_change_id);
1814 if stored_change_id != expected_change_id {
1815 bail!("error undo-state-changed task_id={task_id} field=note");
1816 }
1817 let lineage = std::iter::once(note_add_change_id)
1818 .chain(restoration_change_ids.iter().map(String::as_str))
1819 .collect::<Vec<_>>();
1820 let mut removable = true;
1821 for change_id in &lineage {
1822 removable &= change_is_unsynced(conn, change_id).await?;
1823 }
1824 if removable {
1825 crate::sync::shared_state::ensure_changes_not_local_capture_protected(conn, &lineage)
1826 .await?;
1827 }
1828 sqlx::query("DELETE FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?")
1829 .bind(workspace_id)
1830 .bind(note_id)
1831 .bind(task_id)
1832 .execute(&mut *conn)
1833 .await?;
1834 if removable {
1835 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1836 .bind(expected_change_id)
1837 .execute(&mut *conn)
1838 .await?;
1839 } else {
1840 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1841 let deleted_at = now();
1842 append_change(
1843 conn,
1844 ChangeEntity::Task,
1845 task_id,
1846 Some("notes"),
1847 op_type::NOTE_DELETE,
1848 ChangePayload::workspace(&workspace)
1849 .set("note_id", note_id)
1850 .set("body", row.get::<String, _>("body"))
1851 .set("deleted_at", &deleted_at),
1852 )
1853 .await?;
1854 sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1855 .bind(&deleted_at)
1856 .bind(workspace_id)
1857 .bind(task_id)
1858 .execute(&mut *conn)
1859 .await?;
1860 }
1861 Ok(())
1862}
1863
1864async fn delete_created_project(
1865 conn: &mut SqliteConnection,
1866 workspace_id: &WorkspaceId,
1867 project_key: &str,
1868 create_change_id: &str,
1869 expected_name: &str,
1870 expected_prefix: &str,
1871) -> Result<()> {
1872 let row = sqlx::query(
1873 "SELECT id, name, prefix FROM projects WHERE workspace_id = ? AND key = ? AND deleted = 0",
1874 )
1875 .bind(workspace_id)
1876 .bind(project_key)
1877 .fetch_optional(&mut *conn)
1878 .await?;
1879 let Some(row) = row else {
1880 bail!("error undo-state-changed project_key={project_key}");
1881 };
1882 let project_id: ProjectId = row.get("id");
1883 let name: String = row.get("name");
1884 let prefix: String = row.get("prefix");
1885 if name != expected_name || prefix != expected_prefix {
1886 bail!("error undo-state-changed project_key={project_key}");
1887 }
1888 let task_refs: i64 =
1889 sqlx::query_scalar("SELECT count(*) FROM tasks WHERE workspace_id = ? AND project_id = ?")
1890 .bind(workspace_id)
1891 .bind(&project_id)
1892 .fetch_one(&mut *conn)
1893 .await?;
1894 if task_refs > 0 {
1895 bail!("error undo-state-changed project_key={project_key}");
1896 }
1897 let path_refs: i64 = sqlx::query_scalar(
1898 "SELECT count(*) FROM project_paths WHERE workspace_id = ? AND project_id = ?",
1899 )
1900 .bind(workspace_id)
1901 .bind(&project_id)
1902 .fetch_one(&mut *conn)
1903 .await?;
1904 if path_refs > 0 {
1905 bail!("error undo-state-changed project_key={project_key}");
1906 }
1907 if !change_is_unsynced(conn, create_change_id).await? {
1908 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1909 sqlx::query("DELETE FROM projects WHERE workspace_id = ? AND key = ?")
1910 .bind(workspace_id)
1911 .bind(project_key)
1912 .execute(&mut *conn)
1913 .await?;
1914 append_change(
1915 conn,
1916 ChangeEntity::Project,
1917 project_id.as_str(),
1918 None,
1919 op_type::PROJECT_DELETE,
1920 ChangePayload::workspace(&workspace).set("deleted_at", now()),
1921 )
1922 .await?;
1923 return Ok(());
1924 }
1925 crate::sync::shared_state::ensure_changes_not_local_capture_protected(
1926 conn,
1927 &[create_change_id],
1928 )
1929 .await?;
1930 sqlx::query("DELETE FROM projects WHERE workspace_id = ? AND key = ?")
1931 .bind(workspace_id)
1932 .bind(project_key)
1933 .execute(&mut *conn)
1934 .await?;
1935 sqlx::query("DELETE FROM changes WHERE change_id = ?")
1936 .bind(create_change_id)
1937 .execute(&mut *conn)
1938 .await?;
1939 Ok(())
1940}
1941
1942async fn set_project_metadata_for_undo(
1943 conn: &mut SqliteConnection,
1944 workspace_id: &WorkspaceId,
1945 project_id: &ProjectId,
1946 before: ProjectMetadata<'_>,
1947 after: ProjectMetadata<'_>,
1948) -> Result<()> {
1949 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1950 let row = sqlx::query(
1951 "SELECT key, name, prefix
1952 FROM projects
1953 WHERE workspace_id = ? AND id = ? AND deleted = 0",
1954 )
1955 .bind(workspace_id)
1956 .bind(project_id)
1957 .fetch_optional(&mut *conn)
1958 .await?;
1959 let Some(row) = row else {
1960 bail!("error undo-state-changed project_id={project_id}");
1961 };
1962 let key: String = row.get("key");
1963 let name: String = row.get("name");
1964 let prefix: String = row.get("prefix");
1965 if key != after.key || name != after.name || prefix != after.prefix {
1966 bail!("error undo-state-changed project_id={project_id}");
1967 }
1968 let key_refs: i64 = sqlx::query_scalar(
1969 "SELECT count(*) FROM projects
1970 WHERE workspace_id = ? AND key = ? AND id != ? AND deleted = 0",
1971 )
1972 .bind(workspace_id)
1973 .bind(before.key)
1974 .bind(project_id)
1975 .fetch_one(&mut *conn)
1976 .await?;
1977 if key_refs > 0 {
1978 bail!("error undo-state-changed project_id={project_id}");
1979 }
1980 let prefix_refs: i64 = sqlx::query_scalar(
1981 "SELECT count(*) FROM projects
1982 WHERE workspace_id = ? AND prefix = ? AND id != ? AND deleted = 0",
1983 )
1984 .bind(workspace_id)
1985 .bind(before.prefix)
1986 .bind(project_id)
1987 .fetch_one(&mut *conn)
1988 .await?;
1989 if prefix_refs > 0 {
1990 bail!("error undo-state-changed project_id={project_id}");
1991 }
1992 set_project_metadata(conn, &workspace, project_id, before, false).await?;
1993 insert_project_metadata_change(conn, &workspace, project_id, before, &now()).await?;
1994 Ok(())
1995}
1996
1997async fn delete_created_label(
1998 conn: &mut SqliteConnection,
1999 workspace_id: &WorkspaceId,
2000 label: &str,
2001 create_change_id: &str,
2002) -> Result<()> {
2003 let exists: i64 =
2004 sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
2005 .bind(workspace_id)
2006 .bind(label)
2007 .fetch_one(&mut *conn)
2008 .await?;
2009 if exists == 0 {
2010 bail!("error undo-state-changed label={label}");
2011 }
2012 let task_refs: i64 =
2013 sqlx::query_scalar("SELECT count(*) FROM task_labels WHERE workspace_id = ? AND label = ?")
2014 .bind(workspace_id)
2015 .bind(label)
2016 .fetch_one(&mut *conn)
2017 .await?;
2018 let series_refs: i64 = sqlx::query_scalar(
2019 "SELECT count(*) FROM recurrence_series_labels WHERE workspace_id = ? AND label = ?",
2020 )
2021 .bind(workspace_id)
2022 .bind(label)
2023 .fetch_one(&mut *conn)
2024 .await?;
2025 let live_task_refs: i64 = sqlx::query_scalar(
2026 "SELECT count(*) FROM task_labels tl
2027 WHERE tl.workspace_id = ? AND tl.label = ?
2028 AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.workspace_id = tl.workspace_id
2029 AND t.id = tl.task_id AND t.deleted = 1)",
2030 )
2031 .bind(workspace_id)
2032 .bind(label)
2033 .fetch_one(&mut *conn)
2034 .await?;
2035 if live_task_refs > 0 || series_refs > 0 {
2036 bail!("error undo-state-changed label={label}");
2037 }
2038 if task_refs > 0 || !change_is_unsynced(conn, create_change_id).await? {
2039 sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND label = ?")
2040 .bind(workspace_id)
2041 .bind(label)
2042 .execute(&mut *conn)
2043 .await?;
2044 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2045 sqlx::query("DELETE FROM labels WHERE workspace_id = ? AND name = ?")
2046 .bind(workspace_id)
2047 .bind(label)
2048 .execute(&mut *conn)
2049 .await?;
2050 append_change(
2051 conn,
2052 ChangeEntity::Label,
2053 label,
2054 None,
2055 op_type::LABEL_DELETE,
2056 ChangePayload::workspace(&workspace)
2057 .set("name", label)
2058 .set("deleted_at", now()),
2059 )
2060 .await?;
2061 return Ok(());
2062 }
2063 crate::sync::shared_state::ensure_changes_not_local_capture_protected(
2064 conn,
2065 &[create_change_id],
2066 )
2067 .await?;
2068 sqlx::query("DELETE FROM labels WHERE workspace_id = ? AND name = ?")
2069 .bind(workspace_id)
2070 .bind(label)
2071 .execute(&mut *conn)
2072 .await?;
2073 sqlx::query("DELETE FROM changes WHERE change_id = ?")
2074 .bind(create_change_id)
2075 .execute(&mut *conn)
2076 .await?;
2077 Ok(())
2078}
2079
2080async fn restore_deleted_label(
2081 conn: &mut SqliteConnection,
2082 workspace_id: &WorkspaceId,
2083 name: &str,
2084 created_at: &str,
2085 task_ids: &[crate::ids::TaskId],
2086 series_ids: &[crate::recurrence::RecurrenceSeriesId],
2087) -> Result<()> {
2088 let exists: i64 =
2089 sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
2090 .bind(workspace_id)
2091 .bind(name)
2092 .fetch_one(&mut *conn)
2093 .await?;
2094 if exists > 0 {
2095 bail!("error undo-state-changed label={name}");
2096 }
2097 sqlx::query("INSERT INTO labels(workspace_id, name, created_at) VALUES (?, ?, ?)")
2098 .bind(workspace_id)
2099 .bind(name)
2100 .bind(created_at)
2101 .execute(&mut *conn)
2102 .await?;
2103 for task_id in task_ids {
2104 let inserted = sqlx::query(
2105 "INSERT INTO task_labels(workspace_id, task_id, label)
2106 SELECT ?, id, ? FROM tasks WHERE workspace_id = ? AND id = ?",
2107 )
2108 .bind(workspace_id)
2109 .bind(name)
2110 .bind(workspace_id)
2111 .bind(task_id)
2112 .execute(&mut *conn)
2113 .await?;
2114 if inserted.rows_affected() != 1 {
2115 bail!("error undo-state-changed label={name} task_id={task_id}");
2116 }
2117 }
2118 for series_id in series_ids {
2119 let inserted = sqlx::query(
2120 "INSERT INTO recurrence_series_labels(workspace_id, series_id, label)
2121 SELECT ?, id, ? FROM recurrence_series WHERE workspace_id = ? AND id = ?",
2122 )
2123 .bind(workspace_id)
2124 .bind(name)
2125 .bind(workspace_id)
2126 .bind(series_id)
2127 .execute(&mut *conn)
2128 .await?;
2129 if inserted.rows_affected() != 1 {
2130 bail!("error undo-state-changed label={name} series_id={series_id}");
2131 }
2132 }
2133 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2134 append_change(
2135 conn,
2136 ChangeEntity::Label,
2137 name,
2138 None,
2139 op_type::LABEL_RESTORE,
2140 ChangePayload::workspace(&workspace)
2141 .set("name", name)
2142 .set("created_at", created_at)
2143 .set("task_ids", task_ids)
2144 .set("series_ids", series_ids)
2145 .set("restored_at", now()),
2146 )
2147 .await?;
2148 Ok(())
2149}
2150
2151async fn dependency_task_exists(
2152 conn: &mut SqliteConnection,
2153 workspace_id: &WorkspaceId,
2154 task_id: &crate::ids::TaskId,
2155) -> Result<bool> {
2156 Ok(
2157 sqlx::query_scalar::<_, i64>(
2158 "SELECT count(*) FROM tasks WHERE workspace_id = ? AND id = ?",
2159 )
2160 .bind(workspace_id)
2161 .bind(task_id)
2162 .fetch_one(&mut *conn)
2163 .await?
2164 > 0,
2165 )
2166}
2167
2168async fn dependency_edge_exists(
2169 conn: &mut SqliteConnection,
2170 workspace_id: &WorkspaceId,
2171 task_id: &crate::ids::TaskId,
2172 depends_on_task_id: &crate::ids::TaskId,
2173) -> Result<bool> {
2174 ensure!(
2175 dependency_task_exists(conn, workspace_id, task_id).await?
2176 && dependency_task_exists(conn, workspace_id, depends_on_task_id).await?,
2177 "error undo-state-changed task_id={task_id} field=dependency"
2178 );
2179 Ok(sqlx::query_scalar::<_, i64>(
2180 "SELECT count(*) FROM task_dependencies
2181 WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
2182 )
2183 .bind(workspace_id)
2184 .bind(task_id)
2185 .bind(depends_on_task_id)
2186 .fetch_one(&mut *conn)
2187 .await?
2188 > 0)
2189}
2190
2191async fn epic_edge_exists(
2192 conn: &mut SqliteConnection,
2193 workspace_id: &WorkspaceId,
2194 epic_id: &crate::ids::TaskId,
2195 child_id: &crate::ids::TaskId,
2196) -> Result<bool> {
2197 Ok(sqlx::query_scalar::<_, i64>(
2198 "SELECT count(*) FROM task_epic_links
2199 WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
2200 )
2201 .bind(workspace_id)
2202 .bind(epic_id)
2203 .bind(child_id)
2204 .fetch_one(&mut *conn)
2205 .await?
2206 > 0)
2207}
2208
2209async fn set_epic_edge_for_undo(
2210 conn: &mut SqliteConnection,
2211 workspace_id: &WorkspaceId,
2212 epic_id: &crate::ids::TaskId,
2213 child_id: &crate::ids::TaskId,
2214 linked: bool,
2215) -> Result<()> {
2216 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2217 let outcome = if linked {
2218 crate::operations::restore_task_to_epic_in_transaction(conn, &workspace, child_id, epic_id)
2219 .await?
2220 } else {
2221 crate::operations::remove_task_from_epic_in_transaction(conn, &workspace, child_id, epic_id)
2222 .await?
2223 };
2224 ensure!(
2225 outcome.changed,
2226 "error undo-state-changed task_id={child_id} field=epic"
2227 );
2228 Ok(())
2229}
2230
2231async fn add_dependency_for_undo(
2232 conn: &mut SqliteConnection,
2233 workspace_id: &WorkspaceId,
2234 task_id: &crate::ids::TaskId,
2235 depends_on_task_id: &crate::ids::TaskId,
2236) -> Result<()> {
2237 let created_at = now();
2238 sqlx::query(
2239 "INSERT OR IGNORE INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
2240 VALUES (?, ?, ?, ?)",
2241 )
2242 .bind(workspace_id)
2243 .bind(task_id)
2244 .bind(depends_on_task_id)
2245 .bind(&created_at)
2246 .execute(&mut *conn)
2247 .await?;
2248 append_dependency_change(
2249 conn,
2250 workspace_id,
2251 task_id,
2252 depends_on_task_id,
2253 crate::change_log::op_type::DEPENDENCY_ADD,
2254 )
2255 .await
2256}
2257
2258async fn remove_dependency_for_undo(
2259 conn: &mut SqliteConnection,
2260 workspace_id: &WorkspaceId,
2261 task_id: &crate::ids::TaskId,
2262 depends_on_task_id: &crate::ids::TaskId,
2263) -> Result<()> {
2264 sqlx::query(
2265 "DELETE FROM task_dependencies
2266 WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
2267 )
2268 .bind(workspace_id)
2269 .bind(task_id)
2270 .bind(depends_on_task_id)
2271 .execute(&mut *conn)
2272 .await?;
2273 append_dependency_change(
2274 conn,
2275 workspace_id,
2276 task_id,
2277 depends_on_task_id,
2278 crate::change_log::op_type::DEPENDENCY_REMOVE,
2279 )
2280 .await
2281}
2282
2283async fn append_dependency_change(
2284 conn: &mut SqliteConnection,
2285 workspace_id: &WorkspaceId,
2286 task_id: &crate::ids::TaskId,
2287 depends_on_task_id: &crate::ids::TaskId,
2288 op_type: &'static str,
2289) -> Result<()> {
2290 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2291 crate::change_log::append_change(
2292 conn,
2293 crate::change_log::ChangeEntity::Task,
2294 task_id,
2295 Some("dependencies"),
2296 op_type,
2297 crate::change_log::ChangePayload::workspace(&workspace)
2298 .set("depends_on_task_id", depends_on_task_id.to_string()),
2299 )
2300 .await?;
2301 Ok(())
2302}
2303
2304#[cfg(test)]
2305mod presentation_tests {
2306 use super::*;
2307
2308 fn set_field(task_id: &crate::ids::TaskId, field: &str) -> UndoCommand {
2309 UndoCommand::SetTaskField {
2310 task_id: task_id.clone(),
2311 field: field.to_string(),
2312 before: "before".to_string(),
2313 after: "after".to_string(),
2314 queue_activity_before: None,
2315 queue_activity_after: None,
2316 }
2317 }
2318
2319 #[test]
2320 fn classifier_derives_operations_and_unique_task_scope() {
2321 let first = crate::ids::TaskId::new();
2322 let second = crate::ids::TaskId::new();
2323 let cases = [
2324 (vec![set_field(&first, "priority")], "priority change", 1),
2325 (
2326 vec![
2327 set_field(&first, "status"),
2328 set_field(&first, "status"),
2329 set_field(&second, "status"),
2330 ],
2331 "status change",
2332 2,
2333 ),
2334 (
2335 vec![UndoCommand::SetTaskMetadata {
2336 task_id: first.clone(),
2337 field_id: crate::ids::MetadataFieldId::new(),
2338 before: None,
2339 after: Some("value".to_string()),
2340 }],
2341 "metadata change",
2342 1,
2343 ),
2344 ];
2345
2346 for (commands, operation, task_count) in cases {
2347 let presentation = classify_undo_commands("entry".to_string(), &commands);
2348 assert_eq!(presentation.operation, operation);
2349 assert_eq!(presentation.task_ids.len(), task_count);
2350 }
2351 }
2352
2353 #[test]
2354 fn classifier_describes_related_link_changes_on_the_initiating_task() {
2355 let task_id = crate::ids::TaskId::new();
2356 let related_task_id = crate::ids::TaskId::new();
2357
2358 for (linked, operation) in [
2359 (true, "related link addition"),
2360 (false, "related link removal"),
2361 ] {
2362 let presentation = classify_undo_commands(
2363 "entry".to_string(),
2364 &[UndoCommand::SetTaskRelatedLink {
2365 task_id: task_id.clone(),
2366 related_task_id: related_task_id.clone(),
2367 forward_change_id: "change".to_string(),
2368 linked,
2369 }],
2370 );
2371
2372 assert_eq!(presentation.operation, operation);
2373 assert_eq!(presentation.task_ids, vec![task_id.clone()]);
2374 }
2375 }
2376
2377 #[test]
2378 fn classifier_uses_primary_intent_for_mixed_payloads() {
2379 let task_id = crate::ids::TaskId::new();
2380 let project_id = ProjectId::new();
2381 let creation = UndoCommand::DeleteCreatedTask {
2382 task_id: task_id.clone(),
2383 create_change_id: None,
2384 expected: TaskUndoSnapshot {
2385 title: "title".to_string(),
2386 description: String::new(),
2387 project_id,
2388 project_key: "project".to_string(),
2389 status: "inbox".to_string(),
2390 priority: "none".to_string(),
2391 queue_activity_at: String::new(),
2392 available_at: String::new(),
2393 due_on: String::new(),
2394 deleted: false,
2395 is_epic: false,
2396 labels: vec!["new".to_string()],
2397 metadata: BTreeMap::new(),
2398 },
2399 attachment_ids: Vec::new(),
2400 attachment_change_ids: Vec::new(),
2401 };
2402 let cleanup = UndoCommand::DeleteCreatedLabel {
2403 label: "new".to_string(),
2404 create_change_id: "change".to_string(),
2405 };
2406 let presentation =
2407 classify_undo_commands("entry".to_string(), &[creation, cleanup.clone()]);
2408 assert_eq!(presentation.operation, "task creation");
2409
2410 let label_change = UndoCommand::SetTaskLabels {
2411 task_id,
2412 before: Vec::new(),
2413 after: vec!["new".to_string()],
2414 };
2415 let presentation = classify_undo_commands("entry".to_string(), &[label_change, cleanup]);
2416 assert_eq!(presentation.operation, "label change");
2417
2418 let epic_id = crate::ids::TaskId::new();
2419 let child_id = crate::ids::TaskId::new();
2420 let presentation = classify_undo_commands(
2421 "entry".to_string(),
2422 &[
2423 set_field(&epic_id, "is_epic"),
2424 UndoCommand::AddEpicChild { epic_id, child_id },
2425 ],
2426 );
2427 assert_eq!(presentation.operation, "epic membership addition");
2428 }
2429}