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