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