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 affected_attachment_hashes = BTreeSet::new();
431        let mut undo_commands = Vec::new();
432        let mut created_labels = Vec::new();
433        for (task_id, update) in &updates {
434            let before =
435                if detail == TaskMutationDetail::Report || task_update_requires_snapshot(update) {
436                    Some(task_snapshot(&mut tx, &workspace.id, task_id).await?)
437                } else {
438                    None
439                };
440            let update = match before.as_ref() {
441                Some(before) => materialize_task_update(update, before)?,
442                None => update.clone(),
443            };
444            let (changed, task_created_labels) = apply_task_update(
445                &mut tx,
446                workspace,
447                task_id,
448                &update,
449                &mut affected_attachment_hashes,
450            )
451            .await?;
452            created_labels.extend(task_created_labels);
453            let task = get_task_in_workspace(&mut tx, workspace, task_id).await?;
454            let after = if detail == TaskMutationDetail::Report {
455                Some(task_snapshot(&mut tx, &workspace.id, task_id).await?)
456            } else {
457                None
458            };
459            if let (Some(before), Some(after)) = (&before, &after) {
460                append_task_undo_commands(task_id, before, after, &mut undo_commands);
461            }
462            outcomes.push(TaskMutationExecutionOutcome {
463                task,
464                changed,
465                before,
466                after,
467            });
468        }
469        let affected_attachment_hashes = affected_attachment_hashes.into_iter().collect::<Vec<_>>();
470        crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
471            &mut tx,
472            &affected_attachment_hashes,
473            &crate::attachments::lifecycle::SystemClock,
474        )
475        .await?;
476        let changed_count = outcomes.iter().filter(|outcome| outcome.changed).count();
477        append_created_label_undo_commands(&mut undo_commands, &created_labels);
478        if let Some(summary) = undo.task_mutation_summary(changed_count) {
479            record_tui_undo(
480                &mut tx,
481                &workspace.id,
482                &summary,
483                UndoPayload {
484                    commands: undo_commands,
485                },
486            )
487            .await?;
488        }
489        tx.commit().await?;
490
491        for outcome in &outcomes {
492            info!(task_id = %outcome.task.id, changed = outcome.changed, "task updated");
493        }
494        Ok(outcomes)
495    }
496
497    pub async fn set_task_deleted(
498        &self,
499        workspace: &Workspace,
500        task_id: &TaskId,
501        deleted: bool,
502    ) -> Result<TaskOutcome> {
503        let outcome = self
504            .update_task(
505                workspace,
506                task_id,
507                TaskUpdate {
508                    deleted: Some(deleted),
509                    ..TaskUpdate::default()
510                },
511            )
512            .await?;
513        Ok(TaskOutcome {
514            task: outcome.task,
515            create_change_id: None,
516            attachment_change_ids: Vec::new(),
517        })
518    }
519
520    pub async fn add_note(
521        &self,
522        workspace: &Workspace,
523        task_id: &TaskId,
524        body: String,
525    ) -> Result<NoteOutcome> {
526        let mut conn = self.acquire_writer().await?;
527        add_note_operation(&mut conn, workspace, task_id, body, false).await
528    }
529
530    pub async fn add_note_with_tui_undo(
531        &self,
532        workspace: &Workspace,
533        task_id: &TaskId,
534        body: String,
535    ) -> Result<NoteOutcome> {
536        let mut conn = self.acquire_writer().await?;
537        add_note_operation(&mut conn, workspace, task_id, body, true).await
538    }
539
540    pub async fn edit_note(
541        &self,
542        workspace: &Workspace,
543        task_id: &TaskId,
544        note_id: &str,
545        body: String,
546    ) -> Result<NoteEditOutcome> {
547        let mut conn = self.acquire_writer().await?;
548        edit_note_operation(&mut conn, workspace, task_id, note_id, body, false).await
549    }
550
551    pub async fn edit_note_with_tui_undo(
552        &self,
553        workspace: &Workspace,
554        task_id: &TaskId,
555        note_id: &str,
556        body: String,
557    ) -> Result<NoteEditOutcome> {
558        let mut conn = self.acquire_writer().await?;
559        edit_note_operation(&mut conn, workspace, task_id, note_id, body, true).await
560    }
561
562    pub async fn delete_note(
563        &self,
564        workspace: &Workspace,
565        task_id: &TaskId,
566        note_id: &str,
567    ) -> Result<NoteDeleteOutcome> {
568        let mut conn = self.acquire_writer().await?;
569        delete_note_operation(&mut conn, workspace, task_id, note_id, false).await
570    }
571
572    pub async fn delete_note_with_tui_undo(
573        &self,
574        workspace: &Workspace,
575        task_id: &TaskId,
576        note_id: &str,
577    ) -> Result<NoteDeleteOutcome> {
578        let mut conn = self.acquire_writer().await?;
579        delete_note_operation(&mut conn, workspace, task_id, note_id, true).await
580    }
581}
582
583#[cfg(any(test, feature = "test-support"))]
584pub async fn create_task(
585    conn: &mut SqliteConnection,
586    workspace: &Workspace,
587    draft: TaskDraft,
588) -> Result<TaskOutcome> {
589    create_task_with_epic(
590        conn,
591        workspace,
592        draft,
593        TaskCreationOptions::standalone(TaskCreationUndo::None),
594    )
595    .await
596}
597
598async fn create_task_with_epic(
599    conn: &mut SqliteConnection,
600    workspace: &Workspace,
601    draft: TaskDraft,
602    options: TaskCreationOptions,
603) -> Result<TaskOutcome> {
604    validate_task_draft(&draft)?;
605    let TaskCreationOptions {
606        epic_id,
607        undo,
608        create_missing_labels,
609    } = options;
610    let mut tx = begin_immediate(conn).await?;
611    let inserted = insert_task(&mut tx, workspace, draft, create_missing_labels).await?;
612    if let Some(epic_id) = epic_id {
613        super::add_task_to_epic_in_transaction(&mut tx, workspace, &inserted.id, &epic_id).await?;
614    }
615    let task = get_task_in_workspace(&mut tx, workspace, &inserted.id).await?;
616    record_task_creation_undo(
617        &mut tx,
618        workspace,
619        &task,
620        &inserted,
621        Vec::new(),
622        Vec::new(),
623        undo,
624    )
625    .await?;
626    tx.commit().await?;
627    info!(
628        task_id = %inserted.id,
629        project_key = %inserted.project_key,
630        label_count = inserted.label_count,
631        "task created"
632    );
633    Ok(TaskOutcome {
634        task,
635        create_change_id: Some(inserted.change_id),
636        attachment_change_ids: Vec::new(),
637    })
638}
639
640async fn create_task_with_attachments_and_epic(
641    conn: &mut SqliteConnection,
642    workspace: &Workspace,
643    blob_dir: &Path,
644    lifecycle_policy: crate::attachments::lifecycle::LifecyclePolicy,
645    draft: TaskDraft,
646    attachments: Vec<super::attachments::TaskAttachmentAddInput>,
647    options: TaskCreationOptions,
648) -> Result<TaskOutcome> {
649    validate_task_draft(&draft)?;
650    let TaskCreationOptions {
651        epic_id,
652        undo,
653        create_missing_labels,
654    } = options;
655    let mut prepared = Vec::with_capacity(attachments.len());
656    for attachment in attachments {
657        prepared.push(super::attachments::prepare_task_attachment(attachment).await?);
658    }
659
660    let mut unique = BTreeMap::new();
661    for attachment in &prepared {
662        unique
663            .entry(attachment.sha256.clone())
664            .or_insert_with(|| attachment.clone());
665    }
666    let attachment_hashes = unique.keys().cloned().collect::<Vec<_>>();
667
668    let mut capacity_reservations = Vec::new();
669    for attachment in unique.values() {
670        let available: bool = sqlx::query_scalar(
671            "SELECT EXISTS(SELECT 1 FROM blob_inventory WHERE sha256 = ? AND available = 1)",
672        )
673        .bind(&attachment.sha256)
674        .fetch_one(&mut *conn)
675        .await?;
676        if available {
677            continue;
678        }
679        match crate::attachments::lifecycle::ensure_local_capacity(
680            conn,
681            blob_dir,
682            &attachment.sha256,
683            attachment.byte_size,
684            lifecycle_policy,
685            &crate::attachments::lifecycle::SystemClock,
686        )
687        .await
688        {
689            Ok(Some(reservation_id)) => capacity_reservations.push(reservation_id),
690            Ok(None) => {}
691            Err(error) => {
692                for reservation_id in capacity_reservations {
693                    let _ =
694                        crate::attachments::lifecycle::release_reservation(conn, &reservation_id)
695                            .await;
696                }
697                return Err(error);
698            }
699        }
700    }
701
702    let mut staging_leases = Vec::with_capacity(unique.len());
703    for attachment in unique.values() {
704        match crate::attachments::lifecycle::acquire_lease(
705            conn,
706            &attachment.sha256,
707            "staging",
708            &crate::attachments::lifecycle::SystemClock,
709        )
710        .await
711        {
712            Ok(lease_id) => staging_leases.push(lease_id),
713            Err(error) => {
714                for lease_id in staging_leases {
715                    let _ = crate::attachments::lifecycle::release_lease(conn, &lease_id).await;
716                }
717                for reservation_id in capacity_reservations {
718                    let _ =
719                        crate::attachments::lifecycle::release_reservation(conn, &reservation_id)
720                            .await;
721                }
722                return Err(error);
723            }
724        }
725    }
726
727    let mut created_hashes = Vec::new();
728    for attachment in unique.values() {
729        match crate::attachments::storage::stage_blob(
730            blob_dir,
731            &attachment.sha256,
732            &attachment.bytes,
733        )
734        .await
735        {
736            Ok(staged) if staged.byte_size == attachment.byte_size => {
737                if staged.created {
738                    created_hashes.push(staged.sha256);
739                }
740            }
741            Ok(_) => {
742                cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
743                cleanup_created_objects(conn, blob_dir, &created_hashes).await;
744                bail!("error attachment-staged-size-mismatch");
745            }
746            Err(error) => {
747                cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
748                cleanup_created_objects(conn, blob_dir, &created_hashes).await;
749                return Err(error);
750            }
751        }
752    }
753
754    let database_result = async {
755        let mut tx = begin_immediate(conn).await?;
756        for attachment in unique.values() {
757            crate::attachments::storage::upsert_inventory_available(
758                &mut tx,
759                &attachment.sha256,
760                attachment.byte_size,
761                &attachment.facts.media_type,
762            )
763            .await?;
764        }
765        let inserted = insert_task(&mut tx, workspace, draft, create_missing_labels).await?;
766        if let Some(epic_id) = epic_id.as_ref() {
767            super::add_task_to_epic_in_transaction(&mut tx, workspace, &inserted.id, epic_id)
768                .await?;
769        }
770        let mut attachment_change_ids = Vec::with_capacity(prepared.len());
771        let attachment_base = crate::ids::now_utc();
772        for (index, attachment) in prepared.iter().enumerate() {
773            let created_at = (attachment_base
774                + chrono::TimeDelta::microseconds(i64::try_from(index)?))
775            .to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
776            attachment_change_ids.push(
777                super::attachments::insert_prepared_attachment(
778                    &mut tx,
779                    workspace,
780                    &inserted.id,
781                    attachment,
782                    &created_at,
783                )
784                .await?,
785            );
786        }
787        crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
788            &mut tx,
789            &attachment_hashes,
790            &crate::attachments::lifecycle::SystemClock,
791        )
792        .await?;
793        let task = get_task_in_workspace(&mut tx, workspace, &inserted.id).await?;
794        let attachment_ids = prepared
795            .iter()
796            .map(|attachment| attachment.attachment_id.clone())
797            .collect();
798        record_task_creation_undo(
799            &mut tx,
800            workspace,
801            &task,
802            &inserted,
803            attachment_ids,
804            attachment_change_ids.clone(),
805            undo,
806        )
807        .await?;
808        tx.commit().await?;
809        Ok::<_, anyhow::Error>((inserted, attachment_change_ids, task))
810    }
811    .await;
812
813    let (inserted, attachment_change_ids, task) = match database_result {
814        Ok(value) => value,
815        Err(error) => {
816            cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
817            cleanup_created_objects(conn, blob_dir, &created_hashes).await;
818            return Err(error);
819        }
820    };
821    cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
822    info!(
823        task_id = %inserted.id,
824        project_key = %inserted.project_key,
825        label_count = inserted.label_count,
826        attachment_count = prepared.len(),
827        "task created"
828    );
829    Ok(TaskOutcome {
830        task,
831        create_change_id: Some(inserted.change_id),
832        attachment_change_ids,
833    })
834}
835
836async fn record_task_creation_undo(
837    conn: &mut SqliteConnection,
838    workspace: &Workspace,
839    task: &Task,
840    inserted: &InsertedTask,
841    attachment_ids: Vec<String>,
842    attachment_change_ids: Vec<String>,
843    undo: TaskCreationUndo,
844) -> Result<()> {
845    let (summary, commands) = match undo {
846        TaskCreationUndo::None => return Ok(()),
847        TaskCreationUndo::TuiTask => {
848            let mut commands = vec![UndoCommand::DeleteCreatedTask {
849                task_id: task.id.clone(),
850                create_change_id: Some(inserted.change_id.clone()),
851                expected: task_snapshot(conn, &workspace.id, &task.id).await?,
852                attachment_ids,
853                attachment_change_ids,
854            }];
855            append_created_label_undo_commands(&mut commands, &inserted.created_labels);
856            (format!("task {}", task.id), commands)
857        }
858        TaskCreationUndo::TuiEpicChild {
859            epic_id,
860            epic_display_ref,
861        } => {
862            let display_refs = DisplayRefContext::for_workspace(conn, &workspace.id).await?;
863            let child_ref = display_refs.display_ref(task);
864            (
865                format!("add {child_ref} to {epic_display_ref}"),
866                vec![UndoCommand::AddEpicChild {
867                    epic_id,
868                    child_id: task.id.clone(),
869                }],
870            )
871        }
872    };
873    record_tui_undo(conn, &workspace.id, &summary, UndoPayload { commands }).await
874}
875
876fn append_created_label_undo_commands(
877    commands: &mut Vec<UndoCommand>,
878    created_labels: &[CreatedLabel],
879) {
880    commands.extend(
881        created_labels
882            .iter()
883            .map(|label| UndoCommand::DeleteCreatedLabel {
884                label: label.name.clone(),
885                create_change_id: label.change_id.clone(),
886            }),
887    );
888}
889
890fn validate_task_draft(draft: &TaskDraft) -> Result<()> {
891    TaskStatus::parse(&draft.status)?;
892    TaskPriority::parse(&draft.priority)?;
893    crate::metadata::validate_metadata_update(&draft.metadata, &[])?;
894    if let Some(available_at) = draft.available_at.as_deref() {
895        crate::time_validation::validate_available_at_value(available_at)?;
896    }
897    if let Some(due_on) = draft.due_on.as_deref() {
898        crate::time_validation::validate_due_on_value(due_on)?;
899    }
900    Ok(())
901}
902
903async fn insert_task(
904    conn: &mut SqliteConnection,
905    workspace: &Workspace,
906    draft: TaskDraft,
907    create_missing_labels: bool,
908) -> Result<InsertedTask> {
909    let status = TaskStatus::parse(&draft.status)?;
910    let priority = TaskPriority::parse(&draft.priority)?;
911    let available_at = draft.available_at.as_deref().unwrap_or("");
912    let due_on = draft.due_on.as_deref().unwrap_or("");
913    let id = TaskId::new();
914    let ts = now();
915    let project = draft
916        .project
917        .as_deref()
918        .ok_or_else(|| anyhow::anyhow!("error project-required"))?;
919    let project = resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?;
920    let (labels, created_labels) = if create_missing_labels {
921        let resolution =
922            resolve_or_create_labels_in_workspace(conn, workspace, &draft.labels).await?;
923        (resolution.names, resolution.created)
924    } else {
925        (
926            resolve_labels_in_workspace(conn, &workspace.id, &draft.labels).await?,
927            Vec::new(),
928        )
929    };
930    sqlx::query(
931        "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)
932         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
933    )
934    .bind(&workspace.id)
935    .bind(&id)
936    .bind(&draft.title)
937    .bind(&draft.description)
938    .bind(&project.id)
939    .bind(status.as_str())
940    .bind(priority.as_str())
941    .bind(draft.source.as_str())
942    .bind(&ts)
943    .bind(&ts)
944    .bind(&ts)
945    .bind(available_at)
946    .bind(due_on)
947    .bind(i64::from(draft.is_epic))
948    .execute(&mut *conn)
949    .await?;
950    for label in &labels {
951        sqlx::query(
952            "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
953        )
954        .bind(&workspace.id)
955        .bind(&id)
956        .bind(label)
957        .execute(&mut *conn)
958        .await?;
959    }
960    let metadata =
961        crate::metadata::insert_initial_task_metadata(conn, workspace, &id, &draft.metadata, &ts)
962            .await?;
963    let change_id = append_change(
964        conn,
965        ChangeEntity::Task,
966        &id,
967        None,
968        op_type::CREATE_TASK,
969        ChangePayload::workspace(workspace)
970            .set("title", draft.title)
971            .set("description", draft.description)
972            .set("project_id", project.id.clone())
973            .set("project_key", project.key.clone())
974            .set("project_name", project.name.clone())
975            .set("project_prefix", project.prefix.clone())
976            .set("status", status.as_str())
977            .set("priority", priority.as_str())
978            .set("source", draft.source.as_str())
979            .set("available_at", available_at)
980            .set("due_on", due_on)
981            .set("is_epic", if draft.is_epic { "1" } else { "0" })
982            .set("labels", &labels)
983            .set("metadata", &metadata)
984            .set("created_at", ts),
985    )
986    .await?;
987    for field in TaskField::VERSIONED {
988        set_field_version(conn, &id, field.as_str(), &change_id).await?;
989    }
990    for value in &metadata {
991        set_field_version(
992            conn,
993            &id,
994            &format!("metadata:{}", value.field_id),
995            &change_id,
996        )
997        .await?;
998    }
999    Ok(InsertedTask {
1000        id,
1001        change_id,
1002        project_key: project.key,
1003        label_count: labels.len(),
1004        created_labels,
1005    })
1006}
1007
1008async fn cleanup_attachment_guards(
1009    conn: &mut SqliteConnection,
1010    leases: &[String],
1011    reservations: &[String],
1012) {
1013    for lease_id in leases {
1014        if let Err(error) = crate::attachments::lifecycle::release_lease(conn, lease_id).await {
1015            warn!(%error, "failed to release attachment staging lease");
1016        }
1017    }
1018    for reservation_id in reservations {
1019        if let Err(error) =
1020            crate::attachments::lifecycle::release_reservation(conn, reservation_id).await
1021        {
1022            warn!(%error, "failed to release attachment capacity reservation");
1023        }
1024    }
1025}
1026
1027async fn cleanup_created_objects(conn: &mut SqliteConnection, blob_dir: &Path, hashes: &[String]) {
1028    for sha256 in hashes {
1029        crate::attachments::storage::remove_staged_blob_if_unreferenced(conn, blob_dir, sha256)
1030            .await;
1031    }
1032}
1033
1034#[cfg(any(test, feature = "test-support"))]
1035pub async fn update_task(
1036    conn: &mut SqliteConnection,
1037    workspace: &Workspace,
1038    task_id: &crate::ids::TaskId,
1039    update: TaskUpdate,
1040) -> Result<TaskUpdateOutcome> {
1041    validate_task_update(&update)?;
1042    let mut tx = begin_immediate(conn).await?;
1043    let mut affected_attachment_hashes = BTreeSet::new();
1044    let (changed, _) = apply_task_update(
1045        &mut tx,
1046        workspace,
1047        task_id,
1048        &update,
1049        &mut affected_attachment_hashes,
1050    )
1051    .await?;
1052    let affected_attachment_hashes = affected_attachment_hashes.into_iter().collect::<Vec<_>>();
1053    crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
1054        &mut tx,
1055        &affected_attachment_hashes,
1056        &crate::attachments::lifecycle::SystemClock,
1057    )
1058    .await?;
1059    tx.commit().await?;
1060    Ok(TaskUpdateOutcome {
1061        task: get_task_in_workspace(conn, workspace, task_id).await?,
1062        changed,
1063    })
1064}
1065
1066fn append_task_undo_commands(
1067    task_id: &TaskId,
1068    before_snapshot: &TaskUndoSnapshot,
1069    after_snapshot: &TaskUndoSnapshot,
1070    commands: &mut Vec<UndoCommand>,
1071) {
1072    let before = before_snapshot;
1073    let after = after_snapshot;
1074    let fields = [
1075        ("title", before.title.as_str(), after.title.as_str()),
1076        (
1077            "description",
1078            before.description.as_str(),
1079            after.description.as_str(),
1080        ),
1081        (
1082            "project",
1083            before.project_id.as_str(),
1084            after.project_id.as_str(),
1085        ),
1086        ("status", before.status.as_str(), after.status.as_str()),
1087        (
1088            "priority",
1089            before.priority.as_str(),
1090            after.priority.as_str(),
1091        ),
1092        (
1093            "available_at",
1094            before.available_at.as_str(),
1095            after.available_at.as_str(),
1096        ),
1097        ("due_on", before.due_on.as_str(), after.due_on.as_str()),
1098    ];
1099    for (field, before, after) in fields {
1100        if before != after {
1101            commands.push(UndoCommand::SetTaskField {
1102                task_id: task_id.clone(),
1103                field: field.to_string(),
1104                before: before.to_string(),
1105                after: after.to_string(),
1106                queue_activity_before: (before_snapshot.queue_activity_at
1107                    != after_snapshot.queue_activity_at)
1108                    .then(|| before_snapshot.queue_activity_at.clone()),
1109                queue_activity_after: (before_snapshot.queue_activity_at
1110                    != after_snapshot.queue_activity_at)
1111                    .then(|| after_snapshot.queue_activity_at.clone()),
1112            });
1113        }
1114    }
1115    let before_deleted = if before.deleted { "1" } else { "0" };
1116    let after_deleted = if after.deleted { "1" } else { "0" };
1117    if before_deleted != after_deleted {
1118        commands.push(UndoCommand::SetTaskField {
1119            task_id: task_id.clone(),
1120            field: "deleted".to_string(),
1121            before: before_deleted.to_string(),
1122            after: after_deleted.to_string(),
1123            queue_activity_before: (before.queue_activity_at != after.queue_activity_at)
1124                .then(|| before.queue_activity_at.clone()),
1125            queue_activity_after: (before.queue_activity_at != after.queue_activity_at)
1126                .then(|| after.queue_activity_at.clone()),
1127        });
1128    }
1129    let before_is_epic = if before.is_epic { "1" } else { "0" };
1130    let after_is_epic = if after.is_epic { "1" } else { "0" };
1131    if before_is_epic != after_is_epic {
1132        commands.push(UndoCommand::SetTaskField {
1133            task_id: task_id.clone(),
1134            field: "is_epic".to_string(),
1135            before: before_is_epic.to_string(),
1136            after: after_is_epic.to_string(),
1137            queue_activity_before: None,
1138            queue_activity_after: None,
1139        });
1140    }
1141    if before.labels != after.labels {
1142        commands.push(UndoCommand::SetTaskLabels {
1143            task_id: task_id.clone(),
1144            before: before.labels.clone(),
1145            after: after.labels.clone(),
1146        });
1147    }
1148    let metadata_fields = before
1149        .metadata
1150        .keys()
1151        .chain(after.metadata.keys())
1152        .collect::<BTreeSet<_>>();
1153    for field_id in metadata_fields {
1154        let before_value = before.metadata.get(field_id);
1155        let after_value = after.metadata.get(field_id);
1156        if before_value != after_value {
1157            commands.push(UndoCommand::SetTaskMetadata {
1158                task_id: task_id.clone(),
1159                field_id: field_id
1160                    .parse()
1161                    .expect("task snapshots contain valid metadata field IDs"),
1162                before: before_value.cloned(),
1163                after: after_value.cloned(),
1164            });
1165        }
1166    }
1167}
1168
1169fn cycled_priority(priority: TaskPriority, reverse: bool) -> TaskPriority {
1170    let index = TaskPriority::ALL
1171        .iter()
1172        .position(|candidate| *candidate == priority)
1173        .unwrap_or(0);
1174    let next = if reverse {
1175        (index + TaskPriority::ALL.len() - 1) % TaskPriority::ALL.len()
1176    } else {
1177        (index + 1) % TaskPriority::ALL.len()
1178    };
1179    TaskPriority::ALL[next]
1180}
1181
1182fn task_update_requires_snapshot(update: &TaskUpdate) -> bool {
1183    update.cycle_priority.is_some() || update.label_selection.is_some()
1184}
1185
1186fn materialize_task_update(update: &TaskUpdate, before: &TaskUndoSnapshot) -> Result<TaskUpdate> {
1187    let mut update = update.clone();
1188    if let Some(reverse) = update.cycle_priority.take() {
1189        let priority = TaskPriority::parse(&before.priority)?;
1190        update.priority = Some(cycled_priority(priority, reverse).to_string());
1191    }
1192    if let Some(selection) = update.label_selection.take() {
1193        let selected = selection.selected.iter().cloned().collect::<BTreeSet<_>>();
1194        let partial = selection.partial.iter().cloned().collect::<BTreeSet<_>>();
1195        update.add_labels = selection
1196            .selected
1197            .into_iter()
1198            .filter(|label| !before.labels.contains(label))
1199            .collect();
1200        update.remove_labels = before
1201            .labels
1202            .iter()
1203            .filter(|label| !selected.contains(label.as_str()) && !partial.contains(label.as_str()))
1204            .cloned()
1205            .collect();
1206    }
1207    Ok(update)
1208}
1209
1210fn validate_task_update(update: &TaskUpdate) -> Result<()> {
1211    crate::metadata::validate_metadata_update(&update.set_metadata, &update.remove_metadata)?;
1212    if update.priority.is_some() && update.cycle_priority.is_some() {
1213        bail!("error invalid-task-update priority and priority cycle are mutually exclusive");
1214    }
1215    if update.label_selection.is_some()
1216        && (!update.add_labels.is_empty() || !update.remove_labels.is_empty())
1217    {
1218        bail!("error invalid-task-update label selection and label deltas are mutually exclusive");
1219    }
1220    if let Some(status) = update.status.as_deref() {
1221        TaskStatus::parse(status)?;
1222    }
1223    if let Some(priority) = update.priority.as_deref() {
1224        TaskPriority::parse(priority)?;
1225    }
1226    if let Some(Some(available_at)) = update.available_at.as_ref() {
1227        crate::time_validation::validate_available_at_value(available_at)?;
1228    }
1229    if let Some(Some(due_on)) = update.due_on.as_ref() {
1230        crate::time_validation::validate_due_on_value(due_on)?;
1231    }
1232    Ok(())
1233}
1234
1235async fn apply_task_update(
1236    conn: &mut SqliteConnection,
1237    workspace: &Workspace,
1238    task_id: &crate::ids::TaskId,
1239    update: &TaskUpdate,
1240    affected_attachment_hashes: &mut BTreeSet<String>,
1241) -> Result<(bool, Vec<CreatedLabel>)> {
1242    let created_labels = if update.create_missing_labels {
1243        resolve_or_create_labels_in_workspace(conn, workspace, &update.add_labels)
1244            .await?
1245            .created
1246    } else {
1247        Vec::new()
1248    };
1249    let mut changed = false;
1250    if let Some(title) = update.title.as_deref() {
1251        changed |= update_task_field(conn, workspace, task_id, "title", title).await?;
1252    }
1253    if let Some(description) = update.description.as_deref() {
1254        changed |= update_task_field(conn, workspace, task_id, "description", description).await?;
1255    }
1256    if let Some(project) = update.project.as_deref() {
1257        let project = resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?;
1258        changed |= set_task_project(conn, workspace, task_id, &project).await?;
1259    }
1260    if let Some(status) = update.status.as_deref() {
1261        changed |= update_task_field(conn, workspace, task_id, "status", status).await?;
1262    }
1263    if let Some(priority) = update.priority.as_deref() {
1264        changed |= update_task_field(conn, workspace, task_id, "priority", priority).await?;
1265    }
1266    if let Some(available_at) = update.available_at.as_ref() {
1267        changed |= update_task_field(
1268            conn,
1269            workspace,
1270            task_id,
1271            "available_at",
1272            available_at.as_deref().unwrap_or(""),
1273        )
1274        .await?;
1275    }
1276    if let Some(due_on) = update.due_on.as_ref() {
1277        changed |= update_task_field(
1278            conn,
1279            workspace,
1280            task_id,
1281            "due_on",
1282            due_on.as_deref().unwrap_or(""),
1283        )
1284        .await?;
1285    }
1286    if update.deleted.is_some() {
1287        let attachment_hashes: Vec<String> = sqlx::query_scalar(
1288            "SELECT DISTINCT sha256 FROM task_attachments
1289             WHERE workspace_id = ? AND task_id = ?",
1290        )
1291        .bind(&workspace.id)
1292        .bind(task_id)
1293        .fetch_all(&mut *conn)
1294        .await?;
1295        affected_attachment_hashes.extend(attachment_hashes);
1296    }
1297    if let Some(deleted) = update.deleted {
1298        changed |= update_task_field(
1299            conn,
1300            workspace,
1301            task_id,
1302            "deleted",
1303            if deleted { "1" } else { "0" },
1304        )
1305        .await?;
1306    }
1307    if let Some(is_epic) = update.is_epic {
1308        if !is_epic {
1309            let task = get_task_in_workspace(conn, workspace, task_id).await?;
1310            if super::epics::task_has_epic_children(conn, &task.workspace_id, task_id).await? {
1311                bail!("error epic-has-children task_id={task_id}");
1312            }
1313        }
1314        changed |= update_task_field(
1315            conn,
1316            workspace,
1317            task_id,
1318            "is_epic",
1319            if is_epic { "1" } else { "0" },
1320        )
1321        .await?;
1322    }
1323    changed |= update_task_labels_in_workspace(
1324        conn,
1325        &workspace.id,
1326        task_id,
1327        &update.add_labels,
1328        &update.remove_labels,
1329    )
1330    .await?;
1331    crate::metadata::validate_task_metadata_result(
1332        conn,
1333        &workspace.id,
1334        task_id,
1335        &update.set_metadata,
1336        &update.remove_metadata,
1337    )
1338    .await?;
1339    for input in &update.set_metadata {
1340        changed |= crate::metadata::set_task_metadata(conn, workspace, task_id, input).await?;
1341    }
1342    for key in &update.remove_metadata {
1343        changed |= crate::metadata::remove_task_metadata(conn, workspace, task_id, key).await?;
1344    }
1345    Ok((changed, created_labels))
1346}
1347
1348pub async fn update_task_field(
1349    conn: &mut SqliteConnection,
1350    workspace: &Workspace,
1351    task_id: &crate::ids::TaskId,
1352    field: &str,
1353    value: &str,
1354) -> Result<bool> {
1355    set_task_field(conn, workspace, task_id, field, value).await
1356}
1357
1358pub async fn update_task_labels_in_workspace(
1359    conn: &mut SqliteConnection,
1360    workspace_id: &WorkspaceId,
1361    task_id: &crate::ids::TaskId,
1362    add_labels: &[String],
1363    remove_labels: &[String],
1364) -> Result<bool> {
1365    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1366    crate::operations::route_recurrence_task_field(conn, &workspace, task_id, "labels", "").await?;
1367    let mut changed = false;
1368    for label in resolve_labels_in_workspace(conn, &workspace.id, add_labels).await? {
1369        let rows_affected = sqlx::query(
1370            "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
1371        )
1372        .bind(&workspace.id)
1373        .bind(task_id)
1374        .bind(&label)
1375        .execute(&mut *conn)
1376        .await?
1377        .rows_affected();
1378        if rows_affected > 0 {
1379            append_change(
1380                conn,
1381                ChangeEntity::Task,
1382                task_id,
1383                Some("labels"),
1384                op_type::LABEL_ADD,
1385                ChangePayload::workspace(&workspace).set("label", label),
1386            )
1387            .await?;
1388            changed = true;
1389        }
1390    }
1391    for label in resolve_labels_in_workspace(conn, &workspace.id, remove_labels).await? {
1392        let rows_affected = sqlx::query(
1393            "DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ? AND label = ?",
1394        )
1395        .bind(&workspace.id)
1396        .bind(task_id)
1397        .bind(&label)
1398        .execute(&mut *conn)
1399        .await?
1400        .rows_affected();
1401        if rows_affected > 0 {
1402            append_change(
1403                conn,
1404                ChangeEntity::Task,
1405                task_id,
1406                Some("labels"),
1407                op_type::LABEL_REMOVE,
1408                ChangePayload::workspace(&workspace).set("label", label),
1409            )
1410            .await?;
1411            changed = true;
1412        }
1413    }
1414    if changed {
1415        info!(
1416            task_id = %task_id,
1417            added = add_labels.len(),
1418            removed = remove_labels.len(),
1419            "task labels changed"
1420        );
1421    }
1422    Ok(changed)
1423}
1424
1425pub(super) async fn add_note_operation(
1426    conn: &mut SqliteConnection,
1427    workspace: &Workspace,
1428    task_id: &crate::ids::TaskId,
1429    body: String,
1430    tui_undo: bool,
1431) -> Result<NoteOutcome> {
1432    let note_id = new_id();
1433    let ts = now();
1434    let mut tx = begin_immediate(conn).await?;
1435    crate::operations::route_recurrence_task_field(&mut tx, workspace, task_id, "notes", "")
1436        .await?;
1437    let change_id = append_change(
1438        &mut tx,
1439        ChangeEntity::Task,
1440        task_id,
1441        Some("notes"),
1442        op_type::NOTE_ADD,
1443        ChangePayload::workspace(workspace)
1444            .set("note_id", &note_id)
1445            .set("body", &body)
1446            .set("created_at", &ts),
1447    )
1448    .await?;
1449    sqlx::query(
1450        "INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id) VALUES (?, ?, ?, ?, ?, ?)",
1451    )
1452    .bind(&workspace.id)
1453    .bind(&note_id)
1454    .bind(task_id)
1455    .bind(&body)
1456    .bind(&ts)
1457    .bind(&change_id)
1458    .execute(&mut *tx)
1459    .await?;
1460    sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1461        .bind(&ts)
1462        .bind(&workspace.id)
1463        .bind(task_id)
1464        .execute(&mut *tx)
1465        .await?;
1466    if tui_undo {
1467        record_tui_undo(
1468            &mut tx,
1469            &workspace.id,
1470            &format!("note {note_id}"),
1471            UndoPayload {
1472                commands: vec![UndoCommand::DeleteCreatedNote {
1473                    task_id: task_id.clone(),
1474                    note_id: note_id.clone(),
1475                    note_add_change_id: change_id.clone(),
1476                }],
1477            },
1478        )
1479        .await?;
1480    }
1481    tx.commit().await?;
1482    info!(task_id = %task_id, note_id = %note_id, "note added");
1483    Ok(NoteOutcome {
1484        task_id: task_id.clone(),
1485        note_id,
1486        change_id,
1487    })
1488}
1489
1490async fn edit_note_operation(
1491    conn: &mut SqliteConnection,
1492    workspace: &Workspace,
1493    task_id: &crate::ids::TaskId,
1494    note_id: &str,
1495    body: String,
1496    tui_undo: bool,
1497) -> Result<NoteEditOutcome> {
1498    let mut tx = begin_immediate(conn).await?;
1499    crate::operations::route_recurrence_task_field(&mut tx, workspace, task_id, "notes", "")
1500        .await?;
1501    let before = sqlx::query_scalar::<_, String>(
1502        "SELECT body FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1503    )
1504    .bind(&workspace.id)
1505    .bind(task_id)
1506    .bind(note_id)
1507    .fetch_optional(&mut *tx)
1508    .await?;
1509    let found = before.is_some();
1510    let changed = before.as_ref().is_some_and(|before| before != &body);
1511    if changed {
1512        let edited_at = now();
1513        sqlx::query("UPDATE notes SET body = ? WHERE workspace_id = ? AND task_id = ? AND id = ?")
1514            .bind(&body)
1515            .bind(&workspace.id)
1516            .bind(task_id)
1517            .bind(note_id)
1518            .execute(&mut *tx)
1519            .await?;
1520        sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1521            .bind(&edited_at)
1522            .bind(&workspace.id)
1523            .bind(task_id)
1524            .execute(&mut *tx)
1525            .await?;
1526        append_change(
1527            &mut tx,
1528            ChangeEntity::Task,
1529            task_id,
1530            Some("notes"),
1531            op_type::NOTE_EDIT,
1532            ChangePayload::workspace(workspace)
1533                .set("note_id", note_id)
1534                .set("body", &body)
1535                .set("edited_at", edited_at),
1536        )
1537        .await?;
1538        if tui_undo {
1539            record_tui_undo(
1540                &mut tx,
1541                &workspace.id,
1542                &format!("edit note {note_id}"),
1543                UndoPayload {
1544                    commands: vec![UndoCommand::SetNoteBody {
1545                        task_id: task_id.clone(),
1546                        note_id: note_id.to_string(),
1547                        before: before.expect("changed note has before body"),
1548                        after: body,
1549                    }],
1550                },
1551            )
1552            .await?;
1553        }
1554    }
1555    tx.commit().await?;
1556    if changed {
1557        info!(task_id = %task_id, note_id = %note_id, "note edited");
1558    }
1559    Ok(NoteEditOutcome {
1560        task_id: task_id.clone(),
1561        note_id: note_id.to_string(),
1562        found,
1563        changed,
1564    })
1565}
1566
1567async fn delete_note_operation(
1568    conn: &mut SqliteConnection,
1569    workspace: &Workspace,
1570    task_id: &crate::ids::TaskId,
1571    note_id: &str,
1572    tui_undo: bool,
1573) -> Result<NoteDeleteOutcome> {
1574    let mut tx = begin_immediate(conn).await?;
1575    crate::operations::route_recurrence_task_field(&mut tx, workspace, task_id, "notes", "")
1576        .await?;
1577    let before = sqlx::query_as::<_, (String, String)>(
1578        "SELECT body, created_at FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1579    )
1580    .bind(&workspace.id)
1581    .bind(task_id)
1582    .bind(note_id)
1583    .fetch_optional(&mut *tx)
1584    .await?;
1585    let deleted_at = now();
1586    let deleted =
1587        sqlx::query("DELETE FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?")
1588            .bind(&workspace.id)
1589            .bind(task_id)
1590            .bind(note_id)
1591            .execute(&mut *tx)
1592            .await?
1593            .rows_affected();
1594    if deleted > 0 {
1595        sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1596            .bind(&deleted_at)
1597            .bind(&workspace.id)
1598            .bind(task_id)
1599            .execute(&mut *tx)
1600            .await?;
1601        append_change(
1602            &mut tx,
1603            ChangeEntity::Task,
1604            task_id,
1605            Some("notes"),
1606            op_type::NOTE_DELETE,
1607            ChangePayload::workspace(workspace)
1608                .set("note_id", note_id)
1609                .set(
1610                    "body",
1611                    &before.as_ref().expect("deleted note has before state").0,
1612                )
1613                .set("deleted_at", deleted_at),
1614        )
1615        .await?;
1616        if tui_undo {
1617            let (body, created_at) = before.expect("deleted note has before state");
1618            record_tui_undo(
1619                &mut tx,
1620                &workspace.id,
1621                &format!("delete note {note_id}"),
1622                UndoPayload {
1623                    commands: vec![UndoCommand::RestoreDeletedNote {
1624                        task_id: task_id.clone(),
1625                        note_id: note_id.to_string(),
1626                        body,
1627                        created_at,
1628                    }],
1629                },
1630            )
1631            .await?;
1632        }
1633    }
1634    tx.commit().await?;
1635    if deleted > 0 {
1636        info!(task_id = %task_id, note_id = %note_id, "note deleted");
1637    }
1638    Ok(NoteDeleteOutcome {
1639        task_id: task_id.clone(),
1640        note_id: note_id.to_string(),
1641        changed: deleted > 0,
1642    })
1643}