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