1use std::collections::BTreeMap;
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, TaskStatus};
11use crate::db::{Database, begin_immediate, set_field_version};
12use crate::ids::{TaskId, new_id, now};
13use crate::labels::resolve_labels_in_workspace;
14use crate::mutation::{set_task_field, set_task_project};
15use crate::projects::resolve_or_create_project_in_workspace;
16use crate::refs::get_task_in_workspace;
17use crate::task_fields::TaskField;
18use crate::types::Task;
19use crate::workspaces::Workspace;
20
21pub struct TaskDraft {
22 pub title: String,
23 pub description: String,
24 pub project: Option<String>,
25 pub status: String,
26 pub priority: String,
27 pub labels: Vec<String>,
28 pub available_at: Option<String>,
29 pub due_on: Option<String>,
30 pub is_epic: bool,
31}
32
33#[derive(Debug)]
34pub struct TaskOutcome {
35 pub task: Task,
36 pub create_change_id: Option<String>,
37 pub attachment_change_ids: Vec<String>,
38}
39
40struct InsertedTask {
41 id: TaskId,
42 change_id: String,
43 project_key: String,
44 label_count: usize,
45}
46
47#[derive(Default)]
48pub struct TaskUpdate {
49 pub title: Option<String>,
50 pub description: Option<String>,
51 pub project: Option<String>,
52 pub status: Option<String>,
53 pub priority: Option<String>,
54 pub available_at: Option<Option<String>>,
55 pub due_on: Option<Option<String>>,
56 pub is_epic: Option<bool>,
57 pub add_labels: Vec<String>,
58 pub remove_labels: Vec<String>,
59}
60
61pub struct TaskUpdateOutcome {
62 pub task: Task,
63 pub changed: bool,
64}
65
66pub struct NoteDeleteOutcome {
67 #[allow(dead_code)]
68 pub task_id: TaskId,
69 #[allow(dead_code)]
70 pub note_id: String,
71 pub changed: bool,
72}
73
74pub struct NoteOutcome {
75 #[allow(dead_code)]
76 pub task_id: TaskId,
77 pub note_id: String,
78 pub change_id: String,
79}
80impl Database {
81 pub async fn create_task(
82 &self,
83 workspace: &Workspace,
84 draft: TaskDraft,
85 ) -> Result<TaskOutcome> {
86 let mut conn = self.acquire().await?;
87 create_task(&mut conn, workspace, draft).await
88 }
89
90 pub async fn create_task_for_epic(
91 &self,
92 workspace: &Workspace,
93 draft: TaskDraft,
94 epic_id: &TaskId,
95 ) -> Result<TaskOutcome> {
96 let mut conn = self.acquire().await?;
97 create_task_for_epic(&mut conn, workspace, draft, epic_id).await
98 }
99
100 pub async fn create_task_with_attachments(
101 &self,
102 workspace: &Workspace,
103 blob_dir: &Path,
104 lifecycle_policy: crate::attachments::lifecycle::LifecyclePolicy,
105 draft: TaskDraft,
106 attachments: Vec<super::attachments::TaskAttachmentAddInput>,
107 ) -> Result<TaskOutcome> {
108 let mut conn = self.acquire().await?;
109 create_task_with_attachments(
110 &mut conn,
111 workspace,
112 blob_dir,
113 lifecycle_policy,
114 draft,
115 attachments,
116 )
117 .await
118 }
119
120 pub async fn create_task_with_attachments_for_epic(
121 &self,
122 workspace: &Workspace,
123 blob_dir: &Path,
124 lifecycle_policy: crate::attachments::lifecycle::LifecyclePolicy,
125 draft: TaskDraft,
126 attachments: Vec<super::attachments::TaskAttachmentAddInput>,
127 epic_id: &TaskId,
128 ) -> Result<TaskOutcome> {
129 let mut conn = self.acquire().await?;
130 create_task_with_attachments_for_epic(
131 &mut conn,
132 workspace,
133 blob_dir,
134 lifecycle_policy,
135 draft,
136 attachments,
137 epic_id,
138 )
139 .await
140 }
141
142 pub async fn update_task(
143 &self,
144 workspace: &Workspace,
145 task_id: &TaskId,
146 update: TaskUpdate,
147 ) -> Result<TaskUpdateOutcome> {
148 let mut outcomes = self
149 .update_tasks(workspace, vec![(task_id.clone(), update)])
150 .await?;
151 Ok(outcomes.remove(0))
152 }
153
154 pub async fn update_tasks(
155 &self,
156 workspace: &Workspace,
157 updates: Vec<(TaskId, TaskUpdate)>,
158 ) -> Result<Vec<TaskUpdateOutcome>> {
159 for (_, update) in &updates {
160 validate_task_update(update)?;
161 }
162
163 let mut conn = self.acquire().await?;
164 let mut tx = begin_immediate(&mut conn).await?;
165 let mut changed = Vec::with_capacity(updates.len());
166 for (task_id, update) in &updates {
167 changed.push(apply_task_update(&mut tx, workspace, task_id, update).await?);
168 }
169 tx.commit().await?;
170
171 let mut outcomes = Vec::with_capacity(updates.len());
172 for ((task_id, _), changed) in updates.into_iter().zip(changed) {
173 info!(task_id = %task_id, changed, "task updated");
174 outcomes.push(TaskUpdateOutcome {
175 task: get_task_in_workspace(&mut conn, workspace, &task_id).await?,
176 changed,
177 });
178 }
179 Ok(outcomes)
180 }
181
182 pub async fn set_task_deleted(
183 &self,
184 workspace: &Workspace,
185 task_id: &TaskId,
186 deleted: bool,
187 ) -> Result<TaskOutcome> {
188 let mut conn = self.acquire().await?;
189 set_task_deleted(&mut conn, workspace, task_id, deleted).await
190 }
191
192 pub async fn add_note(
193 &self,
194 workspace: &Workspace,
195 task_id: &TaskId,
196 body: String,
197 ) -> Result<NoteOutcome> {
198 let mut conn = self.acquire().await?;
199 add_note(&mut conn, workspace, task_id, body).await
200 }
201
202 pub async fn delete_note(
203 &self,
204 workspace: &Workspace,
205 task_id: &TaskId,
206 note_id: &str,
207 ) -> Result<NoteDeleteOutcome> {
208 let mut conn = self.acquire().await?;
209 delete_note(&mut conn, workspace, task_id, note_id).await
210 }
211}
212
213pub async fn create_task(
214 conn: &mut SqliteConnection,
215 workspace: &Workspace,
216 draft: TaskDraft,
217) -> Result<TaskOutcome> {
218 create_task_with_epic(conn, workspace, draft, None).await
219}
220
221pub async fn create_task_for_epic(
222 conn: &mut SqliteConnection,
223 workspace: &Workspace,
224 draft: TaskDraft,
225 epic_id: &TaskId,
226) -> Result<TaskOutcome> {
227 create_task_with_epic(conn, workspace, draft, Some(epic_id)).await
228}
229
230async fn create_task_with_epic(
231 conn: &mut SqliteConnection,
232 workspace: &Workspace,
233 draft: TaskDraft,
234 epic_id: Option<&TaskId>,
235) -> Result<TaskOutcome> {
236 validate_task_draft(&draft)?;
237 let mut tx = begin_immediate(conn).await?;
238 let inserted = insert_task(&mut tx, workspace, draft).await?;
239 if let Some(epic_id) = epic_id {
240 super::add_task_to_epic_in_transaction(&mut tx, workspace, &inserted.id, epic_id).await?;
241 }
242 tx.commit().await?;
243 info!(
244 task_id = %inserted.id,
245 project_key = %inserted.project_key,
246 label_count = inserted.label_count,
247 "task created"
248 );
249 Ok(TaskOutcome {
250 task: get_task_in_workspace(conn, workspace, &inserted.id).await?,
251 create_change_id: Some(inserted.change_id),
252 attachment_change_ids: Vec::new(),
253 })
254}
255
256pub async fn create_task_with_attachments(
257 conn: &mut SqliteConnection,
258 workspace: &Workspace,
259 blob_dir: &Path,
260 lifecycle_policy: crate::attachments::lifecycle::LifecyclePolicy,
261 draft: TaskDraft,
262 attachments: Vec<super::attachments::TaskAttachmentAddInput>,
263) -> Result<TaskOutcome> {
264 create_task_with_attachments_and_epic(
265 conn,
266 workspace,
267 blob_dir,
268 lifecycle_policy,
269 draft,
270 attachments,
271 None,
272 )
273 .await
274}
275
276pub async fn create_task_with_attachments_for_epic(
277 conn: &mut SqliteConnection,
278 workspace: &Workspace,
279 blob_dir: &Path,
280 lifecycle_policy: crate::attachments::lifecycle::LifecyclePolicy,
281 draft: TaskDraft,
282 attachments: Vec<super::attachments::TaskAttachmentAddInput>,
283 epic_id: &TaskId,
284) -> Result<TaskOutcome> {
285 create_task_with_attachments_and_epic(
286 conn,
287 workspace,
288 blob_dir,
289 lifecycle_policy,
290 draft,
291 attachments,
292 Some(epic_id),
293 )
294 .await
295}
296
297async fn create_task_with_attachments_and_epic(
298 conn: &mut SqliteConnection,
299 workspace: &Workspace,
300 blob_dir: &Path,
301 lifecycle_policy: crate::attachments::lifecycle::LifecyclePolicy,
302 draft: TaskDraft,
303 attachments: Vec<super::attachments::TaskAttachmentAddInput>,
304 epic_id: Option<&TaskId>,
305) -> Result<TaskOutcome> {
306 validate_task_draft(&draft)?;
307 let mut prepared = Vec::with_capacity(attachments.len());
308 for attachment in attachments {
309 prepared.push(super::attachments::prepare_task_attachment(attachment).await?);
310 }
311
312 let mut unique = BTreeMap::new();
313 for attachment in &prepared {
314 unique
315 .entry(attachment.sha256.clone())
316 .or_insert_with(|| attachment.clone());
317 }
318
319 let mut capacity_reservations = Vec::new();
320 for attachment in unique.values() {
321 let available: bool = sqlx::query_scalar(
322 "SELECT EXISTS(SELECT 1 FROM blob_inventory WHERE sha256 = ? AND available = 1)",
323 )
324 .bind(&attachment.sha256)
325 .fetch_one(&mut *conn)
326 .await?;
327 if available {
328 continue;
329 }
330 match crate::attachments::lifecycle::ensure_local_capacity(
331 conn,
332 blob_dir,
333 &attachment.sha256,
334 attachment.byte_size,
335 lifecycle_policy,
336 &crate::attachments::lifecycle::SystemClock,
337 )
338 .await
339 {
340 Ok(Some(reservation_id)) => capacity_reservations.push(reservation_id),
341 Ok(None) => {}
342 Err(error) => {
343 for reservation_id in capacity_reservations {
344 let _ =
345 crate::attachments::lifecycle::release_reservation(conn, &reservation_id)
346 .await;
347 }
348 return Err(error);
349 }
350 }
351 }
352
353 let mut staging_leases = Vec::with_capacity(unique.len());
354 for attachment in unique.values() {
355 match crate::attachments::lifecycle::acquire_lease(
356 conn,
357 &attachment.sha256,
358 "staging",
359 &crate::attachments::lifecycle::SystemClock,
360 )
361 .await
362 {
363 Ok(lease_id) => staging_leases.push(lease_id),
364 Err(error) => {
365 for lease_id in staging_leases {
366 let _ = crate::attachments::lifecycle::release_lease(conn, &lease_id).await;
367 }
368 for reservation_id in capacity_reservations {
369 let _ =
370 crate::attachments::lifecycle::release_reservation(conn, &reservation_id)
371 .await;
372 }
373 return Err(error);
374 }
375 }
376 }
377
378 let mut created_hashes = Vec::new();
379 for attachment in unique.values() {
380 match crate::attachments::storage::stage_blob(
381 blob_dir,
382 &attachment.sha256,
383 &attachment.bytes,
384 )
385 .await
386 {
387 Ok(staged) if staged.byte_size == attachment.byte_size => {
388 if staged.created {
389 created_hashes.push(staged.sha256);
390 }
391 }
392 Ok(_) => {
393 cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
394 cleanup_created_objects(conn, blob_dir, &created_hashes).await;
395 bail!("error attachment-staged-size-mismatch");
396 }
397 Err(error) => {
398 cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
399 cleanup_created_objects(conn, blob_dir, &created_hashes).await;
400 return Err(error);
401 }
402 }
403 }
404
405 let database_result = async {
406 let mut tx = begin_immediate(conn).await?;
407 for attachment in unique.values() {
408 crate::attachments::storage::upsert_inventory_available(
409 &mut tx,
410 &attachment.sha256,
411 attachment.byte_size,
412 &attachment.facts.media_type,
413 )
414 .await?;
415 }
416 let inserted = insert_task(&mut tx, workspace, draft).await?;
417 if let Some(epic_id) = epic_id {
418 super::add_task_to_epic_in_transaction(&mut tx, workspace, &inserted.id, epic_id)
419 .await?;
420 }
421 let mut attachment_change_ids = Vec::with_capacity(prepared.len());
422 let attachment_base = chrono::DateTime::parse_from_rfc3339(&now())?.to_utc();
423 for (index, attachment) in prepared.iter().enumerate() {
424 let created_at = (attachment_base
425 + chrono::TimeDelta::microseconds(i64::try_from(index)?))
426 .to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
427 attachment_change_ids.push(
428 super::attachments::insert_prepared_attachment(
429 &mut tx,
430 workspace,
431 &inserted.id,
432 attachment,
433 &created_at,
434 )
435 .await?,
436 );
437 }
438 let task = get_task_in_workspace(&mut tx, workspace, &inserted.id).await?;
439 tx.commit().await?;
440 Ok::<_, anyhow::Error>((inserted, attachment_change_ids, task))
441 }
442 .await;
443
444 let (inserted, attachment_change_ids, task) = match database_result {
445 Ok(value) => value,
446 Err(error) => {
447 cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
448 cleanup_created_objects(conn, blob_dir, &created_hashes).await;
449 return Err(error);
450 }
451 };
452 cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
453 if let Err(error) = crate::attachments::lifecycle::reconcile_liveness(
454 conn,
455 &crate::attachments::lifecycle::SystemClock,
456 )
457 .await
458 {
459 warn!(%error, "failed to reconcile attachment liveness");
460 }
461 info!(
462 task_id = %inserted.id,
463 project_key = %inserted.project_key,
464 label_count = inserted.label_count,
465 attachment_count = prepared.len(),
466 "task created"
467 );
468 Ok(TaskOutcome {
469 task,
470 create_change_id: Some(inserted.change_id),
471 attachment_change_ids,
472 })
473}
474
475fn validate_task_draft(draft: &TaskDraft) -> Result<()> {
476 TaskStatus::parse(&draft.status)?;
477 TaskPriority::parse(&draft.priority)?;
478 if let Some(available_at) = draft.available_at.as_deref() {
479 crate::time_validation::validate_available_at_value(available_at)?;
480 }
481 if let Some(due_on) = draft.due_on.as_deref() {
482 crate::time_validation::validate_due_on_value(due_on)?;
483 }
484 Ok(())
485}
486
487async fn insert_task(
488 conn: &mut SqliteConnection,
489 workspace: &Workspace,
490 draft: TaskDraft,
491) -> Result<InsertedTask> {
492 let status = TaskStatus::parse(&draft.status)?;
493 let priority = TaskPriority::parse(&draft.priority)?;
494 let available_at = draft.available_at.as_deref().unwrap_or("");
495 let due_on = draft.due_on.as_deref().unwrap_or("");
496 let id = TaskId::new();
497 let ts = now();
498 let project = draft
499 .project
500 .as_deref()
501 .ok_or_else(|| anyhow::anyhow!("error project-required"))?;
502 let project = resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?;
503 let labels = resolve_labels_in_workspace(conn, &workspace.id, &draft.labels).await?;
504 sqlx::query(
505 "INSERT INTO tasks(workspace_id, id, title, description, project_id, status, priority, created_at, updated_at, queue_activity_at, available_at, due_on, is_epic)
506 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
507 )
508 .bind(&workspace.id)
509 .bind(&id)
510 .bind(&draft.title)
511 .bind(&draft.description)
512 .bind(&project.id)
513 .bind(status.as_str())
514 .bind(priority.as_str())
515 .bind(&ts)
516 .bind(&ts)
517 .bind(&ts)
518 .bind(available_at)
519 .bind(due_on)
520 .bind(i64::from(draft.is_epic))
521 .execute(&mut *conn)
522 .await?;
523 for label in &labels {
524 sqlx::query(
525 "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
526 )
527 .bind(&workspace.id)
528 .bind(&id)
529 .bind(label)
530 .execute(&mut *conn)
531 .await?;
532 }
533 let change_id = append_change(
534 conn,
535 ChangeEntity::Task,
536 &id,
537 None,
538 op_type::CREATE_TASK,
539 ChangePayload::workspace(workspace)
540 .set("title", draft.title)
541 .set("description", draft.description)
542 .set("project_id", project.id.clone())
543 .set("project_key", project.key.clone())
544 .set("project_name", project.name.clone())
545 .set("project_prefix", project.prefix.clone())
546 .set("status", status.as_str())
547 .set("priority", priority.as_str())
548 .set("available_at", available_at)
549 .set("due_on", due_on)
550 .set("is_epic", if draft.is_epic { "1" } else { "0" })
551 .set("labels", &labels)
552 .set("created_at", ts),
553 )
554 .await?;
555 for field in TaskField::VERSIONED {
556 set_field_version(conn, &id, field.as_str(), &change_id).await?;
557 }
558 Ok(InsertedTask {
559 id,
560 change_id,
561 project_key: project.key,
562 label_count: labels.len(),
563 })
564}
565
566async fn cleanup_attachment_guards(
567 conn: &mut SqliteConnection,
568 leases: &[String],
569 reservations: &[String],
570) {
571 for lease_id in leases {
572 if let Err(error) = crate::attachments::lifecycle::release_lease(conn, lease_id).await {
573 warn!(%error, "failed to release attachment staging lease");
574 }
575 }
576 for reservation_id in reservations {
577 if let Err(error) =
578 crate::attachments::lifecycle::release_reservation(conn, reservation_id).await
579 {
580 warn!(%error, "failed to release attachment capacity reservation");
581 }
582 }
583}
584
585async fn cleanup_created_objects(conn: &mut SqliteConnection, blob_dir: &Path, hashes: &[String]) {
586 for sha256 in hashes {
587 crate::attachments::storage::remove_staged_blob_if_unreferenced(conn, blob_dir, sha256)
588 .await;
589 }
590}
591
592#[cfg(any(test, feature = "test-support"))]
593pub async fn update_task(
594 conn: &mut SqliteConnection,
595 workspace: &Workspace,
596 task_id: &crate::ids::TaskId,
597 update: TaskUpdate,
598) -> Result<TaskUpdateOutcome> {
599 validate_task_update(&update)?;
600 let mut tx = begin_immediate(conn).await?;
601 let changed = apply_task_update(&mut tx, workspace, task_id, &update).await?;
602 tx.commit().await?;
603 Ok(TaskUpdateOutcome {
604 task: get_task_in_workspace(conn, workspace, task_id).await?,
605 changed,
606 })
607}
608
609fn validate_task_update(update: &TaskUpdate) -> Result<()> {
610 if let Some(status) = update.status.as_deref() {
611 TaskStatus::parse(status)?;
612 }
613 if let Some(priority) = update.priority.as_deref() {
614 TaskPriority::parse(priority)?;
615 }
616 if let Some(Some(available_at)) = update.available_at.as_ref() {
617 crate::time_validation::validate_available_at_value(available_at)?;
618 }
619 if let Some(Some(due_on)) = update.due_on.as_ref() {
620 crate::time_validation::validate_due_on_value(due_on)?;
621 }
622 Ok(())
623}
624
625async fn apply_task_update(
626 conn: &mut SqliteConnection,
627 workspace: &Workspace,
628 task_id: &crate::ids::TaskId,
629 update: &TaskUpdate,
630) -> Result<bool> {
631 let mut changed = false;
632 if let Some(title) = update.title.as_deref() {
633 changed |= update_task_field(conn, workspace, task_id, "title", title).await?;
634 }
635 if let Some(description) = update.description.as_deref() {
636 changed |= update_task_field(conn, workspace, task_id, "description", description).await?;
637 }
638 if let Some(project) = update.project.as_deref() {
639 let project = resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?;
640 changed |= set_task_project(conn, workspace, task_id, &project).await?;
641 }
642 if let Some(status) = update.status.as_deref() {
643 changed |= update_task_field(conn, workspace, task_id, "status", status).await?;
644 }
645 if let Some(priority) = update.priority.as_deref() {
646 changed |= update_task_field(conn, workspace, task_id, "priority", priority).await?;
647 }
648 if let Some(available_at) = update.available_at.as_ref() {
649 changed |= update_task_field(
650 conn,
651 workspace,
652 task_id,
653 "available_at",
654 available_at.as_deref().unwrap_or(""),
655 )
656 .await?;
657 }
658 if let Some(due_on) = update.due_on.as_ref() {
659 changed |= update_task_field(
660 conn,
661 workspace,
662 task_id,
663 "due_on",
664 due_on.as_deref().unwrap_or(""),
665 )
666 .await?;
667 }
668 if let Some(is_epic) = update.is_epic {
669 if !is_epic {
670 let task = get_task_in_workspace(conn, workspace, task_id).await?;
671 if super::epics::task_has_epic_children(conn, &task.workspace_id, task_id).await? {
672 bail!("error epic-has-children task_id={task_id}");
673 }
674 }
675 changed |= update_task_field(
676 conn,
677 workspace,
678 task_id,
679 "is_epic",
680 if is_epic { "1" } else { "0" },
681 )
682 .await?;
683 }
684 changed |= update_task_labels_in_workspace(
685 conn,
686 &workspace.id,
687 task_id,
688 &update.add_labels,
689 &update.remove_labels,
690 )
691 .await?;
692 Ok(changed)
693}
694
695pub async fn update_task_field(
696 conn: &mut SqliteConnection,
697 workspace: &Workspace,
698 task_id: &crate::ids::TaskId,
699 field: &str,
700 value: &str,
701) -> Result<bool> {
702 set_task_field(conn, workspace, task_id, field, value).await
703}
704
705pub async fn update_task_labels_in_workspace(
706 conn: &mut SqliteConnection,
707 workspace_id: &WorkspaceId,
708 task_id: &crate::ids::TaskId,
709 add_labels: &[String],
710 remove_labels: &[String],
711) -> Result<bool> {
712 let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
713 let mut changed = false;
714 for label in resolve_labels_in_workspace(conn, &workspace.id, add_labels).await? {
715 let rows_affected = sqlx::query(
716 "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
717 )
718 .bind(&workspace.id)
719 .bind(task_id)
720 .bind(&label)
721 .execute(&mut *conn)
722 .await?
723 .rows_affected();
724 if rows_affected > 0 {
725 append_change(
726 conn,
727 ChangeEntity::Task,
728 task_id,
729 Some("labels"),
730 op_type::LABEL_ADD,
731 ChangePayload::workspace(&workspace).set("label", label),
732 )
733 .await?;
734 changed = true;
735 }
736 }
737 for label in resolve_labels_in_workspace(conn, &workspace.id, remove_labels).await? {
738 let rows_affected = sqlx::query(
739 "DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ? AND label = ?",
740 )
741 .bind(&workspace.id)
742 .bind(task_id)
743 .bind(&label)
744 .execute(&mut *conn)
745 .await?
746 .rows_affected();
747 if rows_affected > 0 {
748 append_change(
749 conn,
750 ChangeEntity::Task,
751 task_id,
752 Some("labels"),
753 op_type::LABEL_REMOVE,
754 ChangePayload::workspace(&workspace).set("label", label),
755 )
756 .await?;
757 changed = true;
758 }
759 }
760 if changed {
761 info!(
762 task_id = %task_id,
763 added = add_labels.len(),
764 removed = remove_labels.len(),
765 "task labels changed"
766 );
767 }
768 Ok(changed)
769}
770
771pub async fn set_task_deleted(
772 conn: &mut SqliteConnection,
773 workspace: &Workspace,
774 task_id: &crate::ids::TaskId,
775 deleted: bool,
776) -> Result<TaskOutcome> {
777 set_task_field(
778 conn,
779 workspace,
780 task_id,
781 "deleted",
782 if deleted { "1" } else { "0" },
783 )
784 .await?;
785 crate::attachments::lifecycle::reconcile_liveness(
786 conn,
787 &crate::attachments::lifecycle::SystemClock,
788 )
789 .await?;
790 info!(task_id = %task_id, deleted, "task deleted flag changed");
791 Ok(TaskOutcome {
792 task: get_task_in_workspace(conn, workspace, task_id).await?,
793 create_change_id: None,
794 attachment_change_ids: Vec::new(),
795 })
796}
797
798pub async fn add_note(
799 conn: &mut SqliteConnection,
800 workspace: &Workspace,
801 task_id: &crate::ids::TaskId,
802 body: String,
803) -> Result<NoteOutcome> {
804 let note_id = new_id();
805 let ts = now();
806 let mut tx = begin_immediate(conn).await?;
807 let change_id = append_change(
808 &mut tx,
809 ChangeEntity::Task,
810 task_id,
811 Some("notes"),
812 op_type::NOTE_ADD,
813 ChangePayload::workspace(workspace)
814 .set("note_id", ¬e_id)
815 .set("body", &body)
816 .set("created_at", &ts),
817 )
818 .await?;
819 sqlx::query(
820 "INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id) VALUES (?, ?, ?, ?, ?, ?)",
821 )
822 .bind(&workspace.id)
823 .bind(¬e_id)
824 .bind(task_id)
825 .bind(&body)
826 .bind(&ts)
827 .bind(&change_id)
828 .execute(&mut *tx)
829 .await?;
830 sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
831 .bind(&ts)
832 .bind(&workspace.id)
833 .bind(task_id)
834 .execute(&mut *tx)
835 .await?;
836 tx.commit().await?;
837 info!(task_id = %task_id, note_id = %note_id, "note added");
838 Ok(NoteOutcome {
839 task_id: task_id.clone(),
840 note_id,
841 change_id,
842 })
843}
844
845pub async fn delete_note(
846 conn: &mut SqliteConnection,
847 workspace: &Workspace,
848 task_id: &crate::ids::TaskId,
849 note_id: &str,
850) -> Result<NoteDeleteOutcome> {
851 let mut tx = begin_immediate(conn).await?;
852 let deleted_at = now();
853 let deleted =
854 sqlx::query("DELETE FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?")
855 .bind(&workspace.id)
856 .bind(task_id)
857 .bind(note_id)
858 .execute(&mut *tx)
859 .await?
860 .rows_affected();
861 if deleted > 0 {
862 sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
863 .bind(&deleted_at)
864 .bind(&workspace.id)
865 .bind(task_id)
866 .execute(&mut *tx)
867 .await?;
868 append_change(
869 &mut tx,
870 ChangeEntity::Task,
871 task_id,
872 Some("notes"),
873 op_type::NOTE_DELETE,
874 ChangePayload::workspace(workspace)
875 .set("note_id", note_id)
876 .set("deleted_at", deleted_at),
877 )
878 .await?;
879 }
880 tx.commit().await?;
881 if deleted > 0 {
882 info!(task_id = %task_id, note_id = %note_id, "note deleted");
883 }
884 Ok(NoteDeleteOutcome {
885 task_id: task_id.clone(),
886 note_id: note_id.to_string(),
887 changed: deleted > 0,
888 })
889}