Skip to main content

aven_core/operations/
tasks.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::Path;
3
4use crate::ids::WorkspaceId;
5use anyhow::{Result, bail};
6use sqlx::SqliteConnection;
7use tracing::{info, warn};
8
9use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
10use crate::choices::{TaskPriority, TaskSource, TaskStatus};
11use crate::db::{Database, begin_immediate, set_field_version};
12use crate::ids::{TaskId, new_id, now};
13use crate::labels::{
14    CreatedLabel, resolve_labels_in_workspace, resolve_or_create_labels_in_workspace,
15};
16use crate::mutation::{set_task_field, set_task_project};
17use crate::projects::resolve_or_create_project_in_workspace;
18use crate::refs::{DisplayRefContext, get_task_in_workspace};
19use crate::task_fields::TaskField;
20use crate::types::Task;
21use crate::undo::{
22    TaskUndoSnapshot, UndoCommand, UndoContext, UndoPayload, record_tui_undo, task_snapshot,
23};
24use crate::workspaces::Workspace;
25
26pub struct TaskDraft {
27    pub title: String,
28    pub description: String,
29    pub project: Option<String>,
30    pub status: String,
31    pub priority: String,
32    pub source: TaskSource,
33    pub labels: Vec<String>,
34    pub available_at: Option<String>,
35    pub due_on: Option<String>,
36    pub is_epic: bool,
37}
38
39#[derive(Debug, Clone, Default)]
40pub enum TaskCreationUndo {
41    #[default]
42    None,
43    TuiTask,
44    TuiEpicChild {
45        epic_id: TaskId,
46        epic_display_ref: String,
47    },
48}
49
50#[derive(Debug, Clone)]
51pub struct TaskCreationOptions {
52    epic_id: Option<TaskId>,
53    undo: TaskCreationUndo,
54    create_missing_labels: bool,
55}
56
57impl TaskCreationOptions {
58    pub fn standalone(undo: TaskCreationUndo) -> Self {
59        Self {
60            epic_id: None,
61            undo,
62            create_missing_labels: false,
63        }
64    }
65
66    pub fn for_epic(epic_id: TaskId, undo: TaskCreationUndo) -> Self {
67        Self {
68            epic_id: Some(epic_id),
69            undo,
70            create_missing_labels: false,
71        }
72    }
73
74    pub fn with_create_missing_labels(mut self) -> Self {
75        self.create_missing_labels = true;
76        self
77    }
78}
79
80#[derive(Debug)]
81pub struct TaskOutcome {
82    pub task: Task,
83    pub create_change_id: Option<String>,
84    pub attachment_change_ids: Vec<String>,
85}
86
87struct InsertedTask {
88    id: TaskId,
89    change_id: String,
90    project_key: String,
91    label_count: usize,
92    created_labels: Vec<CreatedLabel>,
93}
94
95#[derive(Debug, Clone)]
96pub struct TaskLabelSelection {
97    pub selected: Vec<String>,
98    pub partial: Vec<String>,
99}
100
101#[derive(Clone, Default)]
102pub struct TaskUpdate {
103    pub title: Option<String>,
104    pub description: Option<String>,
105    pub project: Option<String>,
106    pub status: Option<String>,
107    pub priority: Option<String>,
108    pub cycle_priority: Option<bool>,
109    pub available_at: Option<Option<String>>,
110    pub due_on: Option<Option<String>>,
111    pub deleted: Option<bool>,
112    pub is_epic: Option<bool>,
113    pub add_labels: Vec<String>,
114    pub remove_labels: Vec<String>,
115    pub label_selection: Option<TaskLabelSelection>,
116    pub create_missing_labels: bool,
117}
118
119pub struct TaskUpdateOutcome {
120    pub task: Task,
121    pub changed: bool,
122}
123
124#[derive(Debug)]
125pub struct TaskMutationOutcome {
126    pub task: Task,
127    pub before: TaskUndoSnapshot,
128    pub after: TaskUndoSnapshot,
129    pub changed: bool,
130}
131
132#[derive(Debug)]
133pub struct TaskMutationReport {
134    pub outcomes: Vec<TaskMutationOutcome>,
135}
136
137#[derive(Clone, Copy, PartialEq, Eq)]
138enum TaskMutationDetail {
139    Report,
140    Compact,
141}
142
143struct TaskMutationExecutionOutcome {
144    task: Task,
145    before: Option<TaskUndoSnapshot>,
146    after: Option<TaskUndoSnapshot>,
147    changed: bool,
148}
149
150impl TaskMutationReport {
151    pub fn changed_count(&self) -> usize {
152        self.outcomes
153            .iter()
154            .filter(|outcome| outcome.changed)
155            .count()
156    }
157}
158
159pub struct NoteEditOutcome {
160    #[allow(dead_code)]
161    pub task_id: TaskId,
162    #[allow(dead_code)]
163    pub note_id: String,
164    pub found: bool,
165    pub changed: bool,
166}
167
168pub struct NoteDeleteOutcome {
169    #[allow(dead_code)]
170    pub task_id: TaskId,
171    #[allow(dead_code)]
172    pub note_id: String,
173    pub changed: bool,
174}
175
176pub struct NoteOutcome {
177    #[allow(dead_code)]
178    pub task_id: TaskId,
179    pub note_id: String,
180    pub change_id: String,
181}
182impl Database {
183    pub async fn create_task(
184        &self,
185        workspace: &Workspace,
186        draft: TaskDraft,
187    ) -> Result<TaskOutcome> {
188        self.create_task_with_undo(workspace, draft, TaskCreationUndo::None)
189            .await
190    }
191
192    pub async fn create_task_with_undo(
193        &self,
194        workspace: &Workspace,
195        draft: TaskDraft,
196        undo: TaskCreationUndo,
197    ) -> Result<TaskOutcome> {
198        self.create_task_with_options(workspace, draft, TaskCreationOptions::standalone(undo))
199            .await
200    }
201
202    pub async fn create_task_with_options(
203        &self,
204        workspace: &Workspace,
205        draft: TaskDraft,
206        options: TaskCreationOptions,
207    ) -> Result<TaskOutcome> {
208        let mut conn = self.acquire().await?;
209        create_task_with_epic(&mut conn, workspace, draft, options).await
210    }
211
212    pub async fn create_task_for_epic(
213        &self,
214        workspace: &Workspace,
215        draft: TaskDraft,
216        epic_id: &TaskId,
217    ) -> Result<TaskOutcome> {
218        self.create_task_for_epic_with_undo(workspace, draft, epic_id, TaskCreationUndo::None)
219            .await
220    }
221
222    pub async fn create_task_for_epic_with_undo(
223        &self,
224        workspace: &Workspace,
225        draft: TaskDraft,
226        epic_id: &TaskId,
227        undo: TaskCreationUndo,
228    ) -> Result<TaskOutcome> {
229        let mut conn = self.acquire().await?;
230        create_task_with_epic(
231            &mut conn,
232            workspace,
233            draft,
234            TaskCreationOptions::for_epic(epic_id.clone(), undo),
235        )
236        .await
237    }
238
239    pub async fn create_task_with_attachments(
240        &self,
241        workspace: &Workspace,
242        blob_dir: &Path,
243        lifecycle_policy: crate::attachments::lifecycle::LifecyclePolicy,
244        draft: TaskDraft,
245        attachments: Vec<super::attachments::TaskAttachmentAddInput>,
246    ) -> Result<TaskOutcome> {
247        self.create_task_with_attachments_and_undo(
248            workspace,
249            blob_dir,
250            lifecycle_policy,
251            draft,
252            attachments,
253            TaskCreationUndo::None,
254        )
255        .await
256    }
257
258    pub async fn create_task_with_attachments_and_undo(
259        &self,
260        workspace: &Workspace,
261        blob_dir: &Path,
262        lifecycle_policy: crate::attachments::lifecycle::LifecyclePolicy,
263        draft: TaskDraft,
264        attachments: Vec<super::attachments::TaskAttachmentAddInput>,
265        undo: TaskCreationUndo,
266    ) -> Result<TaskOutcome> {
267        self.create_task_with_attachments_and_options(
268            workspace,
269            blob_dir,
270            lifecycle_policy,
271            draft,
272            attachments,
273            TaskCreationOptions::standalone(undo),
274        )
275        .await
276    }
277
278    pub async fn create_task_with_attachments_and_options(
279        &self,
280        workspace: &Workspace,
281        blob_dir: &Path,
282        lifecycle_policy: crate::attachments::lifecycle::LifecyclePolicy,
283        draft: TaskDraft,
284        attachments: Vec<super::attachments::TaskAttachmentAddInput>,
285        options: TaskCreationOptions,
286    ) -> Result<TaskOutcome> {
287        let mut conn = self.acquire().await?;
288        create_task_with_attachments_and_epic(
289            &mut conn,
290            workspace,
291            blob_dir,
292            lifecycle_policy,
293            draft,
294            attachments,
295            options,
296        )
297        .await
298    }
299
300    pub async fn create_task_with_attachments_for_epic(
301        &self,
302        workspace: &Workspace,
303        blob_dir: &Path,
304        lifecycle_policy: crate::attachments::lifecycle::LifecyclePolicy,
305        draft: TaskDraft,
306        attachments: Vec<super::attachments::TaskAttachmentAddInput>,
307        epic_id: &TaskId,
308    ) -> Result<TaskOutcome> {
309        self.create_task_with_attachments_for_epic_and_undo(
310            workspace,
311            blob_dir,
312            lifecycle_policy,
313            draft,
314            attachments,
315            TaskCreationOptions::for_epic(epic_id.clone(), TaskCreationUndo::None),
316        )
317        .await
318    }
319
320    pub async fn create_task_with_attachments_for_epic_and_undo(
321        &self,
322        workspace: &Workspace,
323        blob_dir: &Path,
324        lifecycle_policy: crate::attachments::lifecycle::LifecyclePolicy,
325        draft: TaskDraft,
326        attachments: Vec<super::attachments::TaskAttachmentAddInput>,
327        options: TaskCreationOptions,
328    ) -> Result<TaskOutcome> {
329        let mut conn = self.acquire().await?;
330        create_task_with_attachments_and_epic(
331            &mut conn,
332            workspace,
333            blob_dir,
334            lifecycle_policy,
335            draft,
336            attachments,
337            options,
338        )
339        .await
340    }
341
342    pub async fn update_task(
343        &self,
344        workspace: &Workspace,
345        task_id: &TaskId,
346        update: TaskUpdate,
347    ) -> Result<TaskUpdateOutcome> {
348        let outcomes = self
349            .mutate_tasks_owned(
350                workspace,
351                vec![(task_id.clone(), update)],
352                UndoContext::None,
353                TaskMutationDetail::Compact,
354            )
355            .await?;
356        let outcome = outcomes.into_iter().next().unwrap();
357        Ok(TaskUpdateOutcome {
358            task: outcome.task,
359            changed: outcome.changed,
360        })
361    }
362
363    pub async fn update_tasks(
364        &self,
365        workspace: &Workspace,
366        updates: Vec<(TaskId, TaskUpdate)>,
367    ) -> Result<Vec<TaskUpdateOutcome>> {
368        Ok(self
369            .mutate_tasks_owned(
370                workspace,
371                updates,
372                UndoContext::None,
373                TaskMutationDetail::Compact,
374            )
375            .await?
376            .into_iter()
377            .map(|outcome| TaskUpdateOutcome {
378                task: outcome.task,
379                changed: outcome.changed,
380            })
381            .collect())
382    }
383
384    pub async fn mutate_tasks(
385        &self,
386        workspace: &Workspace,
387        updates: Vec<(TaskId, TaskUpdate)>,
388        undo: UndoContext,
389    ) -> Result<TaskMutationReport> {
390        let outcomes = self
391            .mutate_tasks_owned(workspace, updates, undo, TaskMutationDetail::Report)
392            .await?;
393        Ok(TaskMutationReport {
394            outcomes: outcomes
395                .into_iter()
396                .map(|outcome| TaskMutationOutcome {
397                    task: outcome.task,
398                    before: outcome.before.expect("report mutation before snapshot"),
399                    after: outcome.after.expect("report mutation after snapshot"),
400                    changed: outcome.changed,
401                })
402                .collect(),
403        })
404    }
405
406    async fn mutate_tasks_owned(
407        &self,
408        workspace: &Workspace,
409        updates: Vec<(TaskId, TaskUpdate)>,
410        undo: UndoContext,
411        detail: TaskMutationDetail,
412    ) -> Result<Vec<TaskMutationExecutionOutcome>> {
413        for (_, update) in &updates {
414            validate_task_update(update)?;
415        }
416        if updates.is_empty() {
417            return Ok(Vec::new());
418        }
419        if detail == TaskMutationDetail::Compact && !matches!(&undo, UndoContext::None) {
420            bail!("compact task mutations cannot record undo");
421        }
422
423        let mut conn = self.acquire().await?;
424        let mut tx = begin_immediate(&mut conn).await?;
425        let mut outcomes = Vec::with_capacity(updates.len());
426        let mut undo_commands = Vec::new();
427        let mut created_labels = Vec::new();
428        for (task_id, update) in &updates {
429            let before =
430                if detail == TaskMutationDetail::Report || task_update_requires_snapshot(update) {
431                    Some(task_snapshot(&mut tx, &workspace.id, task_id).await?)
432                } else {
433                    None
434                };
435            let update = match before.as_ref() {
436                Some(before) => materialize_task_update(update, before)?,
437                None => update.clone(),
438            };
439            let (changed, task_created_labels) =
440                apply_task_update(&mut tx, workspace, task_id, &update).await?;
441            created_labels.extend(task_created_labels);
442            let task = get_task_in_workspace(&mut tx, workspace, task_id).await?;
443            let after = if detail == TaskMutationDetail::Report {
444                Some(task_snapshot(&mut tx, &workspace.id, task_id).await?)
445            } else {
446                None
447            };
448            if let (Some(before), Some(after)) = (&before, &after) {
449                append_task_undo_commands(task_id, before, after, &mut undo_commands);
450            }
451            outcomes.push(TaskMutationExecutionOutcome {
452                task,
453                changed,
454                before,
455                after,
456            });
457        }
458        let changed_count = outcomes.iter().filter(|outcome| outcome.changed).count();
459        append_created_label_undo_commands(&mut undo_commands, &created_labels);
460        if let Some(summary) = undo.task_mutation_summary(changed_count) {
461            record_tui_undo(
462                &mut tx,
463                &workspace.id,
464                &summary,
465                UndoPayload {
466                    commands: undo_commands,
467                },
468            )
469            .await?;
470        }
471        tx.commit().await?;
472
473        for outcome in &outcomes {
474            info!(task_id = %outcome.task.id, changed = outcome.changed, "task updated");
475        }
476        Ok(outcomes)
477    }
478
479    pub async fn set_task_deleted(
480        &self,
481        workspace: &Workspace,
482        task_id: &TaskId,
483        deleted: bool,
484    ) -> Result<TaskOutcome> {
485        let outcome = self
486            .update_task(
487                workspace,
488                task_id,
489                TaskUpdate {
490                    deleted: Some(deleted),
491                    ..TaskUpdate::default()
492                },
493            )
494            .await?;
495        Ok(TaskOutcome {
496            task: outcome.task,
497            create_change_id: None,
498            attachment_change_ids: Vec::new(),
499        })
500    }
501
502    pub async fn add_note(
503        &self,
504        workspace: &Workspace,
505        task_id: &TaskId,
506        body: String,
507    ) -> Result<NoteOutcome> {
508        let mut conn = self.acquire().await?;
509        add_note_operation(&mut conn, workspace, task_id, body, false).await
510    }
511
512    pub async fn add_note_with_tui_undo(
513        &self,
514        workspace: &Workspace,
515        task_id: &TaskId,
516        body: String,
517    ) -> Result<NoteOutcome> {
518        let mut conn = self.acquire().await?;
519        add_note_operation(&mut conn, workspace, task_id, body, true).await
520    }
521
522    pub async fn edit_note(
523        &self,
524        workspace: &Workspace,
525        task_id: &TaskId,
526        note_id: &str,
527        body: String,
528    ) -> Result<NoteEditOutcome> {
529        let mut conn = self.acquire().await?;
530        edit_note_operation(&mut conn, workspace, task_id, note_id, body, false).await
531    }
532
533    pub async fn edit_note_with_tui_undo(
534        &self,
535        workspace: &Workspace,
536        task_id: &TaskId,
537        note_id: &str,
538        body: String,
539    ) -> Result<NoteEditOutcome> {
540        let mut conn = self.acquire().await?;
541        edit_note_operation(&mut conn, workspace, task_id, note_id, body, true).await
542    }
543
544    pub async fn delete_note(
545        &self,
546        workspace: &Workspace,
547        task_id: &TaskId,
548        note_id: &str,
549    ) -> Result<NoteDeleteOutcome> {
550        let mut conn = self.acquire().await?;
551        delete_note_operation(&mut conn, workspace, task_id, note_id, false).await
552    }
553
554    pub async fn delete_note_with_tui_undo(
555        &self,
556        workspace: &Workspace,
557        task_id: &TaskId,
558        note_id: &str,
559    ) -> Result<NoteDeleteOutcome> {
560        let mut conn = self.acquire().await?;
561        delete_note_operation(&mut conn, workspace, task_id, note_id, true).await
562    }
563}
564
565#[cfg(any(test, feature = "test-support"))]
566pub async fn create_task(
567    conn: &mut SqliteConnection,
568    workspace: &Workspace,
569    draft: TaskDraft,
570) -> Result<TaskOutcome> {
571    create_task_with_epic(
572        conn,
573        workspace,
574        draft,
575        TaskCreationOptions::standalone(TaskCreationUndo::None),
576    )
577    .await
578}
579
580async fn create_task_with_epic(
581    conn: &mut SqliteConnection,
582    workspace: &Workspace,
583    draft: TaskDraft,
584    options: TaskCreationOptions,
585) -> Result<TaskOutcome> {
586    validate_task_draft(&draft)?;
587    let TaskCreationOptions {
588        epic_id,
589        undo,
590        create_missing_labels,
591    } = options;
592    let mut tx = begin_immediate(conn).await?;
593    let inserted = insert_task(&mut tx, workspace, draft, create_missing_labels).await?;
594    if let Some(epic_id) = epic_id {
595        super::add_task_to_epic_in_transaction(&mut tx, workspace, &inserted.id, &epic_id).await?;
596    }
597    let task = get_task_in_workspace(&mut tx, workspace, &inserted.id).await?;
598    record_task_creation_undo(
599        &mut tx,
600        workspace,
601        &task,
602        &inserted,
603        Vec::new(),
604        Vec::new(),
605        undo,
606    )
607    .await?;
608    tx.commit().await?;
609    info!(
610        task_id = %inserted.id,
611        project_key = %inserted.project_key,
612        label_count = inserted.label_count,
613        "task created"
614    );
615    Ok(TaskOutcome {
616        task,
617        create_change_id: Some(inserted.change_id),
618        attachment_change_ids: Vec::new(),
619    })
620}
621
622async fn create_task_with_attachments_and_epic(
623    conn: &mut SqliteConnection,
624    workspace: &Workspace,
625    blob_dir: &Path,
626    lifecycle_policy: crate::attachments::lifecycle::LifecyclePolicy,
627    draft: TaskDraft,
628    attachments: Vec<super::attachments::TaskAttachmentAddInput>,
629    options: TaskCreationOptions,
630) -> Result<TaskOutcome> {
631    validate_task_draft(&draft)?;
632    let TaskCreationOptions {
633        epic_id,
634        undo,
635        create_missing_labels,
636    } = options;
637    let mut prepared = Vec::with_capacity(attachments.len());
638    for attachment in attachments {
639        prepared.push(super::attachments::prepare_task_attachment(attachment).await?);
640    }
641
642    let mut unique = BTreeMap::new();
643    for attachment in &prepared {
644        unique
645            .entry(attachment.sha256.clone())
646            .or_insert_with(|| attachment.clone());
647    }
648
649    let mut capacity_reservations = Vec::new();
650    for attachment in unique.values() {
651        let available: bool = sqlx::query_scalar(
652            "SELECT EXISTS(SELECT 1 FROM blob_inventory WHERE sha256 = ? AND available = 1)",
653        )
654        .bind(&attachment.sha256)
655        .fetch_one(&mut *conn)
656        .await?;
657        if available {
658            continue;
659        }
660        match crate::attachments::lifecycle::ensure_local_capacity(
661            conn,
662            blob_dir,
663            &attachment.sha256,
664            attachment.byte_size,
665            lifecycle_policy,
666            &crate::attachments::lifecycle::SystemClock,
667        )
668        .await
669        {
670            Ok(Some(reservation_id)) => capacity_reservations.push(reservation_id),
671            Ok(None) => {}
672            Err(error) => {
673                for reservation_id in capacity_reservations {
674                    let _ =
675                        crate::attachments::lifecycle::release_reservation(conn, &reservation_id)
676                            .await;
677                }
678                return Err(error);
679            }
680        }
681    }
682
683    let mut staging_leases = Vec::with_capacity(unique.len());
684    for attachment in unique.values() {
685        match crate::attachments::lifecycle::acquire_lease(
686            conn,
687            &attachment.sha256,
688            "staging",
689            &crate::attachments::lifecycle::SystemClock,
690        )
691        .await
692        {
693            Ok(lease_id) => staging_leases.push(lease_id),
694            Err(error) => {
695                for lease_id in staging_leases {
696                    let _ = crate::attachments::lifecycle::release_lease(conn, &lease_id).await;
697                }
698                for reservation_id in capacity_reservations {
699                    let _ =
700                        crate::attachments::lifecycle::release_reservation(conn, &reservation_id)
701                            .await;
702                }
703                return Err(error);
704            }
705        }
706    }
707
708    let mut created_hashes = Vec::new();
709    for attachment in unique.values() {
710        match crate::attachments::storage::stage_blob(
711            blob_dir,
712            &attachment.sha256,
713            &attachment.bytes,
714        )
715        .await
716        {
717            Ok(staged) if staged.byte_size == attachment.byte_size => {
718                if staged.created {
719                    created_hashes.push(staged.sha256);
720                }
721            }
722            Ok(_) => {
723                cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
724                cleanup_created_objects(conn, blob_dir, &created_hashes).await;
725                bail!("error attachment-staged-size-mismatch");
726            }
727            Err(error) => {
728                cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
729                cleanup_created_objects(conn, blob_dir, &created_hashes).await;
730                return Err(error);
731            }
732        }
733    }
734
735    let database_result = async {
736        let mut tx = begin_immediate(conn).await?;
737        for attachment in unique.values() {
738            crate::attachments::storage::upsert_inventory_available(
739                &mut tx,
740                &attachment.sha256,
741                attachment.byte_size,
742                &attachment.facts.media_type,
743            )
744            .await?;
745        }
746        let inserted = insert_task(&mut tx, workspace, draft, create_missing_labels).await?;
747        if let Some(epic_id) = epic_id.as_ref() {
748            super::add_task_to_epic_in_transaction(&mut tx, workspace, &inserted.id, epic_id)
749                .await?;
750        }
751        let mut attachment_change_ids = Vec::with_capacity(prepared.len());
752        let attachment_base = chrono::DateTime::parse_from_rfc3339(&now())?.to_utc();
753        for (index, attachment) in prepared.iter().enumerate() {
754            let created_at = (attachment_base
755                + chrono::TimeDelta::microseconds(i64::try_from(index)?))
756            .to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
757            attachment_change_ids.push(
758                super::attachments::insert_prepared_attachment(
759                    &mut tx,
760                    workspace,
761                    &inserted.id,
762                    attachment,
763                    &created_at,
764                )
765                .await?,
766            );
767        }
768        let task = get_task_in_workspace(&mut tx, workspace, &inserted.id).await?;
769        let attachment_ids = prepared
770            .iter()
771            .map(|attachment| attachment.attachment_id.clone())
772            .collect();
773        record_task_creation_undo(
774            &mut tx,
775            workspace,
776            &task,
777            &inserted,
778            attachment_ids,
779            attachment_change_ids.clone(),
780            undo,
781        )
782        .await?;
783        tx.commit().await?;
784        Ok::<_, anyhow::Error>((inserted, attachment_change_ids, task))
785    }
786    .await;
787
788    let (inserted, attachment_change_ids, task) = match database_result {
789        Ok(value) => value,
790        Err(error) => {
791            cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
792            cleanup_created_objects(conn, blob_dir, &created_hashes).await;
793            return Err(error);
794        }
795    };
796    cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
797    if let Err(error) = crate::attachments::lifecycle::reconcile_liveness(
798        conn,
799        &crate::attachments::lifecycle::SystemClock,
800    )
801    .await
802    {
803        warn!(%error, "failed to reconcile attachment liveness");
804    }
805    info!(
806        task_id = %inserted.id,
807        project_key = %inserted.project_key,
808        label_count = inserted.label_count,
809        attachment_count = prepared.len(),
810        "task created"
811    );
812    Ok(TaskOutcome {
813        task,
814        create_change_id: Some(inserted.change_id),
815        attachment_change_ids,
816    })
817}
818
819async fn record_task_creation_undo(
820    conn: &mut SqliteConnection,
821    workspace: &Workspace,
822    task: &Task,
823    inserted: &InsertedTask,
824    attachment_ids: Vec<String>,
825    attachment_change_ids: Vec<String>,
826    undo: TaskCreationUndo,
827) -> Result<()> {
828    let (summary, commands) = match undo {
829        TaskCreationUndo::None => return Ok(()),
830        TaskCreationUndo::TuiTask => {
831            let mut commands = vec![UndoCommand::DeleteCreatedTask {
832                task_id: task.id.clone(),
833                create_change_id: Some(inserted.change_id.clone()),
834                expected: task_snapshot(conn, &workspace.id, &task.id).await?,
835                attachment_ids,
836                attachment_change_ids,
837            }];
838            append_created_label_undo_commands(&mut commands, &inserted.created_labels);
839            (format!("task {}", task.id), commands)
840        }
841        TaskCreationUndo::TuiEpicChild {
842            epic_id,
843            epic_display_ref,
844        } => {
845            let display_refs = DisplayRefContext::for_workspace(conn, &workspace.id).await?;
846            let child_ref = display_refs.display_ref(task);
847            (
848                format!("add {child_ref} to {epic_display_ref}"),
849                vec![UndoCommand::AddEpicChild {
850                    epic_id,
851                    child_id: task.id.clone(),
852                }],
853            )
854        }
855    };
856    record_tui_undo(conn, &workspace.id, &summary, UndoPayload { commands }).await
857}
858
859fn append_created_label_undo_commands(
860    commands: &mut Vec<UndoCommand>,
861    created_labels: &[CreatedLabel],
862) {
863    commands.extend(
864        created_labels
865            .iter()
866            .map(|label| UndoCommand::DeleteCreatedLabel {
867                label: label.name.clone(),
868                create_change_id: label.change_id.clone(),
869            }),
870    );
871}
872
873fn validate_task_draft(draft: &TaskDraft) -> Result<()> {
874    TaskStatus::parse(&draft.status)?;
875    TaskPriority::parse(&draft.priority)?;
876    if let Some(available_at) = draft.available_at.as_deref() {
877        crate::time_validation::validate_available_at_value(available_at)?;
878    }
879    if let Some(due_on) = draft.due_on.as_deref() {
880        crate::time_validation::validate_due_on_value(due_on)?;
881    }
882    Ok(())
883}
884
885async fn insert_task(
886    conn: &mut SqliteConnection,
887    workspace: &Workspace,
888    draft: TaskDraft,
889    create_missing_labels: bool,
890) -> Result<InsertedTask> {
891    let status = TaskStatus::parse(&draft.status)?;
892    let priority = TaskPriority::parse(&draft.priority)?;
893    let available_at = draft.available_at.as_deref().unwrap_or("");
894    let due_on = draft.due_on.as_deref().unwrap_or("");
895    let id = TaskId::new();
896    let ts = now();
897    let project = draft
898        .project
899        .as_deref()
900        .ok_or_else(|| anyhow::anyhow!("error project-required"))?;
901    let project = resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?;
902    let (labels, created_labels) = if create_missing_labels {
903        let resolution =
904            resolve_or_create_labels_in_workspace(conn, workspace, &draft.labels).await?;
905        (resolution.names, resolution.created)
906    } else {
907        (
908            resolve_labels_in_workspace(conn, &workspace.id, &draft.labels).await?,
909            Vec::new(),
910        )
911    };
912    sqlx::query(
913        "INSERT INTO tasks(workspace_id, id, title, description, project_id, status, priority, source, created_at, updated_at, queue_activity_at, available_at, due_on, is_epic)
914         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
915    )
916    .bind(&workspace.id)
917    .bind(&id)
918    .bind(&draft.title)
919    .bind(&draft.description)
920    .bind(&project.id)
921    .bind(status.as_str())
922    .bind(priority.as_str())
923    .bind(draft.source.as_str())
924    .bind(&ts)
925    .bind(&ts)
926    .bind(&ts)
927    .bind(available_at)
928    .bind(due_on)
929    .bind(i64::from(draft.is_epic))
930    .execute(&mut *conn)
931    .await?;
932    for label in &labels {
933        sqlx::query(
934            "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
935        )
936        .bind(&workspace.id)
937        .bind(&id)
938        .bind(label)
939        .execute(&mut *conn)
940        .await?;
941    }
942    let change_id = append_change(
943        conn,
944        ChangeEntity::Task,
945        &id,
946        None,
947        op_type::CREATE_TASK,
948        ChangePayload::workspace(workspace)
949            .set("title", draft.title)
950            .set("description", draft.description)
951            .set("project_id", project.id.clone())
952            .set("project_key", project.key.clone())
953            .set("project_name", project.name.clone())
954            .set("project_prefix", project.prefix.clone())
955            .set("status", status.as_str())
956            .set("priority", priority.as_str())
957            .set("source", draft.source.as_str())
958            .set("available_at", available_at)
959            .set("due_on", due_on)
960            .set("is_epic", if draft.is_epic { "1" } else { "0" })
961            .set("labels", &labels)
962            .set("created_at", ts),
963    )
964    .await?;
965    for field in TaskField::VERSIONED {
966        set_field_version(conn, &id, field.as_str(), &change_id).await?;
967    }
968    Ok(InsertedTask {
969        id,
970        change_id,
971        project_key: project.key,
972        label_count: labels.len(),
973        created_labels,
974    })
975}
976
977async fn cleanup_attachment_guards(
978    conn: &mut SqliteConnection,
979    leases: &[String],
980    reservations: &[String],
981) {
982    for lease_id in leases {
983        if let Err(error) = crate::attachments::lifecycle::release_lease(conn, lease_id).await {
984            warn!(%error, "failed to release attachment staging lease");
985        }
986    }
987    for reservation_id in reservations {
988        if let Err(error) =
989            crate::attachments::lifecycle::release_reservation(conn, reservation_id).await
990        {
991            warn!(%error, "failed to release attachment capacity reservation");
992        }
993    }
994}
995
996async fn cleanup_created_objects(conn: &mut SqliteConnection, blob_dir: &Path, hashes: &[String]) {
997    for sha256 in hashes {
998        crate::attachments::storage::remove_staged_blob_if_unreferenced(conn, blob_dir, sha256)
999            .await;
1000    }
1001}
1002
1003#[cfg(any(test, feature = "test-support"))]
1004pub async fn update_task(
1005    conn: &mut SqliteConnection,
1006    workspace: &Workspace,
1007    task_id: &crate::ids::TaskId,
1008    update: TaskUpdate,
1009) -> Result<TaskUpdateOutcome> {
1010    validate_task_update(&update)?;
1011    let mut tx = begin_immediate(conn).await?;
1012    let (changed, _) = apply_task_update(&mut tx, workspace, task_id, &update).await?;
1013    tx.commit().await?;
1014    Ok(TaskUpdateOutcome {
1015        task: get_task_in_workspace(conn, workspace, task_id).await?,
1016        changed,
1017    })
1018}
1019
1020fn append_task_undo_commands(
1021    task_id: &TaskId,
1022    before: &TaskUndoSnapshot,
1023    after: &TaskUndoSnapshot,
1024    commands: &mut Vec<UndoCommand>,
1025) {
1026    let fields = [
1027        ("title", before.title.as_str(), after.title.as_str()),
1028        (
1029            "description",
1030            before.description.as_str(),
1031            after.description.as_str(),
1032        ),
1033        (
1034            "project",
1035            before.project_id.as_str(),
1036            after.project_id.as_str(),
1037        ),
1038        ("status", before.status.as_str(), after.status.as_str()),
1039        (
1040            "priority",
1041            before.priority.as_str(),
1042            after.priority.as_str(),
1043        ),
1044        (
1045            "available_at",
1046            before.available_at.as_str(),
1047            after.available_at.as_str(),
1048        ),
1049        ("due_on", before.due_on.as_str(), after.due_on.as_str()),
1050    ];
1051    for (field, before, after) in fields {
1052        if before != after {
1053            commands.push(UndoCommand::SetTaskField {
1054                task_id: task_id.clone(),
1055                field: field.to_string(),
1056                before: before.to_string(),
1057                after: after.to_string(),
1058            });
1059        }
1060    }
1061    let before_deleted = if before.deleted { "1" } else { "0" };
1062    let after_deleted = if after.deleted { "1" } else { "0" };
1063    if before_deleted != after_deleted {
1064        commands.push(UndoCommand::SetTaskField {
1065            task_id: task_id.clone(),
1066            field: "deleted".to_string(),
1067            before: before_deleted.to_string(),
1068            after: after_deleted.to_string(),
1069        });
1070    }
1071    let before_is_epic = if before.is_epic { "1" } else { "0" };
1072    let after_is_epic = if after.is_epic { "1" } else { "0" };
1073    if before_is_epic != after_is_epic {
1074        commands.push(UndoCommand::SetTaskField {
1075            task_id: task_id.clone(),
1076            field: "is_epic".to_string(),
1077            before: before_is_epic.to_string(),
1078            after: after_is_epic.to_string(),
1079        });
1080    }
1081    if before.labels != after.labels {
1082        commands.push(UndoCommand::SetTaskLabels {
1083            task_id: task_id.clone(),
1084            before: before.labels.clone(),
1085            after: after.labels.clone(),
1086        });
1087    }
1088}
1089
1090fn cycled_priority(priority: TaskPriority, reverse: bool) -> TaskPriority {
1091    let index = TaskPriority::ALL
1092        .iter()
1093        .position(|candidate| *candidate == priority)
1094        .unwrap_or(0);
1095    let next = if reverse {
1096        (index + TaskPriority::ALL.len() - 1) % TaskPriority::ALL.len()
1097    } else {
1098        (index + 1) % TaskPriority::ALL.len()
1099    };
1100    TaskPriority::ALL[next]
1101}
1102
1103fn task_update_requires_snapshot(update: &TaskUpdate) -> bool {
1104    update.cycle_priority.is_some() || update.label_selection.is_some()
1105}
1106
1107fn materialize_task_update(update: &TaskUpdate, before: &TaskUndoSnapshot) -> Result<TaskUpdate> {
1108    let mut update = update.clone();
1109    if let Some(reverse) = update.cycle_priority.take() {
1110        let priority = TaskPriority::parse(&before.priority)?;
1111        update.priority = Some(cycled_priority(priority, reverse).to_string());
1112    }
1113    if let Some(selection) = update.label_selection.take() {
1114        let selected = selection.selected.iter().cloned().collect::<BTreeSet<_>>();
1115        let partial = selection.partial.iter().cloned().collect::<BTreeSet<_>>();
1116        update.add_labels = selection
1117            .selected
1118            .into_iter()
1119            .filter(|label| !before.labels.contains(label))
1120            .collect();
1121        update.remove_labels = before
1122            .labels
1123            .iter()
1124            .filter(|label| !selected.contains(label.as_str()) && !partial.contains(label.as_str()))
1125            .cloned()
1126            .collect();
1127    }
1128    Ok(update)
1129}
1130
1131fn validate_task_update(update: &TaskUpdate) -> Result<()> {
1132    if update.priority.is_some() && update.cycle_priority.is_some() {
1133        bail!("error invalid-task-update priority and priority cycle are mutually exclusive");
1134    }
1135    if update.label_selection.is_some()
1136        && (!update.add_labels.is_empty() || !update.remove_labels.is_empty())
1137    {
1138        bail!("error invalid-task-update label selection and label deltas are mutually exclusive");
1139    }
1140    if let Some(status) = update.status.as_deref() {
1141        TaskStatus::parse(status)?;
1142    }
1143    if let Some(priority) = update.priority.as_deref() {
1144        TaskPriority::parse(priority)?;
1145    }
1146    if let Some(Some(available_at)) = update.available_at.as_ref() {
1147        crate::time_validation::validate_available_at_value(available_at)?;
1148    }
1149    if let Some(Some(due_on)) = update.due_on.as_ref() {
1150        crate::time_validation::validate_due_on_value(due_on)?;
1151    }
1152    Ok(())
1153}
1154
1155async fn apply_task_update(
1156    conn: &mut SqliteConnection,
1157    workspace: &Workspace,
1158    task_id: &crate::ids::TaskId,
1159    update: &TaskUpdate,
1160) -> Result<(bool, Vec<CreatedLabel>)> {
1161    let created_labels = if update.create_missing_labels {
1162        resolve_or_create_labels_in_workspace(conn, workspace, &update.add_labels)
1163            .await?
1164            .created
1165    } else {
1166        Vec::new()
1167    };
1168    let mut changed = false;
1169    if let Some(title) = update.title.as_deref() {
1170        changed |= update_task_field(conn, workspace, task_id, "title", title).await?;
1171    }
1172    if let Some(description) = update.description.as_deref() {
1173        changed |= update_task_field(conn, workspace, task_id, "description", description).await?;
1174    }
1175    if let Some(project) = update.project.as_deref() {
1176        let project = resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?;
1177        changed |= set_task_project(conn, workspace, task_id, &project).await?;
1178    }
1179    if let Some(status) = update.status.as_deref() {
1180        changed |= update_task_field(conn, workspace, task_id, "status", status).await?;
1181    }
1182    if let Some(priority) = update.priority.as_deref() {
1183        changed |= update_task_field(conn, workspace, task_id, "priority", priority).await?;
1184    }
1185    if let Some(available_at) = update.available_at.as_ref() {
1186        changed |= update_task_field(
1187            conn,
1188            workspace,
1189            task_id,
1190            "available_at",
1191            available_at.as_deref().unwrap_or(""),
1192        )
1193        .await?;
1194    }
1195    if let Some(due_on) = update.due_on.as_ref() {
1196        changed |= update_task_field(
1197            conn,
1198            workspace,
1199            task_id,
1200            "due_on",
1201            due_on.as_deref().unwrap_or(""),
1202        )
1203        .await?;
1204    }
1205    if let Some(deleted) = update.deleted {
1206        changed |= update_task_field(
1207            conn,
1208            workspace,
1209            task_id,
1210            "deleted",
1211            if deleted { "1" } else { "0" },
1212        )
1213        .await?;
1214        crate::attachments::lifecycle::reconcile_liveness_in_transaction(
1215            conn,
1216            &crate::attachments::lifecycle::SystemClock,
1217        )
1218        .await?;
1219    }
1220    if let Some(is_epic) = update.is_epic {
1221        if !is_epic {
1222            let task = get_task_in_workspace(conn, workspace, task_id).await?;
1223            if super::epics::task_has_epic_children(conn, &task.workspace_id, task_id).await? {
1224                bail!("error epic-has-children task_id={task_id}");
1225            }
1226        }
1227        changed |= update_task_field(
1228            conn,
1229            workspace,
1230            task_id,
1231            "is_epic",
1232            if is_epic { "1" } else { "0" },
1233        )
1234        .await?;
1235    }
1236    changed |= update_task_labels_in_workspace(
1237        conn,
1238        &workspace.id,
1239        task_id,
1240        &update.add_labels,
1241        &update.remove_labels,
1242    )
1243    .await?;
1244    Ok((changed, created_labels))
1245}
1246
1247pub async fn update_task_field(
1248    conn: &mut SqliteConnection,
1249    workspace: &Workspace,
1250    task_id: &crate::ids::TaskId,
1251    field: &str,
1252    value: &str,
1253) -> Result<bool> {
1254    set_task_field(conn, workspace, task_id, field, value).await
1255}
1256
1257pub async fn update_task_labels_in_workspace(
1258    conn: &mut SqliteConnection,
1259    workspace_id: &WorkspaceId,
1260    task_id: &crate::ids::TaskId,
1261    add_labels: &[String],
1262    remove_labels: &[String],
1263) -> Result<bool> {
1264    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1265    crate::operations::route_recurrence_task_field(conn, &workspace, task_id, "labels", "").await?;
1266    let mut changed = false;
1267    for label in resolve_labels_in_workspace(conn, &workspace.id, add_labels).await? {
1268        let rows_affected = sqlx::query(
1269            "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
1270        )
1271        .bind(&workspace.id)
1272        .bind(task_id)
1273        .bind(&label)
1274        .execute(&mut *conn)
1275        .await?
1276        .rows_affected();
1277        if rows_affected > 0 {
1278            append_change(
1279                conn,
1280                ChangeEntity::Task,
1281                task_id,
1282                Some("labels"),
1283                op_type::LABEL_ADD,
1284                ChangePayload::workspace(&workspace).set("label", label),
1285            )
1286            .await?;
1287            changed = true;
1288        }
1289    }
1290    for label in resolve_labels_in_workspace(conn, &workspace.id, remove_labels).await? {
1291        let rows_affected = sqlx::query(
1292            "DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ? AND label = ?",
1293        )
1294        .bind(&workspace.id)
1295        .bind(task_id)
1296        .bind(&label)
1297        .execute(&mut *conn)
1298        .await?
1299        .rows_affected();
1300        if rows_affected > 0 {
1301            append_change(
1302                conn,
1303                ChangeEntity::Task,
1304                task_id,
1305                Some("labels"),
1306                op_type::LABEL_REMOVE,
1307                ChangePayload::workspace(&workspace).set("label", label),
1308            )
1309            .await?;
1310            changed = true;
1311        }
1312    }
1313    if changed {
1314        info!(
1315            task_id = %task_id,
1316            added = add_labels.len(),
1317            removed = remove_labels.len(),
1318            "task labels changed"
1319        );
1320    }
1321    Ok(changed)
1322}
1323
1324pub(super) async fn add_note_operation(
1325    conn: &mut SqliteConnection,
1326    workspace: &Workspace,
1327    task_id: &crate::ids::TaskId,
1328    body: String,
1329    tui_undo: bool,
1330) -> Result<NoteOutcome> {
1331    let note_id = new_id();
1332    let ts = now();
1333    let mut tx = begin_immediate(conn).await?;
1334    crate::operations::route_recurrence_task_field(&mut tx, workspace, task_id, "notes", "")
1335        .await?;
1336    let change_id = append_change(
1337        &mut tx,
1338        ChangeEntity::Task,
1339        task_id,
1340        Some("notes"),
1341        op_type::NOTE_ADD,
1342        ChangePayload::workspace(workspace)
1343            .set("note_id", &note_id)
1344            .set("body", &body)
1345            .set("created_at", &ts),
1346    )
1347    .await?;
1348    sqlx::query(
1349        "INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id) VALUES (?, ?, ?, ?, ?, ?)",
1350    )
1351    .bind(&workspace.id)
1352    .bind(&note_id)
1353    .bind(task_id)
1354    .bind(&body)
1355    .bind(&ts)
1356    .bind(&change_id)
1357    .execute(&mut *tx)
1358    .await?;
1359    sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1360        .bind(&ts)
1361        .bind(&workspace.id)
1362        .bind(task_id)
1363        .execute(&mut *tx)
1364        .await?;
1365    if tui_undo {
1366        record_tui_undo(
1367            &mut tx,
1368            &workspace.id,
1369            &format!("note {note_id}"),
1370            UndoPayload {
1371                commands: vec![UndoCommand::DeleteCreatedNote {
1372                    task_id: task_id.clone(),
1373                    note_id: note_id.clone(),
1374                    note_add_change_id: change_id.clone(),
1375                }],
1376            },
1377        )
1378        .await?;
1379    }
1380    tx.commit().await?;
1381    info!(task_id = %task_id, note_id = %note_id, "note added");
1382    Ok(NoteOutcome {
1383        task_id: task_id.clone(),
1384        note_id,
1385        change_id,
1386    })
1387}
1388
1389async fn edit_note_operation(
1390    conn: &mut SqliteConnection,
1391    workspace: &Workspace,
1392    task_id: &crate::ids::TaskId,
1393    note_id: &str,
1394    body: String,
1395    tui_undo: bool,
1396) -> Result<NoteEditOutcome> {
1397    let mut tx = begin_immediate(conn).await?;
1398    crate::operations::route_recurrence_task_field(&mut tx, workspace, task_id, "notes", "")
1399        .await?;
1400    let before = sqlx::query_scalar::<_, String>(
1401        "SELECT body FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1402    )
1403    .bind(&workspace.id)
1404    .bind(task_id)
1405    .bind(note_id)
1406    .fetch_optional(&mut *tx)
1407    .await?;
1408    let found = before.is_some();
1409    let changed = before.as_ref().is_some_and(|before| before != &body);
1410    if changed {
1411        let edited_at = now();
1412        sqlx::query("UPDATE notes SET body = ? WHERE workspace_id = ? AND task_id = ? AND id = ?")
1413            .bind(&body)
1414            .bind(&workspace.id)
1415            .bind(task_id)
1416            .bind(note_id)
1417            .execute(&mut *tx)
1418            .await?;
1419        sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1420            .bind(&edited_at)
1421            .bind(&workspace.id)
1422            .bind(task_id)
1423            .execute(&mut *tx)
1424            .await?;
1425        append_change(
1426            &mut tx,
1427            ChangeEntity::Task,
1428            task_id,
1429            Some("notes"),
1430            op_type::NOTE_EDIT,
1431            ChangePayload::workspace(workspace)
1432                .set("note_id", note_id)
1433                .set("body", &body)
1434                .set("edited_at", edited_at),
1435        )
1436        .await?;
1437        if tui_undo {
1438            record_tui_undo(
1439                &mut tx,
1440                &workspace.id,
1441                &format!("edit note {note_id}"),
1442                UndoPayload {
1443                    commands: vec![UndoCommand::SetNoteBody {
1444                        task_id: task_id.clone(),
1445                        note_id: note_id.to_string(),
1446                        before: before.expect("changed note has before body"),
1447                        after: body,
1448                    }],
1449                },
1450            )
1451            .await?;
1452        }
1453    }
1454    tx.commit().await?;
1455    if changed {
1456        info!(task_id = %task_id, note_id = %note_id, "note edited");
1457    }
1458    Ok(NoteEditOutcome {
1459        task_id: task_id.clone(),
1460        note_id: note_id.to_string(),
1461        found,
1462        changed,
1463    })
1464}
1465
1466async fn delete_note_operation(
1467    conn: &mut SqliteConnection,
1468    workspace: &Workspace,
1469    task_id: &crate::ids::TaskId,
1470    note_id: &str,
1471    tui_undo: bool,
1472) -> Result<NoteDeleteOutcome> {
1473    let mut tx = begin_immediate(conn).await?;
1474    crate::operations::route_recurrence_task_field(&mut tx, workspace, task_id, "notes", "")
1475        .await?;
1476    let before = sqlx::query_as::<_, (String, String)>(
1477        "SELECT body, created_at FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1478    )
1479    .bind(&workspace.id)
1480    .bind(task_id)
1481    .bind(note_id)
1482    .fetch_optional(&mut *tx)
1483    .await?;
1484    let deleted_at = now();
1485    let deleted =
1486        sqlx::query("DELETE FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?")
1487            .bind(&workspace.id)
1488            .bind(task_id)
1489            .bind(note_id)
1490            .execute(&mut *tx)
1491            .await?
1492            .rows_affected();
1493    if deleted > 0 {
1494        sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1495            .bind(&deleted_at)
1496            .bind(&workspace.id)
1497            .bind(task_id)
1498            .execute(&mut *tx)
1499            .await?;
1500        append_change(
1501            &mut tx,
1502            ChangeEntity::Task,
1503            task_id,
1504            Some("notes"),
1505            op_type::NOTE_DELETE,
1506            ChangePayload::workspace(workspace)
1507                .set("note_id", note_id)
1508                .set("deleted_at", deleted_at),
1509        )
1510        .await?;
1511        if tui_undo {
1512            let (body, created_at) = before.expect("deleted note has before state");
1513            record_tui_undo(
1514                &mut tx,
1515                &workspace.id,
1516                &format!("delete note {note_id}"),
1517                UndoPayload {
1518                    commands: vec![UndoCommand::RestoreDeletedNote {
1519                        task_id: task_id.clone(),
1520                        note_id: note_id.to_string(),
1521                        body,
1522                        created_at,
1523                    }],
1524                },
1525            )
1526            .await?;
1527        }
1528    }
1529    tx.commit().await?;
1530    if deleted > 0 {
1531        info!(task_id = %task_id, note_id = %note_id, "note deleted");
1532    }
1533    Ok(NoteDeleteOutcome {
1534        task_id: task_id.clone(),
1535        note_id: note_id.to_string(),
1536        changed: deleted > 0,
1537    })
1538}