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