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