Skip to main content

aven_core/operations/
tasks.rs

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