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_with_attachments(
91        &self,
92        workspace: &Workspace,
93        blob_dir: &Path,
94        lifecycle_policy: crate::attachments::lifecycle::LifecyclePolicy,
95        draft: TaskDraft,
96        attachments: Vec<super::attachments::TaskAttachmentAddInput>,
97    ) -> Result<TaskOutcome> {
98        let mut conn = self.acquire().await?;
99        create_task_with_attachments(
100            &mut conn,
101            workspace,
102            blob_dir,
103            lifecycle_policy,
104            draft,
105            attachments,
106        )
107        .await
108    }
109
110    pub async fn update_task(
111        &self,
112        workspace: &Workspace,
113        task_id: &TaskId,
114        update: TaskUpdate,
115    ) -> Result<TaskUpdateOutcome> {
116        let mut conn = self.acquire().await?;
117        update_task(&mut conn, workspace, task_id, update).await
118    }
119
120    pub async fn update_tasks(
121        &self,
122        workspace: &Workspace,
123        updates: Vec<(TaskId, TaskUpdate)>,
124    ) -> Result<Vec<TaskUpdateOutcome>> {
125        let mut conn = self.acquire().await?;
126        let mut outcomes = Vec::with_capacity(updates.len());
127        for (task_id, update) in updates {
128            outcomes.push(update_task(&mut conn, workspace, &task_id, update).await?);
129        }
130        Ok(outcomes)
131    }
132
133    pub async fn set_task_deleted(
134        &self,
135        workspace: &Workspace,
136        task_id: &TaskId,
137        deleted: bool,
138    ) -> Result<TaskOutcome> {
139        let mut conn = self.acquire().await?;
140        set_task_deleted(&mut conn, workspace, task_id, deleted).await
141    }
142
143    pub async fn add_note(
144        &self,
145        workspace: &Workspace,
146        task_id: &TaskId,
147        body: String,
148    ) -> Result<NoteOutcome> {
149        let mut conn = self.acquire().await?;
150        add_note(&mut conn, workspace, task_id, body).await
151    }
152
153    pub async fn delete_note(
154        &self,
155        workspace: &Workspace,
156        task_id: &TaskId,
157        note_id: &str,
158    ) -> Result<NoteDeleteOutcome> {
159        let mut conn = self.acquire().await?;
160        delete_note(&mut conn, workspace, task_id, note_id).await
161    }
162}
163
164pub async fn create_task(
165    conn: &mut SqliteConnection,
166    workspace: &Workspace,
167    draft: TaskDraft,
168) -> Result<TaskOutcome> {
169    validate_task_draft(&draft)?;
170    let mut tx = begin_immediate(conn).await?;
171    let inserted = insert_task(&mut tx, workspace, draft).await?;
172    tx.commit().await?;
173    info!(
174        task_id = %inserted.id,
175        project_key = %inserted.project_key,
176        label_count = inserted.label_count,
177        "task created"
178    );
179    Ok(TaskOutcome {
180        task: get_task_in_workspace(conn, workspace, &inserted.id).await?,
181        create_change_id: Some(inserted.change_id),
182        attachment_change_ids: Vec::new(),
183    })
184}
185
186pub async fn create_task_with_attachments(
187    conn: &mut SqliteConnection,
188    workspace: &Workspace,
189    blob_dir: &Path,
190    lifecycle_policy: crate::attachments::lifecycle::LifecyclePolicy,
191    draft: TaskDraft,
192    attachments: Vec<super::attachments::TaskAttachmentAddInput>,
193) -> Result<TaskOutcome> {
194    validate_task_draft(&draft)?;
195    let mut prepared = Vec::with_capacity(attachments.len());
196    for attachment in attachments {
197        prepared.push(super::attachments::prepare_task_attachment(attachment).await?);
198    }
199
200    let mut unique = BTreeMap::new();
201    for attachment in &prepared {
202        unique
203            .entry(attachment.sha256.clone())
204            .or_insert_with(|| attachment.clone());
205    }
206
207    let mut capacity_reservations = Vec::new();
208    for attachment in unique.values() {
209        let available: bool = sqlx::query_scalar(
210            "SELECT EXISTS(SELECT 1 FROM blob_inventory WHERE sha256 = ? AND available = 1)",
211        )
212        .bind(&attachment.sha256)
213        .fetch_one(&mut *conn)
214        .await?;
215        if available {
216            continue;
217        }
218        match crate::attachments::lifecycle::ensure_local_capacity(
219            conn,
220            blob_dir,
221            &attachment.sha256,
222            attachment.byte_size,
223            lifecycle_policy,
224            &crate::attachments::lifecycle::SystemClock,
225        )
226        .await
227        {
228            Ok(Some(reservation_id)) => capacity_reservations.push(reservation_id),
229            Ok(None) => {}
230            Err(error) => {
231                for reservation_id in capacity_reservations {
232                    let _ =
233                        crate::attachments::lifecycle::release_reservation(conn, &reservation_id)
234                            .await;
235                }
236                return Err(error);
237            }
238        }
239    }
240
241    let mut staging_leases = Vec::with_capacity(unique.len());
242    for attachment in unique.values() {
243        match crate::attachments::lifecycle::acquire_lease(
244            conn,
245            &attachment.sha256,
246            "staging",
247            &crate::attachments::lifecycle::SystemClock,
248        )
249        .await
250        {
251            Ok(lease_id) => staging_leases.push(lease_id),
252            Err(error) => {
253                for lease_id in staging_leases {
254                    let _ = crate::attachments::lifecycle::release_lease(conn, &lease_id).await;
255                }
256                for reservation_id in capacity_reservations {
257                    let _ =
258                        crate::attachments::lifecycle::release_reservation(conn, &reservation_id)
259                            .await;
260                }
261                return Err(error);
262            }
263        }
264    }
265
266    let mut created_hashes = Vec::new();
267    for attachment in unique.values() {
268        match crate::attachments::storage::stage_blob(
269            blob_dir,
270            &attachment.sha256,
271            &attachment.bytes,
272        )
273        .await
274        {
275            Ok(staged) if staged.byte_size == attachment.byte_size => {
276                if staged.created {
277                    created_hashes.push(staged.sha256);
278                }
279            }
280            Ok(_) => {
281                cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
282                cleanup_created_objects(conn, blob_dir, &created_hashes).await;
283                bail!("error attachment-staged-size-mismatch");
284            }
285            Err(error) => {
286                cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
287                cleanup_created_objects(conn, blob_dir, &created_hashes).await;
288                return Err(error);
289            }
290        }
291    }
292
293    let database_result = async {
294        let mut tx = begin_immediate(conn).await?;
295        for attachment in unique.values() {
296            crate::attachments::storage::upsert_inventory_available(
297                &mut tx,
298                &attachment.sha256,
299                attachment.byte_size,
300                &attachment.facts.media_type,
301            )
302            .await?;
303        }
304        let inserted = insert_task(&mut tx, workspace, draft).await?;
305        let mut attachment_change_ids = Vec::with_capacity(prepared.len());
306        let attachment_base = chrono::DateTime::parse_from_rfc3339(&now())?.to_utc();
307        for (index, attachment) in prepared.iter().enumerate() {
308            let created_at = (attachment_base
309                + chrono::TimeDelta::microseconds(i64::try_from(index)?))
310            .to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
311            attachment_change_ids.push(
312                super::attachments::insert_prepared_attachment(
313                    &mut tx,
314                    workspace,
315                    &inserted.id,
316                    attachment,
317                    &created_at,
318                )
319                .await?,
320            );
321        }
322        let task = get_task_in_workspace(&mut tx, workspace, &inserted.id).await?;
323        tx.commit().await?;
324        Ok::<_, anyhow::Error>((inserted, attachment_change_ids, task))
325    }
326    .await;
327
328    let (inserted, attachment_change_ids, task) = match database_result {
329        Ok(value) => value,
330        Err(error) => {
331            cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
332            cleanup_created_objects(conn, blob_dir, &created_hashes).await;
333            return Err(error);
334        }
335    };
336    cleanup_attachment_guards(conn, &staging_leases, &capacity_reservations).await;
337    if let Err(error) = crate::attachments::lifecycle::reconcile_liveness(
338        conn,
339        &crate::attachments::lifecycle::SystemClock,
340    )
341    .await
342    {
343        warn!(%error, "failed to reconcile attachment liveness");
344    }
345    info!(
346        task_id = %inserted.id,
347        project_key = %inserted.project_key,
348        label_count = inserted.label_count,
349        attachment_count = prepared.len(),
350        "task created"
351    );
352    Ok(TaskOutcome {
353        task,
354        create_change_id: Some(inserted.change_id),
355        attachment_change_ids,
356    })
357}
358
359fn validate_task_draft(draft: &TaskDraft) -> Result<()> {
360    TaskStatus::parse(&draft.status)?;
361    TaskPriority::parse(&draft.priority)?;
362    if let Some(available_at) = draft.available_at.as_deref() {
363        crate::time_validation::validate_available_at_value(available_at)?;
364    }
365    if let Some(due_on) = draft.due_on.as_deref() {
366        crate::time_validation::validate_due_on_value(due_on)?;
367    }
368    Ok(())
369}
370
371async fn insert_task(
372    conn: &mut SqliteConnection,
373    workspace: &Workspace,
374    draft: TaskDraft,
375) -> Result<InsertedTask> {
376    let status = TaskStatus::parse(&draft.status)?;
377    let priority = TaskPriority::parse(&draft.priority)?;
378    let available_at = draft.available_at.as_deref().unwrap_or("");
379    let due_on = draft.due_on.as_deref().unwrap_or("");
380    let id = TaskId::new();
381    let ts = now();
382    let project = draft
383        .project
384        .as_deref()
385        .ok_or_else(|| anyhow::anyhow!("error project-required"))?;
386    let project = resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?;
387    let labels = resolve_labels_in_workspace(conn, &workspace.id, &draft.labels).await?;
388    sqlx::query(
389        "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)
390         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
391    )
392    .bind(&workspace.id)
393    .bind(&id)
394    .bind(&draft.title)
395    .bind(&draft.description)
396    .bind(&project.id)
397    .bind(status.as_str())
398    .bind(priority.as_str())
399    .bind(&ts)
400    .bind(&ts)
401    .bind(&ts)
402    .bind(available_at)
403    .bind(due_on)
404    .bind(i64::from(draft.is_epic))
405    .execute(&mut *conn)
406    .await?;
407    for label in &labels {
408        sqlx::query(
409            "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
410        )
411        .bind(&workspace.id)
412        .bind(&id)
413        .bind(label)
414        .execute(&mut *conn)
415        .await?;
416    }
417    let change_id = append_change(
418        conn,
419        ChangeEntity::Task,
420        &id,
421        None,
422        op_type::CREATE_TASK,
423        ChangePayload::workspace(workspace)
424            .set("title", draft.title)
425            .set("description", draft.description)
426            .set("project_id", project.id.clone())
427            .set("project_key", project.key.clone())
428            .set("project_name", project.name.clone())
429            .set("project_prefix", project.prefix.clone())
430            .set("status", status.as_str())
431            .set("priority", priority.as_str())
432            .set("available_at", available_at)
433            .set("due_on", due_on)
434            .set("is_epic", if draft.is_epic { "1" } else { "0" })
435            .set("labels", &labels)
436            .set("created_at", ts),
437    )
438    .await?;
439    for field in TaskField::VERSIONED {
440        set_field_version(conn, &id, field.as_str(), &change_id).await?;
441    }
442    Ok(InsertedTask {
443        id,
444        change_id,
445        project_key: project.key,
446        label_count: labels.len(),
447    })
448}
449
450async fn cleanup_attachment_guards(
451    conn: &mut SqliteConnection,
452    leases: &[String],
453    reservations: &[String],
454) {
455    for lease_id in leases {
456        if let Err(error) = crate::attachments::lifecycle::release_lease(conn, lease_id).await {
457            warn!(%error, "failed to release attachment staging lease");
458        }
459    }
460    for reservation_id in reservations {
461        if let Err(error) =
462            crate::attachments::lifecycle::release_reservation(conn, reservation_id).await
463        {
464            warn!(%error, "failed to release attachment capacity reservation");
465        }
466    }
467}
468
469async fn cleanup_created_objects(conn: &mut SqliteConnection, blob_dir: &Path, hashes: &[String]) {
470    for sha256 in hashes {
471        crate::attachments::storage::remove_staged_blob_if_unreferenced(conn, blob_dir, sha256)
472            .await;
473    }
474}
475
476pub async fn update_task(
477    conn: &mut SqliteConnection,
478    workspace: &Workspace,
479    task_id: &crate::ids::TaskId,
480    update: TaskUpdate,
481) -> Result<TaskUpdateOutcome> {
482    if let Some(status) = update.status.as_deref() {
483        TaskStatus::parse(status)?;
484    }
485    if let Some(priority) = update.priority.as_deref() {
486        TaskPriority::parse(priority)?;
487    }
488    if let Some(Some(available_at)) = update.available_at.as_ref() {
489        crate::time_validation::validate_available_at_value(available_at)?;
490    }
491    if let Some(Some(due_on)) = update.due_on.as_ref() {
492        crate::time_validation::validate_due_on_value(due_on)?;
493    }
494    let mut changed = false;
495    let mut tx = begin_immediate(conn).await?;
496    if let Some(title) = update.title {
497        changed |= update_task_field(&mut tx, workspace, task_id, "title", &title).await?;
498    }
499    if let Some(description) = update.description {
500        changed |=
501            update_task_field(&mut tx, workspace, task_id, "description", &description).await?;
502    }
503    if let Some(project) = update.project {
504        let project =
505            resolve_or_create_project_in_workspace(&mut tx, &workspace.id, &project).await?;
506        changed |= set_task_project(&mut tx, workspace, task_id, &project).await?;
507    }
508    if let Some(status) = update.status {
509        changed |= update_task_field(&mut tx, workspace, task_id, "status", &status).await?;
510    }
511    if let Some(priority) = update.priority {
512        changed |= update_task_field(&mut tx, workspace, task_id, "priority", &priority).await?;
513    }
514    if let Some(available_at) = update.available_at {
515        changed |= update_task_field(
516            &mut tx,
517            workspace,
518            task_id,
519            "available_at",
520            available_at.as_deref().unwrap_or(""),
521        )
522        .await?;
523    }
524    if let Some(due_on) = update.due_on {
525        changed |= update_task_field(
526            &mut tx,
527            workspace,
528            task_id,
529            "due_on",
530            due_on.as_deref().unwrap_or(""),
531        )
532        .await?;
533    }
534    if let Some(is_epic) = update.is_epic {
535        if !is_epic {
536            let task = get_task_in_workspace(&mut tx, workspace, task_id).await?;
537            if super::epics::task_has_epic_children(&mut tx, &task.workspace_id, task_id).await? {
538                bail!("error epic-has-children task_id={task_id}");
539            }
540        }
541        changed |= update_task_field(
542            &mut tx,
543            workspace,
544            task_id,
545            "is_epic",
546            if is_epic { "1" } else { "0" },
547        )
548        .await?;
549    }
550    if update_task_labels_in_workspace(
551        &mut tx,
552        &workspace.id,
553        task_id,
554        &update.add_labels,
555        &update.remove_labels,
556    )
557    .await?
558    {
559        changed = true;
560    }
561    tx.commit().await?;
562    info!(task_id = %task_id, changed, "task updated");
563    Ok(TaskUpdateOutcome {
564        task: get_task_in_workspace(conn, workspace, task_id).await?,
565        changed,
566    })
567}
568
569pub async fn update_task_field(
570    conn: &mut SqliteConnection,
571    workspace: &Workspace,
572    task_id: &crate::ids::TaskId,
573    field: &str,
574    value: &str,
575) -> Result<bool> {
576    set_task_field(conn, workspace, task_id, field, value).await
577}
578
579pub async fn update_task_labels_in_workspace(
580    conn: &mut SqliteConnection,
581    workspace_id: &WorkspaceId,
582    task_id: &crate::ids::TaskId,
583    add_labels: &[String],
584    remove_labels: &[String],
585) -> Result<bool> {
586    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
587    let mut changed = false;
588    for label in resolve_labels_in_workspace(conn, &workspace.id, add_labels).await? {
589        let rows_affected = sqlx::query(
590            "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
591        )
592        .bind(&workspace.id)
593        .bind(task_id)
594        .bind(&label)
595        .execute(&mut *conn)
596        .await?
597        .rows_affected();
598        if rows_affected > 0 {
599            append_change(
600                conn,
601                ChangeEntity::Task,
602                task_id,
603                Some("labels"),
604                op_type::LABEL_ADD,
605                ChangePayload::workspace(&workspace).set("label", label),
606            )
607            .await?;
608            changed = true;
609        }
610    }
611    for label in resolve_labels_in_workspace(conn, &workspace.id, remove_labels).await? {
612        let rows_affected = sqlx::query(
613            "DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ? AND label = ?",
614        )
615        .bind(&workspace.id)
616        .bind(task_id)
617        .bind(&label)
618        .execute(&mut *conn)
619        .await?
620        .rows_affected();
621        if rows_affected > 0 {
622            append_change(
623                conn,
624                ChangeEntity::Task,
625                task_id,
626                Some("labels"),
627                op_type::LABEL_REMOVE,
628                ChangePayload::workspace(&workspace).set("label", label),
629            )
630            .await?;
631            changed = true;
632        }
633    }
634    if changed {
635        info!(
636            task_id = %task_id,
637            added = add_labels.len(),
638            removed = remove_labels.len(),
639            "task labels changed"
640        );
641    }
642    Ok(changed)
643}
644
645pub async fn set_task_deleted(
646    conn: &mut SqliteConnection,
647    workspace: &Workspace,
648    task_id: &crate::ids::TaskId,
649    deleted: bool,
650) -> Result<TaskOutcome> {
651    set_task_field(
652        conn,
653        workspace,
654        task_id,
655        "deleted",
656        if deleted { "1" } else { "0" },
657    )
658    .await?;
659    crate::attachments::lifecycle::reconcile_liveness(
660        conn,
661        &crate::attachments::lifecycle::SystemClock,
662    )
663    .await?;
664    info!(task_id = %task_id, deleted, "task deleted flag changed");
665    Ok(TaskOutcome {
666        task: get_task_in_workspace(conn, workspace, task_id).await?,
667        create_change_id: None,
668        attachment_change_ids: Vec::new(),
669    })
670}
671
672pub async fn add_note(
673    conn: &mut SqliteConnection,
674    workspace: &Workspace,
675    task_id: &crate::ids::TaskId,
676    body: String,
677) -> Result<NoteOutcome> {
678    let note_id = new_id();
679    let ts = now();
680    let mut tx = begin_immediate(conn).await?;
681    let change_id = append_change(
682        &mut tx,
683        ChangeEntity::Task,
684        task_id,
685        Some("notes"),
686        op_type::NOTE_ADD,
687        ChangePayload::workspace(workspace)
688            .set("note_id", &note_id)
689            .set("body", &body)
690            .set("created_at", &ts),
691    )
692    .await?;
693    sqlx::query(
694        "INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id) VALUES (?, ?, ?, ?, ?, ?)",
695    )
696    .bind(&workspace.id)
697    .bind(&note_id)
698    .bind(task_id)
699    .bind(&body)
700    .bind(&ts)
701    .bind(&change_id)
702    .execute(&mut *tx)
703    .await?;
704    sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
705        .bind(&ts)
706        .bind(&workspace.id)
707        .bind(task_id)
708        .execute(&mut *tx)
709        .await?;
710    tx.commit().await?;
711    info!(task_id = %task_id, note_id = %note_id, "note added");
712    Ok(NoteOutcome {
713        task_id: task_id.clone(),
714        note_id,
715        change_id,
716    })
717}
718
719pub async fn delete_note(
720    conn: &mut SqliteConnection,
721    workspace: &Workspace,
722    task_id: &crate::ids::TaskId,
723    note_id: &str,
724) -> Result<NoteDeleteOutcome> {
725    let mut tx = begin_immediate(conn).await?;
726    let deleted_at = now();
727    let deleted =
728        sqlx::query("DELETE FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?")
729            .bind(&workspace.id)
730            .bind(task_id)
731            .bind(note_id)
732            .execute(&mut *tx)
733            .await?
734            .rows_affected();
735    if deleted > 0 {
736        sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
737            .bind(&deleted_at)
738            .bind(&workspace.id)
739            .bind(task_id)
740            .execute(&mut *tx)
741            .await?;
742        append_change(
743            &mut tx,
744            ChangeEntity::Task,
745            task_id,
746            Some("notes"),
747            op_type::NOTE_DELETE,
748            ChangePayload::workspace(workspace)
749                .set("note_id", note_id)
750                .set("deleted_at", deleted_at),
751        )
752        .await?;
753    }
754    tx.commit().await?;
755    if deleted > 0 {
756        info!(task_id = %task_id, note_id = %note_id, "note deleted");
757    }
758    Ok(NoteDeleteOutcome {
759        task_id: task_id.clone(),
760        note_id: note_id.to_string(),
761        changed: deleted > 0,
762    })
763}