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