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 available_at = draft.available_at.as_deref().unwrap_or("");
1222    let due_on = draft.due_on.as_deref().unwrap_or("");
1223    let id = TaskId::new();
1224    let ts = now();
1225    let project = draft
1226        .project
1227        .as_deref()
1228        .ok_or_else(|| anyhow::anyhow!("error project-required"))?;
1229    let project = if require_existing_project {
1230        resolve_existing_project_in_workspace(conn, &workspace.id, project).await?
1231    } else {
1232        resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?
1233    };
1234    let (labels, created_labels) = if create_missing_labels {
1235        let resolution =
1236            resolve_or_create_labels_in_workspace(conn, workspace, &draft.labels).await?;
1237        (resolution.names, resolution.created)
1238    } else {
1239        (
1240            resolve_labels_in_workspace(conn, &workspace.id, &draft.labels).await?,
1241            Vec::new(),
1242        )
1243    };
1244    sqlx::query(
1245        "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)
1246         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1247    )
1248    .bind(&workspace.id)
1249    .bind(&id)
1250    .bind(&draft.title)
1251    .bind(&draft.description)
1252    .bind(&project.id)
1253    .bind(status.as_str())
1254    .bind(priority.as_str())
1255    .bind(draft.source.as_str())
1256    .bind(&ts)
1257    .bind(&ts)
1258    .bind(&ts)
1259    .bind(available_at)
1260    .bind(due_on)
1261    .bind(i64::from(draft.is_epic))
1262    .execute(&mut *conn)
1263    .await?;
1264    for label in &labels {
1265        sqlx::query(
1266            "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
1267        )
1268        .bind(&workspace.id)
1269        .bind(&id)
1270        .bind(label)
1271        .execute(&mut *conn)
1272        .await?;
1273    }
1274    let metadata =
1275        crate::metadata::insert_initial_task_metadata(conn, workspace, &id, &draft.metadata, &ts)
1276            .await?;
1277    let change_id = append_change(
1278        conn,
1279        ChangeEntity::Task,
1280        &id,
1281        None,
1282        op_type::CREATE_TASK,
1283        ChangePayload::workspace(workspace)
1284            .set("title", draft.title)
1285            .set("description", draft.description)
1286            .set("project_id", project.id.clone())
1287            .set("project_key", project.key.clone())
1288            .set("project_name", project.name.clone())
1289            .set("project_prefix", project.prefix.clone())
1290            .set("status", status.as_str())
1291            .set("priority", priority.as_str())
1292            .set("source", draft.source.as_str())
1293            .set("available_at", available_at)
1294            .set("due_on", due_on)
1295            .set("is_epic", if draft.is_epic { "1" } else { "0" })
1296            .set("labels", &labels)
1297            .set("metadata", &metadata)
1298            .set("created_at", ts),
1299    )
1300    .await?;
1301    for field in TaskField::VERSIONED {
1302        set_field_version(conn, &id, field.as_str(), &change_id).await?;
1303    }
1304    for value in &metadata {
1305        set_field_version(
1306            conn,
1307            &id,
1308            &format!("metadata:{}", value.field_id),
1309            &change_id,
1310        )
1311        .await?;
1312    }
1313    Ok(InsertedTask {
1314        id,
1315        change_id,
1316        project_key: project.key,
1317        label_count: labels.len(),
1318        created_labels,
1319    })
1320}
1321
1322async fn cleanup_attachment_guards(
1323    conn: &mut SqliteConnection,
1324    leases: &[String],
1325    reservations: &[String],
1326) {
1327    for lease_id in leases {
1328        if let Err(error) = crate::attachments::lifecycle::release_lease(conn, lease_id).await {
1329            warn!(%error, "failed to release attachment staging lease");
1330        }
1331    }
1332    for reservation_id in reservations {
1333        if let Err(error) =
1334            crate::attachments::lifecycle::release_reservation(conn, reservation_id).await
1335        {
1336            warn!(%error, "failed to release attachment capacity reservation");
1337        }
1338    }
1339}
1340
1341async fn cleanup_created_objects(conn: &mut SqliteConnection, blob_dir: &Path, hashes: &[String]) {
1342    for sha256 in hashes {
1343        crate::attachments::storage::remove_staged_blob_if_unreferenced(conn, blob_dir, sha256)
1344            .await;
1345    }
1346}
1347
1348#[cfg(any(test, feature = "test-support"))]
1349pub async fn update_task(
1350    conn: &mut SqliteConnection,
1351    workspace: &Workspace,
1352    task_id: &crate::ids::TaskId,
1353    update: TaskUpdate,
1354) -> Result<TaskUpdateOutcome> {
1355    validate_task_update(&update)?;
1356    let mut tx = begin_immediate(conn).await?;
1357    let mut affected_attachment_hashes = BTreeSet::new();
1358    let (changed, _) = apply_task_update(
1359        &mut tx,
1360        workspace,
1361        task_id,
1362        &update,
1363        &mut affected_attachment_hashes,
1364    )
1365    .await?;
1366    let affected_attachment_hashes = affected_attachment_hashes.into_iter().collect::<Vec<_>>();
1367    crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
1368        &mut tx,
1369        &affected_attachment_hashes,
1370        &crate::attachments::lifecycle::SystemClock,
1371    )
1372    .await?;
1373    tx.commit().await?;
1374    Ok(TaskUpdateOutcome {
1375        task: get_task_in_workspace(conn, workspace, task_id).await?,
1376        changed,
1377    })
1378}
1379
1380fn append_task_undo_commands(
1381    task_id: &TaskId,
1382    before_snapshot: &TaskUndoSnapshot,
1383    after_snapshot: &TaskUndoSnapshot,
1384    commands: &mut Vec<UndoCommand>,
1385) {
1386    let before = before_snapshot;
1387    let after = after_snapshot;
1388    let fields = [
1389        ("title", before.title.as_str(), after.title.as_str()),
1390        (
1391            "description",
1392            before.description.as_str(),
1393            after.description.as_str(),
1394        ),
1395        (
1396            "project",
1397            before.project_id.as_str(),
1398            after.project_id.as_str(),
1399        ),
1400        ("status", before.status.as_str(), after.status.as_str()),
1401        (
1402            "priority",
1403            before.priority.as_str(),
1404            after.priority.as_str(),
1405        ),
1406        (
1407            "available_at",
1408            before.available_at.as_str(),
1409            after.available_at.as_str(),
1410        ),
1411        ("due_on", before.due_on.as_str(), after.due_on.as_str()),
1412    ];
1413    for (field, before, after) in fields {
1414        if before != after {
1415            commands.push(UndoCommand::SetTaskField {
1416                task_id: task_id.clone(),
1417                field: field.to_string(),
1418                before: before.to_string(),
1419                after: after.to_string(),
1420                queue_activity_before: (before_snapshot.queue_activity_at
1421                    != after_snapshot.queue_activity_at)
1422                    .then(|| before_snapshot.queue_activity_at.clone()),
1423                queue_activity_after: (before_snapshot.queue_activity_at
1424                    != after_snapshot.queue_activity_at)
1425                    .then(|| after_snapshot.queue_activity_at.clone()),
1426            });
1427        }
1428    }
1429    let before_deleted = if before.deleted { "1" } else { "0" };
1430    let after_deleted = if after.deleted { "1" } else { "0" };
1431    if before_deleted != after_deleted {
1432        commands.push(UndoCommand::SetTaskField {
1433            task_id: task_id.clone(),
1434            field: "deleted".to_string(),
1435            before: before_deleted.to_string(),
1436            after: after_deleted.to_string(),
1437            queue_activity_before: (before.queue_activity_at != after.queue_activity_at)
1438                .then(|| before.queue_activity_at.clone()),
1439            queue_activity_after: (before.queue_activity_at != after.queue_activity_at)
1440                .then(|| after.queue_activity_at.clone()),
1441        });
1442    }
1443    let before_is_epic = if before.is_epic { "1" } else { "0" };
1444    let after_is_epic = if after.is_epic { "1" } else { "0" };
1445    if before_is_epic != after_is_epic {
1446        commands.push(UndoCommand::SetTaskField {
1447            task_id: task_id.clone(),
1448            field: "is_epic".to_string(),
1449            before: before_is_epic.to_string(),
1450            after: after_is_epic.to_string(),
1451            queue_activity_before: None,
1452            queue_activity_after: None,
1453        });
1454    }
1455    if before.labels != after.labels {
1456        commands.push(UndoCommand::SetTaskLabels {
1457            task_id: task_id.clone(),
1458            before: before.labels.clone(),
1459            after: after.labels.clone(),
1460        });
1461    }
1462    let metadata_fields = before
1463        .metadata
1464        .keys()
1465        .chain(after.metadata.keys())
1466        .collect::<BTreeSet<_>>();
1467    for field_id in metadata_fields {
1468        let before_value = before.metadata.get(field_id);
1469        let after_value = after.metadata.get(field_id);
1470        if before_value != after_value {
1471            commands.push(UndoCommand::SetTaskMetadata {
1472                task_id: task_id.clone(),
1473                field_id: field_id
1474                    .parse()
1475                    .expect("task snapshots contain valid metadata field IDs"),
1476                before: before_value.cloned(),
1477                after: after_value.cloned(),
1478            });
1479        }
1480    }
1481}
1482
1483fn cycled_priority(priority: TaskPriority, reverse: bool) -> TaskPriority {
1484    let index = TaskPriority::ALL
1485        .iter()
1486        .position(|candidate| *candidate == priority)
1487        .unwrap_or(0);
1488    let next = if reverse {
1489        (index + TaskPriority::ALL.len() - 1) % TaskPriority::ALL.len()
1490    } else {
1491        (index + 1) % TaskPriority::ALL.len()
1492    };
1493    TaskPriority::ALL[next]
1494}
1495
1496fn task_update_requires_snapshot(update: &TaskUpdate) -> bool {
1497    update.cycle_priority.is_some() || update.label_selection.is_some()
1498}
1499
1500fn materialize_task_update(update: &TaskUpdate, before: &TaskUndoSnapshot) -> Result<TaskUpdate> {
1501    let mut update = update.clone();
1502    if let Some(reverse) = update.cycle_priority.take() {
1503        let priority = TaskPriority::parse(&before.priority)?;
1504        update.priority = Some(cycled_priority(priority, reverse).to_string());
1505    }
1506    if let Some(selection) = update.label_selection.take() {
1507        let selected = selection.selected.iter().cloned().collect::<BTreeSet<_>>();
1508        let partial = selection.partial.iter().cloned().collect::<BTreeSet<_>>();
1509        update.add_labels = selection
1510            .selected
1511            .into_iter()
1512            .filter(|label| !before.labels.contains(label))
1513            .collect();
1514        update.remove_labels = before
1515            .labels
1516            .iter()
1517            .filter(|label| !selected.contains(label.as_str()) && !partial.contains(label.as_str()))
1518            .cloned()
1519            .collect();
1520    }
1521    Ok(update)
1522}
1523
1524fn validate_task_update(update: &TaskUpdate) -> Result<()> {
1525    crate::metadata::validate_metadata_update(&update.set_metadata, &update.remove_metadata)?;
1526    if update.priority.is_some() && update.cycle_priority.is_some() {
1527        bail!("error invalid-task-update priority and priority cycle are mutually exclusive");
1528    }
1529    if update.label_selection.is_some()
1530        && (!update.add_labels.is_empty() || !update.remove_labels.is_empty())
1531    {
1532        bail!("error invalid-task-update label selection and label deltas are mutually exclusive");
1533    }
1534    if let Some(status) = update.status.as_deref() {
1535        TaskStatus::parse(status)?;
1536    }
1537    if let Some(priority) = update.priority.as_deref() {
1538        TaskPriority::parse(priority)?;
1539    }
1540    if let Some(Some(available_at)) = update.available_at.as_ref() {
1541        crate::time_validation::validate_available_at_value(available_at)?;
1542    }
1543    if let Some(Some(due_on)) = update.due_on.as_ref() {
1544        crate::time_validation::validate_due_on_value(due_on)?;
1545    }
1546    Ok(())
1547}
1548
1549async fn apply_task_update(
1550    conn: &mut SqliteConnection,
1551    workspace: &Workspace,
1552    task_id: &crate::ids::TaskId,
1553    update: &TaskUpdate,
1554    affected_attachment_hashes: &mut BTreeSet<String>,
1555) -> Result<(bool, Vec<CreatedLabel>)> {
1556    let created_labels = if update.create_missing_labels {
1557        resolve_or_create_labels_in_workspace(conn, workspace, &update.add_labels)
1558            .await?
1559            .created
1560    } else {
1561        Vec::new()
1562    };
1563    let mut changed = false;
1564    if let Some(title) = update.title.as_deref() {
1565        changed |= update_task_field(conn, workspace, task_id, "title", title).await?;
1566    }
1567    if let Some(description) = update.description.as_deref() {
1568        changed |= update_task_field(conn, workspace, task_id, "description", description).await?;
1569    }
1570    if let Some(project) = update.project.as_deref() {
1571        let project = resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?;
1572        changed |= set_task_project(conn, workspace, task_id, &project).await?;
1573    }
1574    if let Some(status) = update.status.as_deref() {
1575        changed |= update_task_field(conn, workspace, task_id, "status", status).await?;
1576    }
1577    if let Some(priority) = update.priority.as_deref() {
1578        changed |= update_task_field(conn, workspace, task_id, "priority", priority).await?;
1579    }
1580    if let Some(available_at) = update.available_at.as_ref() {
1581        changed |= update_task_field(
1582            conn,
1583            workspace,
1584            task_id,
1585            "available_at",
1586            available_at.as_deref().unwrap_or(""),
1587        )
1588        .await?;
1589    }
1590    if let Some(due_on) = update.due_on.as_ref() {
1591        changed |= update_task_field(
1592            conn,
1593            workspace,
1594            task_id,
1595            "due_on",
1596            due_on.as_deref().unwrap_or(""),
1597        )
1598        .await?;
1599    }
1600    if update.deleted.is_some() {
1601        let attachment_hashes: Vec<String> = sqlx::query_scalar(
1602            "SELECT DISTINCT sha256 FROM task_attachments
1603             WHERE workspace_id = ? AND task_id = ?",
1604        )
1605        .bind(&workspace.id)
1606        .bind(task_id)
1607        .fetch_all(&mut *conn)
1608        .await?;
1609        affected_attachment_hashes.extend(attachment_hashes);
1610    }
1611    if let Some(deleted) = update.deleted {
1612        changed |= update_task_field(
1613            conn,
1614            workspace,
1615            task_id,
1616            "deleted",
1617            if deleted { "1" } else { "0" },
1618        )
1619        .await?;
1620    }
1621    if let Some(is_epic) = update.is_epic {
1622        if !is_epic {
1623            let task = get_task_in_workspace(conn, workspace, task_id).await?;
1624            if super::epics::task_has_epic_children(conn, &task.workspace_id, task_id).await? {
1625                bail!("error epic-has-children task_id={task_id}");
1626            }
1627        }
1628        changed |= update_task_field(
1629            conn,
1630            workspace,
1631            task_id,
1632            "is_epic",
1633            if is_epic { "1" } else { "0" },
1634        )
1635        .await?;
1636    }
1637    changed |= update_task_labels_in_workspace(
1638        conn,
1639        &workspace.id,
1640        task_id,
1641        &update.add_labels,
1642        &update.remove_labels,
1643    )
1644    .await?;
1645    for (id, key) in &update.require_metadata_fields {
1646        crate::metadata::require_metadata_field(conn, &workspace.id, id, key).await?;
1647    }
1648    crate::metadata::validate_task_metadata_result(
1649        conn,
1650        &workspace.id,
1651        task_id,
1652        &update.set_metadata,
1653        &update.remove_metadata,
1654    )
1655    .await?;
1656    for input in &update.set_metadata {
1657        changed |= crate::metadata::set_task_metadata(conn, workspace, task_id, input).await?;
1658    }
1659    for key in &update.remove_metadata {
1660        changed |= crate::metadata::remove_task_metadata(conn, workspace, task_id, key).await?;
1661    }
1662    Ok((changed, created_labels))
1663}
1664
1665pub async fn update_task_field(
1666    conn: &mut SqliteConnection,
1667    workspace: &Workspace,
1668    task_id: &crate::ids::TaskId,
1669    field: &str,
1670    value: &str,
1671) -> Result<bool> {
1672    set_task_field(conn, workspace, task_id, field, value).await
1673}
1674
1675pub async fn update_task_labels_in_workspace(
1676    conn: &mut SqliteConnection,
1677    workspace_id: &WorkspaceId,
1678    task_id: &crate::ids::TaskId,
1679    add_labels: &[String],
1680    remove_labels: &[String],
1681) -> Result<bool> {
1682    let mutation_at = now();
1683    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1684    crate::operations::route_recurrence_task_mutation(
1685        conn,
1686        &workspace,
1687        task_id,
1688        RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Labels),
1689        &mutation_at,
1690    )
1691    .await?;
1692    let mut changed = false;
1693    for label in resolve_labels_in_workspace(conn, &workspace.id, add_labels).await? {
1694        let rows_affected = sqlx::query(
1695            "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
1696        )
1697        .bind(&workspace.id)
1698        .bind(task_id)
1699        .bind(&label)
1700        .execute(&mut *conn)
1701        .await?
1702        .rows_affected();
1703        if rows_affected > 0 {
1704            append_change(
1705                conn,
1706                ChangeEntity::Task,
1707                task_id,
1708                Some("labels"),
1709                op_type::LABEL_ADD,
1710                ChangePayload::workspace(&workspace).set("label", label),
1711            )
1712            .await?;
1713            changed = true;
1714        }
1715    }
1716    for label in resolve_labels_in_workspace(conn, &workspace.id, remove_labels).await? {
1717        let rows_affected = sqlx::query(
1718            "DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ? AND label = ?",
1719        )
1720        .bind(&workspace.id)
1721        .bind(task_id)
1722        .bind(&label)
1723        .execute(&mut *conn)
1724        .await?
1725        .rows_affected();
1726        if rows_affected > 0 {
1727            append_change(
1728                conn,
1729                ChangeEntity::Task,
1730                task_id,
1731                Some("labels"),
1732                op_type::LABEL_REMOVE,
1733                ChangePayload::workspace(&workspace).set("label", label),
1734            )
1735            .await?;
1736            changed = true;
1737        }
1738    }
1739    if changed {
1740        info!(
1741            task_id = %task_id,
1742            added = add_labels.len(),
1743            removed = remove_labels.len(),
1744            "task labels changed"
1745        );
1746    }
1747    Ok(changed)
1748}
1749
1750pub(super) async fn add_note_operation(
1751    conn: &mut SqliteConnection,
1752    workspace: &Workspace,
1753    task_id: &crate::ids::TaskId,
1754    body: String,
1755    tui_undo: bool,
1756) -> Result<NoteOutcome> {
1757    let ts = now();
1758    let note_id = new_id();
1759    let mut tx = begin_immediate(conn).await?;
1760    crate::operations::route_recurrence_task_mutation(
1761        &mut tx,
1762        workspace,
1763        task_id,
1764        RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1765        &ts,
1766    )
1767    .await?;
1768    let change_id = append_change(
1769        &mut tx,
1770        ChangeEntity::Task,
1771        task_id,
1772        Some("notes"),
1773        op_type::NOTE_ADD,
1774        ChangePayload::workspace(workspace)
1775            .set("note_id", &note_id)
1776            .set("body", &body)
1777            .set("created_at", &ts),
1778    )
1779    .await?;
1780    sqlx::query(
1781        "INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id) VALUES (?, ?, ?, ?, ?, ?)",
1782    )
1783    .bind(&workspace.id)
1784    .bind(&note_id)
1785    .bind(task_id)
1786    .bind(&body)
1787    .bind(&ts)
1788    .bind(&change_id)
1789    .execute(&mut *tx)
1790    .await?;
1791    sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1792        .bind(&ts)
1793        .bind(&workspace.id)
1794        .bind(task_id)
1795        .execute(&mut *tx)
1796        .await?;
1797    if tui_undo {
1798        record_tui_undo(
1799            &mut tx,
1800            &workspace.id,
1801            &format!("note {note_id}"),
1802            UndoPayload {
1803                commands: vec![UndoCommand::DeleteCreatedNote {
1804                    task_id: task_id.clone(),
1805                    note_id: note_id.clone(),
1806                    note_add_change_id: change_id.clone(),
1807                    restoration_change_ids: Vec::new(),
1808                }],
1809            },
1810        )
1811        .await?;
1812    }
1813    tx.commit().await?;
1814    info!(task_id = %task_id, note_id = %note_id, "note added");
1815    Ok(NoteOutcome {
1816        task_id: task_id.clone(),
1817        note_id,
1818        change_id,
1819    })
1820}
1821
1822async fn edit_note_operation(
1823    conn: &mut SqliteConnection,
1824    workspace: &Workspace,
1825    task_id: &crate::ids::TaskId,
1826    note_id: &str,
1827    body: String,
1828    tui_undo: bool,
1829) -> Result<NoteEditOutcome> {
1830    let edited_at = now();
1831    let mut tx = begin_immediate(conn).await?;
1832    crate::operations::route_recurrence_task_mutation(
1833        &mut tx,
1834        workspace,
1835        task_id,
1836        RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1837        &edited_at,
1838    )
1839    .await?;
1840    let before = sqlx::query_scalar::<_, String>(
1841        "SELECT body FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1842    )
1843    .bind(&workspace.id)
1844    .bind(task_id)
1845    .bind(note_id)
1846    .fetch_optional(&mut *tx)
1847    .await?;
1848    let found = before.is_some();
1849    let changed = before.as_ref().is_some_and(|before| before != &body);
1850    if changed {
1851        sqlx::query("UPDATE notes SET body = ? WHERE workspace_id = ? AND task_id = ? AND id = ?")
1852            .bind(&body)
1853            .bind(&workspace.id)
1854            .bind(task_id)
1855            .bind(note_id)
1856            .execute(&mut *tx)
1857            .await?;
1858        sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1859            .bind(&edited_at)
1860            .bind(&workspace.id)
1861            .bind(task_id)
1862            .execute(&mut *tx)
1863            .await?;
1864        append_change(
1865            &mut tx,
1866            ChangeEntity::Task,
1867            task_id,
1868            Some("notes"),
1869            op_type::NOTE_EDIT,
1870            ChangePayload::workspace(workspace)
1871                .set("note_id", note_id)
1872                .set("body", &body)
1873                .set("edited_at", edited_at),
1874        )
1875        .await?;
1876        if tui_undo {
1877            record_tui_undo(
1878                &mut tx,
1879                &workspace.id,
1880                &format!("edit note {note_id}"),
1881                UndoPayload {
1882                    commands: vec![UndoCommand::SetNoteBody {
1883                        task_id: task_id.clone(),
1884                        note_id: note_id.to_string(),
1885                        before: before.expect("changed note has before body"),
1886                        after: body,
1887                    }],
1888                },
1889            )
1890            .await?;
1891        }
1892    }
1893    tx.commit().await?;
1894    if changed {
1895        info!(task_id = %task_id, note_id = %note_id, "note edited");
1896    }
1897    Ok(NoteEditOutcome {
1898        task_id: task_id.clone(),
1899        note_id: note_id.to_string(),
1900        found,
1901        changed,
1902    })
1903}
1904
1905async fn delete_note_operation(
1906    conn: &mut SqliteConnection,
1907    workspace: &Workspace,
1908    task_id: &crate::ids::TaskId,
1909    note_id: &str,
1910    tui_undo: bool,
1911) -> Result<NoteDeleteOutcome> {
1912    let deleted_at = now();
1913    let mut tx = begin_immediate(conn).await?;
1914    crate::operations::route_recurrence_task_mutation(
1915        &mut tx,
1916        workspace,
1917        task_id,
1918        RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1919        &deleted_at,
1920    )
1921    .await?;
1922    let before = sqlx::query_as::<_, (String, String, String)>(
1923        "SELECT body, created_at, change_id FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1924    )
1925    .bind(&workspace.id)
1926    .bind(task_id)
1927    .bind(note_id)
1928    .fetch_optional(&mut *tx)
1929    .await?;
1930    let deleted =
1931        sqlx::query("DELETE FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?")
1932            .bind(&workspace.id)
1933            .bind(task_id)
1934            .bind(note_id)
1935            .execute(&mut *tx)
1936            .await?
1937            .rows_affected();
1938    if deleted > 0 {
1939        sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1940            .bind(&deleted_at)
1941            .bind(&workspace.id)
1942            .bind(task_id)
1943            .execute(&mut *tx)
1944            .await?;
1945        append_change(
1946            &mut tx,
1947            ChangeEntity::Task,
1948            task_id,
1949            Some("notes"),
1950            op_type::NOTE_DELETE,
1951            ChangePayload::workspace(workspace)
1952                .set("note_id", note_id)
1953                .set(
1954                    "body",
1955                    &before.as_ref().expect("deleted note has before state").0,
1956                )
1957                .set("deleted_at", deleted_at),
1958        )
1959        .await?;
1960        if tui_undo {
1961            let (body, created_at, note_add_change_id) =
1962                before.expect("deleted note has before state");
1963            record_tui_undo(
1964                &mut tx,
1965                &workspace.id,
1966                &format!("delete note {note_id}"),
1967                UndoPayload {
1968                    commands: vec![UndoCommand::RestoreDeletedNote {
1969                        task_id: task_id.clone(),
1970                        note_id: note_id.to_string(),
1971                        body,
1972                        created_at,
1973                        note_add_change_id,
1974                    }],
1975                },
1976            )
1977            .await?;
1978        }
1979    }
1980    tx.commit().await?;
1981    if deleted > 0 {
1982        info!(task_id = %task_id, note_id = %note_id, "note deleted");
1983    }
1984    Ok(NoteDeleteOutcome {
1985        task_id: task_id.clone(),
1986        note_id: note_id.to_string(),
1987        changed: deleted > 0,
1988    })
1989}