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