Skip to main content

aven_core/operations/
tasks.rs

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", &note_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(&note_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}