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