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