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 affected_attachment_hashes = BTreeSet::new();
431 let mut undo_commands = Vec::new();
432 let mut created_labels = Vec::new();
433 for (task_id, update) in &updates {
434 let before =
435 if detail == TaskMutationDetail::Report || task_update_requires_snapshot(update) {
436 Some(task_snapshot(&mut tx, &workspace.id, task_id).await?)
437 } else {
438 None
439 };
440 let update = match before.as_ref() {
441 Some(before) => materialize_task_update(update, before)?,
442 None => update.clone(),
443 };
444 let (changed, task_created_labels) = apply_task_update(
445 &mut tx,
446 workspace,
447 task_id,
448 &update,
449 &mut affected_attachment_hashes,
450 )
451 .await?;
452 created_labels.extend(task_created_labels);
453 let task = get_task_in_workspace(&mut tx, workspace, task_id).await?;
454 let after = if detail == TaskMutationDetail::Report {
455 Some(task_snapshot(&mut tx, &workspace.id, task_id).await?)
456 } else {
457 None
458 };
459 if let (Some(before), Some(after)) = (&before, &after) {
460 append_task_undo_commands(task_id, before, after, &mut undo_commands);
461 }
462 outcomes.push(TaskMutationExecutionOutcome {
463 task,
464 changed,
465 before,
466 after,
467 });
468 }
469 let affected_attachment_hashes = affected_attachment_hashes.into_iter().collect::<Vec<_>>();
470 crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
471 &mut tx,
472 &affected_attachment_hashes,
473 &crate::attachments::lifecycle::SystemClock,
474 )
475 .await?;
476 let changed_count = outcomes.iter().filter(|outcome| outcome.changed).count();
477 append_created_label_undo_commands(&mut undo_commands, &created_labels);
478 if let Some(summary) = undo.task_mutation_summary(changed_count) {
479 record_tui_undo(
480 &mut tx,
481 &workspace.id,
482 &summary,
483 UndoPayload {
484 commands: undo_commands,
485 },
486 )
487 .await?;
488 }
489 tx.commit().await?;
490
491 for outcome in &outcomes {
492 info!(task_id = %outcome.task.id, changed = outcome.changed, "task updated");
493 }
494 Ok(outcomes)
495 }
496
497 pub async fn set_task_deleted(
498 &self,
499 workspace: &Workspace,
500 task_id: &TaskId,
501 deleted: bool,
502 ) -> Result<TaskOutcome> {
503 let outcome = self
504 .update_task(
505 workspace,
506 task_id,
507 TaskUpdate {
508 deleted: Some(deleted),
509 ..TaskUpdate::default()
510 },
511 )
512 .await?;
513 Ok(TaskOutcome {
514 task: outcome.task,
515 create_change_id: None,
516 attachment_change_ids: Vec::new(),
517 })
518 }
519
520 pub async fn add_note(
521 &self,
522 workspace: &Workspace,
523 task_id: &TaskId,
524 body: String,
525 ) -> Result<NoteOutcome> {
526 let mut conn = self.acquire_writer().await?;
527 add_note_operation(&mut conn, workspace, task_id, body, false).await
528 }
529
530 pub async fn add_note_with_tui_undo(
531 &self,
532 workspace: &Workspace,
533 task_id: &TaskId,
534 body: String,
535 ) -> Result<NoteOutcome> {
536 let mut conn = self.acquire_writer().await?;
537 add_note_operation(&mut conn, workspace, task_id, body, true).await
538 }
539
540 pub async fn edit_note(
541 &self,
542 workspace: &Workspace,
543 task_id: &TaskId,
544 note_id: &str,
545 body: String,
546 ) -> Result<NoteEditOutcome> {
547 let mut conn = self.acquire_writer().await?;
548 edit_note_operation(&mut conn, workspace, task_id, note_id, body, false).await
549 }
550
551 pub async fn edit_note_with_tui_undo(
552 &self,
553 workspace: &Workspace,
554 task_id: &TaskId,
555 note_id: &str,
556 body: String,
557 ) -> Result<NoteEditOutcome> {
558 let mut conn = self.acquire_writer().await?;
559 edit_note_operation(&mut conn, workspace, task_id, note_id, body, true).await
560 }
561
562 pub async fn delete_note(
563 &self,
564 workspace: &Workspace,
565 task_id: &TaskId,
566 note_id: &str,
567 ) -> Result<NoteDeleteOutcome> {
568 let mut conn = self.acquire_writer().await?;
569 delete_note_operation(&mut conn, workspace, task_id, note_id, false).await
570 }
571
572 pub async fn delete_note_with_tui_undo(
573 &self,
574 workspace: &Workspace,
575 task_id: &TaskId,
576 note_id: &str,
577 ) -> Result<NoteDeleteOutcome> {
578 let mut conn = self.acquire_writer().await?;
579 delete_note_operation(&mut conn, workspace, task_id, note_id, true).await
580 }
581}
582
583#[cfg(any(test, feature = "test-support"))]
584pub async fn create_task(
585 conn: &mut SqliteConnection,
586 workspace: &Workspace,
587 draft: TaskDraft,
588) -> Result<TaskOutcome> {
589 create_task_with_epic(
590 conn,
591 workspace,
592 draft,
593 TaskCreationOptions::standalone(TaskCreationUndo::None),
594 )
595 .await
596}
597
598async fn create_task_with_epic(
599 conn: &mut SqliteConnection,
600 workspace: &Workspace,
601 draft: TaskDraft,
602 options: TaskCreationOptions,
603) -> Result<TaskOutcome> {
604 validate_task_draft(&draft)?;
605 let TaskCreationOptions {
606 epic_id,
607 undo,
608 create_missing_labels,
609 } = options;
610 let mut tx = begin_immediate(conn).await?;
611 let inserted = insert_task(&mut tx, workspace, draft, create_missing_labels).await?;
612 if let Some(epic_id) = epic_id {
613 super::add_task_to_epic_in_transaction(&mut tx, workspace, &inserted.id, &epic_id).await?;
614 }
615 let task = get_task_in_workspace(&mut tx, workspace, &inserted.id).await?;
616 record_task_creation_undo(
617 &mut tx,
618 workspace,
619 &task,
620 &inserted,
621 Vec::new(),
622 Vec::new(),
623 undo,
624 )
625 .await?;
626 tx.commit().await?;
627 info!(
628 task_id = %inserted.id,
629 project_key = %inserted.project_key,
630 label_count = inserted.label_count,
631 "task created"
632 );
633 Ok(TaskOutcome {
634 task,
635 create_change_id: Some(inserted.change_id),
636 attachment_change_ids: Vec::new(),
637 })
638}
639
640async fn create_task_with_attachments_and_epic(
641 conn: &mut SqliteConnection,
642 workspace: &Workspace,
643 blob_dir: &Path,
644 lifecycle_policy: crate::attachments::lifecycle::LifecyclePolicy,
645 draft: TaskDraft,
646 attachments: Vec<super::attachments::TaskAttachmentAddInput>,
647 options: TaskCreationOptions,
648) -> Result<TaskOutcome> {
649 validate_task_draft(&draft)?;
650 let TaskCreationOptions {
651 epic_id,
652 undo,
653 create_missing_labels,
654 } = options;
655 let mut prepared = Vec::with_capacity(attachments.len());
656 for attachment in attachments {
657 prepared.push(super::attachments::prepare_task_attachment(attachment).await?);
658 }
659
660 let mut unique = BTreeMap::new();
661 for attachment in &prepared {
662 unique
663 .entry(attachment.sha256.clone())
664 .or_insert_with(|| attachment.clone());
665 }
666 let attachment_hashes = unique.keys().cloned().collect::<Vec<_>>();
667
668 let mut capacity_reservations = Vec::new();
669 for attachment in unique.values() {
670 let available: bool = sqlx::query_scalar(
671 "SELECT EXISTS(SELECT 1 FROM blob_inventory WHERE sha256 = ? AND available = 1)",
672 )
673 .bind(&attachment.sha256)
674 .fetch_one(&mut *conn)
675 .await?;
676 if available {
677 continue;
678 }
679 match crate::attachments::lifecycle::ensure_local_capacity(
680 conn,
681 blob_dir,
682 &attachment.sha256,
683 attachment.byte_size,
684 lifecycle_policy,
685 &crate::attachments::lifecycle::SystemClock,
686 )
687 .await
688 {
689 Ok(Some(reservation_id)) => capacity_reservations.push(reservation_id),
690 Ok(None) => {}
691 Err(error) => {
692 for reservation_id in capacity_reservations {
693 let _ =
694 crate::attachments::lifecycle::release_reservation(conn, &reservation_id)
695 .await;
696 }
697 return Err(error);
698 }
699 }
700 }
701
702 let mut staging_leases = Vec::with_capacity(unique.len());
703 for attachment in unique.values() {
704 match crate::attachments::lifecycle::acquire_lease(
705 conn,
706 &attachment.sha256,
707 "staging",
708 &crate::attachments::lifecycle::SystemClock,
709 )
710 .await
711 {
712 Ok(lease_id) => staging_leases.push(lease_id),
713 Err(error) => {
714 for lease_id in staging_leases {
715 let _ = crate::attachments::lifecycle::release_lease(conn, &lease_id).await;
716 }
717 for reservation_id in capacity_reservations {
718 let _ =
719 crate::attachments::lifecycle::release_reservation(conn, &reservation_id)
720 .await;
721 }
722 return Err(error);
723 }
724 }
725 }
726
727 let mut created_hashes = Vec::new();
728 for attachment in unique.values() {
729 match crate::attachments::storage::stage_blob(
730 blob_dir,
731 &attachment.sha256,
732 &attachment.bytes,
733 )
734 .await
735 {
736 Ok(staged) if staged.byte_size == attachment.byte_size => {
737 if staged.created {
738 created_hashes.push(staged.sha256);
739 }
740 }
741 Ok(_) => {
742 cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
743 cleanup_created_objects(conn, blob_dir, &created_hashes).await;
744 bail!("error attachment-staged-size-mismatch");
745 }
746 Err(error) => {
747 cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
748 cleanup_created_objects(conn, blob_dir, &created_hashes).await;
749 return Err(error);
750 }
751 }
752 }
753
754 let database_result = async {
755 let mut tx = begin_immediate(conn).await?;
756 for attachment in unique.values() {
757 crate::attachments::storage::upsert_inventory_available(
758 &mut tx,
759 &attachment.sha256,
760 attachment.byte_size,
761 &attachment.facts.media_type,
762 )
763 .await?;
764 }
765 let inserted = insert_task(&mut tx, workspace, draft, create_missing_labels).await?;
766 if let Some(epic_id) = epic_id.as_ref() {
767 super::add_task_to_epic_in_transaction(&mut tx, workspace, &inserted.id, epic_id)
768 .await?;
769 }
770 let mut attachment_change_ids = Vec::with_capacity(prepared.len());
771 let attachment_base = crate::ids::now_utc();
772 for (index, attachment) in prepared.iter().enumerate() {
773 let created_at = (attachment_base
774 + chrono::TimeDelta::microseconds(i64::try_from(index)?))
775 .to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
776 attachment_change_ids.push(
777 super::attachments::insert_prepared_attachment(
778 &mut tx,
779 workspace,
780 &inserted.id,
781 attachment,
782 &created_at,
783 )
784 .await?,
785 );
786 }
787 crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
788 &mut tx,
789 &attachment_hashes,
790 &crate::attachments::lifecycle::SystemClock,
791 )
792 .await?;
793 let task = get_task_in_workspace(&mut tx, workspace, &inserted.id).await?;
794 let attachment_ids = prepared
795 .iter()
796 .map(|attachment| attachment.attachment_id.clone())
797 .collect();
798 record_task_creation_undo(
799 &mut tx,
800 workspace,
801 &task,
802 &inserted,
803 attachment_ids,
804 attachment_change_ids.clone(),
805 undo,
806 )
807 .await?;
808 tx.commit().await?;
809 Ok::<_, anyhow::Error>((inserted, attachment_change_ids, task))
810 }
811 .await;
812
813 let (inserted, attachment_change_ids, task) = match database_result {
814 Ok(value) => value,
815 Err(error) => {
816 cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
817 cleanup_created_objects(conn, blob_dir, &created_hashes).await;
818 return Err(error);
819 }
820 };
821 cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
822 info!(
823 task_id = %inserted.id,
824 project_key = %inserted.project_key,
825 label_count = inserted.label_count,
826 attachment_count = prepared.len(),
827 "task created"
828 );
829 Ok(TaskOutcome {
830 task,
831 create_change_id: Some(inserted.change_id),
832 attachment_change_ids,
833 })
834}
835
836async fn record_task_creation_undo(
837 conn: &mut SqliteConnection,
838 workspace: &Workspace,
839 task: &Task,
840 inserted: &InsertedTask,
841 attachment_ids: Vec<String>,
842 attachment_change_ids: Vec<String>,
843 undo: TaskCreationUndo,
844) -> Result<()> {
845 let (summary, commands) = match undo {
846 TaskCreationUndo::None => return Ok(()),
847 TaskCreationUndo::TuiTask => {
848 let mut commands = vec![UndoCommand::DeleteCreatedTask {
849 task_id: task.id.clone(),
850 create_change_id: Some(inserted.change_id.clone()),
851 expected: task_snapshot(conn, &workspace.id, &task.id).await?,
852 attachment_ids,
853 attachment_change_ids,
854 }];
855 append_created_label_undo_commands(&mut commands, &inserted.created_labels);
856 (format!("task {}", task.id), commands)
857 }
858 TaskCreationUndo::TuiEpicChild {
859 epic_id,
860 epic_display_ref,
861 } => {
862 let display_refs = DisplayRefContext::for_workspace(conn, &workspace.id).await?;
863 let child_ref = display_refs.display_ref(task);
864 (
865 format!("add {child_ref} to {epic_display_ref}"),
866 vec![UndoCommand::AddEpicChild {
867 epic_id,
868 child_id: task.id.clone(),
869 }],
870 )
871 }
872 };
873 record_tui_undo(conn, &workspace.id, &summary, UndoPayload { commands }).await
874}
875
876fn append_created_label_undo_commands(
877 commands: &mut Vec<UndoCommand>,
878 created_labels: &[CreatedLabel],
879) {
880 commands.extend(
881 created_labels
882 .iter()
883 .map(|label| UndoCommand::DeleteCreatedLabel {
884 label: label.name.clone(),
885 create_change_id: label.change_id.clone(),
886 }),
887 );
888}
889
890fn validate_task_draft(draft: &TaskDraft) -> Result<()> {
891 TaskStatus::parse(&draft.status)?;
892 TaskPriority::parse(&draft.priority)?;
893 crate::metadata::validate_metadata_update(&draft.metadata, &[])?;
894 if let Some(available_at) = draft.available_at.as_deref() {
895 crate::time_validation::validate_available_at_value(available_at)?;
896 }
897 if let Some(due_on) = draft.due_on.as_deref() {
898 crate::time_validation::validate_due_on_value(due_on)?;
899 }
900 Ok(())
901}
902
903async fn insert_task(
904 conn: &mut SqliteConnection,
905 workspace: &Workspace,
906 draft: TaskDraft,
907 create_missing_labels: bool,
908) -> Result<InsertedTask> {
909 let status = TaskStatus::parse(&draft.status)?;
910 let priority = TaskPriority::parse(&draft.priority)?;
911 let available_at = draft.available_at.as_deref().unwrap_or("");
912 let due_on = draft.due_on.as_deref().unwrap_or("");
913 let id = TaskId::new();
914 let ts = now();
915 let project = draft
916 .project
917 .as_deref()
918 .ok_or_else(|| anyhow::anyhow!("error project-required"))?;
919 let project = resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?;
920 let (labels, created_labels) = if create_missing_labels {
921 let resolution =
922 resolve_or_create_labels_in_workspace(conn, workspace, &draft.labels).await?;
923 (resolution.names, resolution.created)
924 } else {
925 (
926 resolve_labels_in_workspace(conn, &workspace.id, &draft.labels).await?,
927 Vec::new(),
928 )
929 };
930 sqlx::query(
931 "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)
932 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
933 )
934 .bind(&workspace.id)
935 .bind(&id)
936 .bind(&draft.title)
937 .bind(&draft.description)
938 .bind(&project.id)
939 .bind(status.as_str())
940 .bind(priority.as_str())
941 .bind(draft.source.as_str())
942 .bind(&ts)
943 .bind(&ts)
944 .bind(&ts)
945 .bind(available_at)
946 .bind(due_on)
947 .bind(i64::from(draft.is_epic))
948 .execute(&mut *conn)
949 .await?;
950 for label in &labels {
951 sqlx::query(
952 "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
953 )
954 .bind(&workspace.id)
955 .bind(&id)
956 .bind(label)
957 .execute(&mut *conn)
958 .await?;
959 }
960 let metadata =
961 crate::metadata::insert_initial_task_metadata(conn, workspace, &id, &draft.metadata, &ts)
962 .await?;
963 let change_id = append_change(
964 conn,
965 ChangeEntity::Task,
966 &id,
967 None,
968 op_type::CREATE_TASK,
969 ChangePayload::workspace(workspace)
970 .set("title", draft.title)
971 .set("description", draft.description)
972 .set("project_id", project.id.clone())
973 .set("project_key", project.key.clone())
974 .set("project_name", project.name.clone())
975 .set("project_prefix", project.prefix.clone())
976 .set("status", status.as_str())
977 .set("priority", priority.as_str())
978 .set("source", draft.source.as_str())
979 .set("available_at", available_at)
980 .set("due_on", due_on)
981 .set("is_epic", if draft.is_epic { "1" } else { "0" })
982 .set("labels", &labels)
983 .set("metadata", &metadata)
984 .set("created_at", ts),
985 )
986 .await?;
987 for field in TaskField::VERSIONED {
988 set_field_version(conn, &id, field.as_str(), &change_id).await?;
989 }
990 for value in &metadata {
991 set_field_version(
992 conn,
993 &id,
994 &format!("metadata:{}", value.field_id),
995 &change_id,
996 )
997 .await?;
998 }
999 Ok(InsertedTask {
1000 id,
1001 change_id,
1002 project_key: project.key,
1003 label_count: labels.len(),
1004 created_labels,
1005 })
1006}
1007
1008async fn cleanup_attachment_guards(
1009 conn: &mut SqliteConnection,
1010 leases: &[String],
1011 reservations: &[String],
1012) {
1013 for lease_id in leases {
1014 if let Err(error) = crate::attachments::lifecycle::release_lease(conn, lease_id).await {
1015 warn!(%error, "failed to release attachment staging lease");
1016 }
1017 }
1018 for reservation_id in reservations {
1019 if let Err(error) =
1020 crate::attachments::lifecycle::release_reservation(conn, reservation_id).await
1021 {
1022 warn!(%error, "failed to release attachment capacity reservation");
1023 }
1024 }
1025}
1026
1027async fn cleanup_created_objects(conn: &mut SqliteConnection, blob_dir: &Path, hashes: &[String]) {
1028 for sha256 in hashes {
1029 crate::attachments::storage::remove_staged_blob_if_unreferenced(conn, blob_dir, sha256)
1030 .await;
1031 }
1032}
1033
1034#[cfg(any(test, feature = "test-support"))]
1035pub async fn update_task(
1036 conn: &mut SqliteConnection,
1037 workspace: &Workspace,
1038 task_id: &crate::ids::TaskId,
1039 update: TaskUpdate,
1040) -> Result<TaskUpdateOutcome> {
1041 validate_task_update(&update)?;
1042 let mut tx = begin_immediate(conn).await?;
1043 let mut affected_attachment_hashes = BTreeSet::new();
1044 let (changed, _) = apply_task_update(
1045 &mut tx,
1046 workspace,
1047 task_id,
1048 &update,
1049 &mut affected_attachment_hashes,
1050 )
1051 .await?;
1052 let affected_attachment_hashes = affected_attachment_hashes.into_iter().collect::<Vec<_>>();
1053 crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
1054 &mut tx,
1055 &affected_attachment_hashes,
1056 &crate::attachments::lifecycle::SystemClock,
1057 )
1058 .await?;
1059 tx.commit().await?;
1060 Ok(TaskUpdateOutcome {
1061 task: get_task_in_workspace(conn, workspace, task_id).await?,
1062 changed,
1063 })
1064}
1065
1066fn append_task_undo_commands(
1067 task_id: &TaskId,
1068 before_snapshot: &TaskUndoSnapshot,
1069 after_snapshot: &TaskUndoSnapshot,
1070 commands: &mut Vec<UndoCommand>,
1071) {
1072 let before = before_snapshot;
1073 let after = after_snapshot;
1074 let fields = [
1075 ("title", before.title.as_str(), after.title.as_str()),
1076 (
1077 "description",
1078 before.description.as_str(),
1079 after.description.as_str(),
1080 ),
1081 (
1082 "project",
1083 before.project_id.as_str(),
1084 after.project_id.as_str(),
1085 ),
1086 ("status", before.status.as_str(), after.status.as_str()),
1087 (
1088 "priority",
1089 before.priority.as_str(),
1090 after.priority.as_str(),
1091 ),
1092 (
1093 "available_at",
1094 before.available_at.as_str(),
1095 after.available_at.as_str(),
1096 ),
1097 ("due_on", before.due_on.as_str(), after.due_on.as_str()),
1098 ];
1099 for (field, before, after) in fields {
1100 if before != after {
1101 commands.push(UndoCommand::SetTaskField {
1102 task_id: task_id.clone(),
1103 field: field.to_string(),
1104 before: before.to_string(),
1105 after: after.to_string(),
1106 queue_activity_before: (before_snapshot.queue_activity_at
1107 != after_snapshot.queue_activity_at)
1108 .then(|| before_snapshot.queue_activity_at.clone()),
1109 queue_activity_after: (before_snapshot.queue_activity_at
1110 != after_snapshot.queue_activity_at)
1111 .then(|| after_snapshot.queue_activity_at.clone()),
1112 });
1113 }
1114 }
1115 let before_deleted = if before.deleted { "1" } else { "0" };
1116 let after_deleted = if after.deleted { "1" } else { "0" };
1117 if before_deleted != after_deleted {
1118 commands.push(UndoCommand::SetTaskField {
1119 task_id: task_id.clone(),
1120 field: "deleted".to_string(),
1121 before: before_deleted.to_string(),
1122 after: after_deleted.to_string(),
1123 queue_activity_before: (before.queue_activity_at != after.queue_activity_at)
1124 .then(|| before.queue_activity_at.clone()),
1125 queue_activity_after: (before.queue_activity_at != after.queue_activity_at)
1126 .then(|| after.queue_activity_at.clone()),
1127 });
1128 }
1129 let before_is_epic = if before.is_epic { "1" } else { "0" };
1130 let after_is_epic = if after.is_epic { "1" } else { "0" };
1131 if before_is_epic != after_is_epic {
1132 commands.push(UndoCommand::SetTaskField {
1133 task_id: task_id.clone(),
1134 field: "is_epic".to_string(),
1135 before: before_is_epic.to_string(),
1136 after: after_is_epic.to_string(),
1137 queue_activity_before: None,
1138 queue_activity_after: None,
1139 });
1140 }
1141 if before.labels != after.labels {
1142 commands.push(UndoCommand::SetTaskLabels {
1143 task_id: task_id.clone(),
1144 before: before.labels.clone(),
1145 after: after.labels.clone(),
1146 });
1147 }
1148 let metadata_fields = before
1149 .metadata
1150 .keys()
1151 .chain(after.metadata.keys())
1152 .collect::<BTreeSet<_>>();
1153 for field_id in metadata_fields {
1154 let before_value = before.metadata.get(field_id);
1155 let after_value = after.metadata.get(field_id);
1156 if before_value != after_value {
1157 commands.push(UndoCommand::SetTaskMetadata {
1158 task_id: task_id.clone(),
1159 field_id: field_id
1160 .parse()
1161 .expect("task snapshots contain valid metadata field IDs"),
1162 before: before_value.cloned(),
1163 after: after_value.cloned(),
1164 });
1165 }
1166 }
1167}
1168
1169fn cycled_priority(priority: TaskPriority, reverse: bool) -> TaskPriority {
1170 let index = TaskPriority::ALL
1171 .iter()
1172 .position(|candidate| *candidate == priority)
1173 .unwrap_or(0);
1174 let next = if reverse {
1175 (index + TaskPriority::ALL.len() - 1) % TaskPriority::ALL.len()
1176 } else {
1177 (index + 1) % TaskPriority::ALL.len()
1178 };
1179 TaskPriority::ALL[next]
1180}
1181
1182fn task_update_requires_snapshot(update: &TaskUpdate) -> bool {
1183 update.cycle_priority.is_some() || update.label_selection.is_some()
1184}
1185
1186fn materialize_task_update(update: &TaskUpdate, before: &TaskUndoSnapshot) -> Result<TaskUpdate> {
1187 let mut update = update.clone();
1188 if let Some(reverse) = update.cycle_priority.take() {
1189 let priority = TaskPriority::parse(&before.priority)?;
1190 update.priority = Some(cycled_priority(priority, reverse).to_string());
1191 }
1192 if let Some(selection) = update.label_selection.take() {
1193 let selected = selection.selected.iter().cloned().collect::<BTreeSet<_>>();
1194 let partial = selection.partial.iter().cloned().collect::<BTreeSet<_>>();
1195 update.add_labels = selection
1196 .selected
1197 .into_iter()
1198 .filter(|label| !before.labels.contains(label))
1199 .collect();
1200 update.remove_labels = before
1201 .labels
1202 .iter()
1203 .filter(|label| !selected.contains(label.as_str()) && !partial.contains(label.as_str()))
1204 .cloned()
1205 .collect();
1206 }
1207 Ok(update)
1208}
1209
1210fn validate_task_update(update: &TaskUpdate) -> Result<()> {
1211 crate::metadata::validate_metadata_update(&update.set_metadata, &update.remove_metadata)?;
1212 if update.priority.is_some() && update.cycle_priority.is_some() {
1213 bail!("error invalid-task-update priority and priority cycle are mutually exclusive");
1214 }
1215 if update.label_selection.is_some()
1216 && (!update.add_labels.is_empty() || !update.remove_labels.is_empty())
1217 {
1218 bail!("error invalid-task-update label selection and label deltas are mutually exclusive");
1219 }
1220 if let Some(status) = update.status.as_deref() {
1221 TaskStatus::parse(status)?;
1222 }
1223 if let Some(priority) = update.priority.as_deref() {
1224 TaskPriority::parse(priority)?;
1225 }
1226 if let Some(Some(available_at)) = update.available_at.as_ref() {
1227 crate::time_validation::validate_available_at_value(available_at)?;
1228 }
1229 if let Some(Some(due_on)) = update.due_on.as_ref() {
1230 crate::time_validation::validate_due_on_value(due_on)?;
1231 }
1232 Ok(())
1233}
1234
1235async fn apply_task_update(
1236 conn: &mut SqliteConnection,
1237 workspace: &Workspace,
1238 task_id: &crate::ids::TaskId,
1239 update: &TaskUpdate,
1240 affected_attachment_hashes: &mut BTreeSet<String>,
1241) -> Result<(bool, Vec<CreatedLabel>)> {
1242 let created_labels = if update.create_missing_labels {
1243 resolve_or_create_labels_in_workspace(conn, workspace, &update.add_labels)
1244 .await?
1245 .created
1246 } else {
1247 Vec::new()
1248 };
1249 let mut changed = false;
1250 if let Some(title) = update.title.as_deref() {
1251 changed |= update_task_field(conn, workspace, task_id, "title", title).await?;
1252 }
1253 if let Some(description) = update.description.as_deref() {
1254 changed |= update_task_field(conn, workspace, task_id, "description", description).await?;
1255 }
1256 if let Some(project) = update.project.as_deref() {
1257 let project = resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?;
1258 changed |= set_task_project(conn, workspace, task_id, &project).await?;
1259 }
1260 if let Some(status) = update.status.as_deref() {
1261 changed |= update_task_field(conn, workspace, task_id, "status", status).await?;
1262 }
1263 if let Some(priority) = update.priority.as_deref() {
1264 changed |= update_task_field(conn, workspace, task_id, "priority", priority).await?;
1265 }
1266 if let Some(available_at) = update.available_at.as_ref() {
1267 changed |= update_task_field(
1268 conn,
1269 workspace,
1270 task_id,
1271 "available_at",
1272 available_at.as_deref().unwrap_or(""),
1273 )
1274 .await?;
1275 }
1276 if let Some(due_on) = update.due_on.as_ref() {
1277 changed |= update_task_field(
1278 conn,
1279 workspace,
1280 task_id,
1281 "due_on",
1282 due_on.as_deref().unwrap_or(""),
1283 )
1284 .await?;
1285 }
1286 if update.deleted.is_some() {
1287 let attachment_hashes: Vec<String> = sqlx::query_scalar(
1288 "SELECT DISTINCT sha256 FROM task_attachments
1289 WHERE workspace_id = ? AND task_id = ?",
1290 )
1291 .bind(&workspace.id)
1292 .bind(task_id)
1293 .fetch_all(&mut *conn)
1294 .await?;
1295 affected_attachment_hashes.extend(attachment_hashes);
1296 }
1297 if let Some(deleted) = update.deleted {
1298 changed |= update_task_field(
1299 conn,
1300 workspace,
1301 task_id,
1302 "deleted",
1303 if deleted { "1" } else { "0" },
1304 )
1305 .await?;
1306 }
1307 if let Some(is_epic) = update.is_epic {
1308 if !is_epic {
1309 let task = get_task_in_workspace(conn, workspace, task_id).await?;
1310 if super::epics::task_has_epic_children(conn, &task.workspace_id, task_id).await? {
1311 bail!("error epic-has-children task_id={task_id}");
1312 }
1313 }
1314 changed |= update_task_field(
1315 conn,
1316 workspace,
1317 task_id,
1318 "is_epic",
1319 if is_epic { "1" } else { "0" },
1320 )
1321 .await?;
1322 }
1323 changed |= update_task_labels_in_workspace(
1324 conn,
1325 &workspace.id,
1326 task_id,
1327 &update.add_labels,
1328 &update.remove_labels,
1329 )
1330 .await?;
1331 crate::metadata::validate_task_metadata_result(
1332 conn,
1333 &workspace.id,
1334 task_id,
1335 &update.set_metadata,
1336 &update.remove_metadata,
1337 )
1338 .await?;
1339 for input in &update.set_metadata {
1340 changed |= crate::metadata::set_task_metadata(conn, workspace, task_id, input).await?;
1341 }
1342 for key in &update.remove_metadata {
1343 changed |= crate::metadata::remove_task_metadata(conn, workspace, task_id, key).await?;
1344 }
1345 Ok((changed, created_labels))
1346}
1347
1348pub async fn update_task_field(
1349 conn: &mut SqliteConnection,
1350 workspace: &Workspace,
1351 task_id: &crate::ids::TaskId,
1352 field: &str,
1353 value: &str,
1354) -> Result<bool> {
1355 set_task_field(conn, workspace, task_id, field, value).await
1356}
1357
1358pub async fn update_task_labels_in_workspace(
1359 conn: &mut SqliteConnection,
1360 workspace_id: &WorkspaceId,
1361 task_id: &crate::ids::TaskId,
1362 add_labels: &[String],
1363 remove_labels: &[String],
1364) -> Result<bool> {
1365 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1366 crate::operations::route_recurrence_task_field(conn, &workspace, task_id, "labels", "").await?;
1367 let mut changed = false;
1368 for label in resolve_labels_in_workspace(conn, &workspace.id, add_labels).await? {
1369 let rows_affected = sqlx::query(
1370 "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
1371 )
1372 .bind(&workspace.id)
1373 .bind(task_id)
1374 .bind(&label)
1375 .execute(&mut *conn)
1376 .await?
1377 .rows_affected();
1378 if rows_affected > 0 {
1379 append_change(
1380 conn,
1381 ChangeEntity::Task,
1382 task_id,
1383 Some("labels"),
1384 op_type::LABEL_ADD,
1385 ChangePayload::workspace(&workspace).set("label", label),
1386 )
1387 .await?;
1388 changed = true;
1389 }
1390 }
1391 for label in resolve_labels_in_workspace(conn, &workspace.id, remove_labels).await? {
1392 let rows_affected = sqlx::query(
1393 "DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ? AND label = ?",
1394 )
1395 .bind(&workspace.id)
1396 .bind(task_id)
1397 .bind(&label)
1398 .execute(&mut *conn)
1399 .await?
1400 .rows_affected();
1401 if rows_affected > 0 {
1402 append_change(
1403 conn,
1404 ChangeEntity::Task,
1405 task_id,
1406 Some("labels"),
1407 op_type::LABEL_REMOVE,
1408 ChangePayload::workspace(&workspace).set("label", label),
1409 )
1410 .await?;
1411 changed = true;
1412 }
1413 }
1414 if changed {
1415 info!(
1416 task_id = %task_id,
1417 added = add_labels.len(),
1418 removed = remove_labels.len(),
1419 "task labels changed"
1420 );
1421 }
1422 Ok(changed)
1423}
1424
1425pub(super) async fn add_note_operation(
1426 conn: &mut SqliteConnection,
1427 workspace: &Workspace,
1428 task_id: &crate::ids::TaskId,
1429 body: String,
1430 tui_undo: bool,
1431) -> Result<NoteOutcome> {
1432 let note_id = new_id();
1433 let ts = now();
1434 let mut tx = begin_immediate(conn).await?;
1435 crate::operations::route_recurrence_task_field(&mut tx, workspace, task_id, "notes", "")
1436 .await?;
1437 let change_id = append_change(
1438 &mut tx,
1439 ChangeEntity::Task,
1440 task_id,
1441 Some("notes"),
1442 op_type::NOTE_ADD,
1443 ChangePayload::workspace(workspace)
1444 .set("note_id", ¬e_id)
1445 .set("body", &body)
1446 .set("created_at", &ts),
1447 )
1448 .await?;
1449 sqlx::query(
1450 "INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id) VALUES (?, ?, ?, ?, ?, ?)",
1451 )
1452 .bind(&workspace.id)
1453 .bind(¬e_id)
1454 .bind(task_id)
1455 .bind(&body)
1456 .bind(&ts)
1457 .bind(&change_id)
1458 .execute(&mut *tx)
1459 .await?;
1460 sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1461 .bind(&ts)
1462 .bind(&workspace.id)
1463 .bind(task_id)
1464 .execute(&mut *tx)
1465 .await?;
1466 if tui_undo {
1467 record_tui_undo(
1468 &mut tx,
1469 &workspace.id,
1470 &format!("note {note_id}"),
1471 UndoPayload {
1472 commands: vec![UndoCommand::DeleteCreatedNote {
1473 task_id: task_id.clone(),
1474 note_id: note_id.clone(),
1475 note_add_change_id: change_id.clone(),
1476 }],
1477 },
1478 )
1479 .await?;
1480 }
1481 tx.commit().await?;
1482 info!(task_id = %task_id, note_id = %note_id, "note added");
1483 Ok(NoteOutcome {
1484 task_id: task_id.clone(),
1485 note_id,
1486 change_id,
1487 })
1488}
1489
1490async fn edit_note_operation(
1491 conn: &mut SqliteConnection,
1492 workspace: &Workspace,
1493 task_id: &crate::ids::TaskId,
1494 note_id: &str,
1495 body: String,
1496 tui_undo: bool,
1497) -> Result<NoteEditOutcome> {
1498 let mut tx = begin_immediate(conn).await?;
1499 crate::operations::route_recurrence_task_field(&mut tx, workspace, task_id, "notes", "")
1500 .await?;
1501 let before = sqlx::query_scalar::<_, String>(
1502 "SELECT body FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1503 )
1504 .bind(&workspace.id)
1505 .bind(task_id)
1506 .bind(note_id)
1507 .fetch_optional(&mut *tx)
1508 .await?;
1509 let found = before.is_some();
1510 let changed = before.as_ref().is_some_and(|before| before != &body);
1511 if changed {
1512 let edited_at = now();
1513 sqlx::query("UPDATE notes SET body = ? WHERE workspace_id = ? AND task_id = ? AND id = ?")
1514 .bind(&body)
1515 .bind(&workspace.id)
1516 .bind(task_id)
1517 .bind(note_id)
1518 .execute(&mut *tx)
1519 .await?;
1520 sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1521 .bind(&edited_at)
1522 .bind(&workspace.id)
1523 .bind(task_id)
1524 .execute(&mut *tx)
1525 .await?;
1526 append_change(
1527 &mut tx,
1528 ChangeEntity::Task,
1529 task_id,
1530 Some("notes"),
1531 op_type::NOTE_EDIT,
1532 ChangePayload::workspace(workspace)
1533 .set("note_id", note_id)
1534 .set("body", &body)
1535 .set("edited_at", edited_at),
1536 )
1537 .await?;
1538 if tui_undo {
1539 record_tui_undo(
1540 &mut tx,
1541 &workspace.id,
1542 &format!("edit note {note_id}"),
1543 UndoPayload {
1544 commands: vec![UndoCommand::SetNoteBody {
1545 task_id: task_id.clone(),
1546 note_id: note_id.to_string(),
1547 before: before.expect("changed note has before body"),
1548 after: body,
1549 }],
1550 },
1551 )
1552 .await?;
1553 }
1554 }
1555 tx.commit().await?;
1556 if changed {
1557 info!(task_id = %task_id, note_id = %note_id, "note edited");
1558 }
1559 Ok(NoteEditOutcome {
1560 task_id: task_id.clone(),
1561 note_id: note_id.to_string(),
1562 found,
1563 changed,
1564 })
1565}
1566
1567async fn delete_note_operation(
1568 conn: &mut SqliteConnection,
1569 workspace: &Workspace,
1570 task_id: &crate::ids::TaskId,
1571 note_id: &str,
1572 tui_undo: bool,
1573) -> Result<NoteDeleteOutcome> {
1574 let mut tx = begin_immediate(conn).await?;
1575 crate::operations::route_recurrence_task_field(&mut tx, workspace, task_id, "notes", "")
1576 .await?;
1577 let before = sqlx::query_as::<_, (String, String)>(
1578 "SELECT body, created_at FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1579 )
1580 .bind(&workspace.id)
1581 .bind(task_id)
1582 .bind(note_id)
1583 .fetch_optional(&mut *tx)
1584 .await?;
1585 let deleted_at = now();
1586 let deleted =
1587 sqlx::query("DELETE FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?")
1588 .bind(&workspace.id)
1589 .bind(task_id)
1590 .bind(note_id)
1591 .execute(&mut *tx)
1592 .await?
1593 .rows_affected();
1594 if deleted > 0 {
1595 sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1596 .bind(&deleted_at)
1597 .bind(&workspace.id)
1598 .bind(task_id)
1599 .execute(&mut *tx)
1600 .await?;
1601 append_change(
1602 &mut tx,
1603 ChangeEntity::Task,
1604 task_id,
1605 Some("notes"),
1606 op_type::NOTE_DELETE,
1607 ChangePayload::workspace(workspace)
1608 .set("note_id", note_id)
1609 .set(
1610 "body",
1611 &before.as_ref().expect("deleted note has before state").0,
1612 )
1613 .set("deleted_at", deleted_at),
1614 )
1615 .await?;
1616 if tui_undo {
1617 let (body, created_at) = before.expect("deleted note has before state");
1618 record_tui_undo(
1619 &mut tx,
1620 &workspace.id,
1621 &format!("delete note {note_id}"),
1622 UndoPayload {
1623 commands: vec![UndoCommand::RestoreDeletedNote {
1624 task_id: task_id.clone(),
1625 note_id: note_id.to_string(),
1626 body,
1627 created_at,
1628 }],
1629 },
1630 )
1631 .await?;
1632 }
1633 }
1634 tx.commit().await?;
1635 if deleted > 0 {
1636 info!(task_id = %task_id, note_id = %note_id, "note deleted");
1637 }
1638 Ok(NoteDeleteOutcome {
1639 task_id: task_id.clone(),
1640 note_id: note_id.to_string(),
1641 changed: deleted > 0,
1642 })
1643}