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