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