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", ¬e_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(¬e_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}