Skip to main content

aven_core/
undo.rs

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