Skip to main content

aven_core/
mutation.rs

1use crate::ids::{ProjectId, WorkspaceId};
2use anyhow::{Result, bail, ensure};
3use sqlx::SqliteConnection;
4use tracing::{debug, info};
5
6use crate::change_log::op_type;
7use crate::choices::TaskPriority;
8use crate::db::{
9    Database, begin_immediate, conflict_exists, field_version, insert_change, set_field_version,
10    task_from_row,
11};
12use crate::ids::now;
13use crate::projects::resolve_or_create_project_in_workspace;
14use crate::refs::get_task_in_workspace;
15use crate::task_fields::TaskField;
16use crate::types::{Project, Task};
17use crate::workspaces::Workspace;
18
19#[derive(Debug)]
20pub(crate) struct OpenConflictError {
21    task_id: crate::ids::TaskId,
22    field: &'static str,
23}
24
25impl std::fmt::Display for OpenConflictError {
26    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        write!(
28            formatter,
29            "error conflicted-field ref={} field={} hint=\"use conflict resolve\"",
30            self.task_id, self.field
31        )
32    }
33}
34
35impl std::error::Error for OpenConflictError {}
36
37impl Database {
38    pub async fn set_task_status(
39        &self,
40        workspace: &Workspace,
41        task: &Task,
42        status: &str,
43    ) -> Result<Task> {
44        let mut conn = self.acquire().await?;
45        let mut tx = begin_immediate(&mut conn).await?;
46        let task = set_status(&mut tx, workspace, task, status).await?;
47        tx.commit().await?;
48        Ok(task)
49    }
50
51    pub async fn set_task_priority(
52        &self,
53        workspace: &Workspace,
54        task: &Task,
55        priority: &str,
56    ) -> Result<Task> {
57        let mut conn = self.acquire().await?;
58        let mut tx = begin_immediate(&mut conn).await?;
59        let task = set_priority(&mut tx, workspace, task, priority).await?;
60        tx.commit().await?;
61        Ok(task)
62    }
63
64    pub async fn cycle_task_priority(
65        &self,
66        workspace: &Workspace,
67        task: &Task,
68        reverse: bool,
69    ) -> Result<Task> {
70        let mut conn = self.acquire().await?;
71        let mut tx = begin_immediate(&mut conn).await?;
72        let current = get_task_in_workspace(&mut tx, workspace, &task.id).await?;
73        let task = cycle_priority(&mut tx, workspace, &current, reverse).await?;
74        tx.commit().await?;
75        Ok(task)
76    }
77
78    pub async fn set_task_deleted_state(
79        &self,
80        workspace: &Workspace,
81        task: &Task,
82        deleted: bool,
83    ) -> Result<Task> {
84        let mut conn = self.acquire().await?;
85        let mut tx = begin_immediate(&mut conn).await?;
86        let task = set_deleted(&mut tx, workspace, task, deleted).await?;
87        tx.commit().await?;
88        Ok(task)
89    }
90
91    pub async fn set_task_field(
92        &self,
93        workspace: &Workspace,
94        task_id: &crate::ids::TaskId,
95        field: &str,
96        value: &str,
97    ) -> Result<bool> {
98        let mut conn = self.acquire().await?;
99        let mut tx = begin_immediate(&mut conn).await?;
100        let changed = set_task_field(&mut tx, workspace, task_id, field, value).await?;
101        tx.commit().await?;
102        Ok(changed)
103    }
104
105    pub async fn set_task_project(
106        &self,
107        workspace: &Workspace,
108        task_id: &crate::ids::TaskId,
109        project: &Project,
110    ) -> Result<bool> {
111        let mut conn = self.acquire().await?;
112        let mut tx = begin_immediate(&mut conn).await?;
113        let changed = set_task_project(&mut tx, workspace, task_id, project).await?;
114        tx.commit().await?;
115        Ok(changed)
116    }
117
118    pub async fn set_task_fields(
119        &self,
120        workspace: &Workspace,
121        updates: &[(crate::ids::TaskId, String, String)],
122    ) -> Result<Vec<bool>> {
123        let mut conn = self.acquire().await?;
124        let mut tx = begin_immediate(&mut conn).await?;
125        let mut outcomes = Vec::with_capacity(updates.len());
126        for (task_id, field, value) in updates {
127            outcomes.push(set_task_field(&mut tx, workspace, task_id, field, value).await?);
128        }
129        tx.commit().await?;
130        Ok(outcomes)
131    }
132
133    pub async fn cycle_task_priorities(
134        &self,
135        workspace: &Workspace,
136        tasks: &[Task],
137        reverse: bool,
138    ) -> Result<Vec<Task>> {
139        let mut conn = self.acquire().await?;
140        let mut tx = begin_immediate(&mut conn).await?;
141        let mut outcomes = Vec::with_capacity(tasks.len());
142        for task in tasks {
143            outcomes.push(cycle_priority(&mut tx, workspace, task, reverse).await?);
144        }
145        tx.commit().await?;
146        Ok(outcomes)
147    }
148}
149
150pub(crate) async fn set_status(
151    conn: &mut SqliteConnection,
152    workspace: &Workspace,
153    task: &Task,
154    status: &str,
155) -> Result<Task> {
156    set_task_field(conn, workspace, &task.id, "status", status).await?;
157    get_task_in_workspace(conn, workspace, &task.id).await
158}
159
160pub(crate) async fn set_priority(
161    conn: &mut SqliteConnection,
162    workspace: &Workspace,
163    task: &Task,
164    priority: &str,
165) -> Result<Task> {
166    set_task_field(conn, workspace, &task.id, "priority", priority).await?;
167    get_task_in_workspace(conn, workspace, &task.id).await
168}
169
170pub(crate) async fn cycle_priority(
171    conn: &mut SqliteConnection,
172    workspace: &Workspace,
173    task: &Task,
174    reverse: bool,
175) -> Result<Task> {
176    let index = TaskPriority::ALL
177        .iter()
178        .position(|priority| *priority == task.priority)
179        .unwrap_or(0);
180    let next = if reverse {
181        (index + TaskPriority::ALL.len() - 1) % TaskPriority::ALL.len()
182    } else {
183        (index + 1) % TaskPriority::ALL.len()
184    };
185    set_priority(conn, workspace, task, TaskPriority::ALL[next].as_str()).await
186}
187
188pub(crate) async fn set_deleted(
189    conn: &mut SqliteConnection,
190    workspace: &Workspace,
191    task: &Task,
192    deleted: bool,
193) -> Result<Task> {
194    set_task_field(
195        conn,
196        workspace,
197        &task.id,
198        "deleted",
199        if deleted { "1" } else { "0" },
200    )
201    .await?;
202    get_task_in_workspace(conn, workspace, &task.id).await
203}
204
205pub(crate) async fn set_task_field(
206    conn: &mut SqliteConnection,
207    workspace: &Workspace,
208    task_id: &crate::ids::TaskId,
209    field: &str,
210    value: &str,
211) -> Result<bool> {
212    let task_field = TaskField::parse_or_unknown(field)?;
213    if task_field.is_project() {
214        let project = resolve_or_create_project_in_workspace(conn, &workspace.id, value).await?;
215        set_task_project(conn, workspace, task_id, &project).await
216    } else {
217        set_task_scalar_field(conn, workspace, task_id, task_field, value).await
218    }
219}
220
221pub(crate) async fn set_task_project(
222    conn: &mut SqliteConnection,
223    workspace: &Workspace,
224    task_id: &crate::ids::TaskId,
225    project: &Project,
226) -> Result<bool> {
227    let field = TaskField::Project.as_str();
228    let current = current_task(conn, &workspace.id, task_id).await?;
229    if current.project_id == project.id {
230        return Ok(false);
231    }
232    if conflict_exists(conn, &workspace.id, task_id, field).await? {
233        return Err(anyhow::Error::new(OpenConflictError {
234            task_id: task_id.clone(),
235            field,
236        }));
237    }
238    debug!(task_id = %task_id, field = %field, "task field mutation started");
239    let base = field_version(conn, task_id, field).await?;
240    apply_project_id_in_workspace(conn, &workspace.id, task_id, &project.id).await?;
241    let payload = TaskField::project_payload(&workspace.id, &workspace.key, project);
242    finish_task_field_change(conn, task_id, field, payload, base.as_deref()).await?;
243    Ok(true)
244}
245
246async fn set_task_scalar_field(
247    conn: &mut SqliteConnection,
248    workspace: &Workspace,
249    task_id: &crate::ids::TaskId,
250    task_field: TaskField,
251    value: &str,
252) -> Result<bool> {
253    task_field.validate_value(value)?;
254
255    let field = task_field.as_str();
256    let current = current_task(conn, &workspace.id, task_id).await?;
257    if task_field.current_value(&current) == value {
258        return Ok(false);
259    }
260    if conflict_exists(conn, &workspace.id, task_id, field).await? {
261        return Err(anyhow::Error::new(OpenConflictError {
262            task_id: task_id.clone(),
263            field,
264        }));
265    }
266    debug!(task_id = %task_id, field = %field, "task field mutation started");
267    let base = field_version(conn, task_id, field).await?;
268    apply_scalar_field_value_in_workspace(conn, &workspace.id, task_id, task_field, value).await?;
269    let payload = task_field.scalar_payload(&workspace.id, &workspace.key, value)?;
270    finish_task_field_change(conn, task_id, field, payload, base.as_deref()).await?;
271    Ok(true)
272}
273
274async fn finish_task_field_change(
275    conn: &mut SqliteConnection,
276    task_id: &crate::ids::TaskId,
277    field: &str,
278    payload: serde_json::Value,
279    base: Option<&str>,
280) -> Result<()> {
281    let change_id = insert_change(
282        conn,
283        "task",
284        task_id,
285        Some(field),
286        op_type::SET_FIELD,
287        payload,
288        base,
289    )
290    .await?;
291    set_field_version(conn, task_id, field, &change_id).await?;
292    info!(
293        task_id = %task_id,
294        field = %field,
295        change_id = %change_id,
296        "task field mutated"
297    );
298    Ok(())
299}
300
301async fn current_task(
302    conn: &mut SqliteConnection,
303    workspace_id: &WorkspaceId,
304    task_id: &crate::ids::TaskId,
305) -> Result<Task> {
306    let row = sqlx::query(
307        "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id,
308                p.key AS project_key, p.prefix AS project_prefix, t.status,
309                t.priority, t.created_at, t.updated_at, t.queue_activity_at,
310                t.available_at, t.due_on, t.deleted, t.is_epic
311         FROM tasks t
312         JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
313         WHERE t.workspace_id = ? AND t.id = ?",
314    )
315    .bind(workspace_id)
316    .bind(task_id)
317    .fetch_optional(&mut *conn)
318    .await?
319    .ok_or_else(|| {
320        anyhow::anyhow!(
321            "error task-not-found task_id={} workspace_id={}",
322            task_id,
323            workspace_id
324        )
325    })?;
326    task_from_row(&row)
327}
328
329#[allow(dead_code)]
330pub async fn apply_field_value(
331    conn: &mut SqliteConnection,
332    workspace_id: &WorkspaceId,
333    task_id: &crate::ids::TaskId,
334    field: &str,
335    value: &str,
336) -> Result<()> {
337    apply_field_value_in_workspace(conn, workspace_id, task_id, field, value).await
338}
339
340pub async fn apply_project_id_in_workspace(
341    conn: &mut SqliteConnection,
342    workspace_id: &WorkspaceId,
343    task_id: &crate::ids::TaskId,
344    project_id: &ProjectId,
345) -> Result<()> {
346    let project_exists = sqlx::query_scalar::<_, i64>(
347        "SELECT count(*) FROM projects WHERE workspace_id = ? AND id = ? AND deleted = 0",
348    )
349    .bind(workspace_id)
350    .bind(project_id)
351    .fetch_one(&mut *conn)
352    .await?
353        > 0;
354    if !project_exists {
355        bail!("error unknown-project-id id={project_id}");
356    }
357    let ts = now();
358    let rows_affected = sqlx::query(
359        "UPDATE tasks SET project_id = ?, updated_at = ? WHERE workspace_id = ? AND id = ?",
360    )
361    .bind(project_id)
362    .bind(&ts)
363    .bind(workspace_id)
364    .bind(task_id)
365    .execute(&mut *conn)
366    .await?
367    .rows_affected();
368    ensure!(
369        rows_affected == 1,
370        "error task-not-found task_id={} workspace_id={}",
371        task_id,
372        workspace_id
373    );
374    Ok(())
375}
376
377pub async fn apply_field_value_in_workspace(
378    conn: &mut SqliteConnection,
379    workspace_id: &WorkspaceId,
380    task_id: &crate::ids::TaskId,
381    field: &str,
382    value: &str,
383) -> Result<()> {
384    let task_field = TaskField::parse_or_unknown(field)?;
385    apply_scalar_field_value_in_workspace(conn, workspace_id, task_id, task_field, value).await
386}
387
388async fn apply_scalar_field_value_in_workspace(
389    conn: &mut SqliteConnection,
390    workspace_id: &WorkspaceId,
391    task_id: &crate::ids::TaskId,
392    task_field: TaskField,
393    value: &str,
394) -> Result<()> {
395    task_field.validate_value(value)?;
396
397    let ts = now();
398    let activity_at = if task_field.updates_queue_activity() {
399        ts.as_str()
400    } else {
401        ""
402    };
403    let deleted_value = i64::from(value == "1");
404    let epic_value = i64::from(value == "1");
405    let rows_affected = match task_field {
406        TaskField::Title => sqlx::query(
407            "UPDATE tasks SET title = ?, updated_at = ?, queue_activity_at = COALESCE(NULLIF(?, ''), queue_activity_at) WHERE workspace_id = ? AND id = ?",
408        )
409        .bind(value)
410        .bind(&ts)
411        .bind(activity_at)
412        .bind(workspace_id)
413        .bind(task_id)
414        .execute(&mut *conn)
415        .await?
416        .rows_affected(),
417        TaskField::Description => sqlx::query(
418            "UPDATE tasks SET description = ?, updated_at = ?, queue_activity_at = COALESCE(NULLIF(?, ''), queue_activity_at) WHERE workspace_id = ? AND id = ?",
419        )
420        .bind(value)
421        .bind(&ts)
422        .bind(activity_at)
423        .bind(workspace_id)
424        .bind(task_id)
425        .execute(&mut *conn)
426        .await?
427        .rows_affected(),
428        TaskField::Project => bail!("error project-update-requires-project-id"),
429        TaskField::Status => sqlx::query(
430            "UPDATE tasks SET status = ?, updated_at = ?, queue_activity_at = COALESCE(NULLIF(?, ''), queue_activity_at) WHERE workspace_id = ? AND id = ?",
431        )
432        .bind(value)
433        .bind(&ts)
434        .bind(activity_at)
435        .bind(workspace_id)
436        .bind(task_id)
437        .execute(&mut *conn)
438        .await?
439        .rows_affected(),
440        TaskField::Priority => sqlx::query(
441            "UPDATE tasks SET priority = ?, updated_at = ?, queue_activity_at = COALESCE(NULLIF(?, ''), queue_activity_at) WHERE workspace_id = ? AND id = ?",
442        )
443        .bind(value)
444        .bind(&ts)
445        .bind(activity_at)
446        .bind(workspace_id)
447        .bind(task_id)
448        .execute(&mut *conn)
449        .await?
450        .rows_affected(),
451        TaskField::AvailableAt => sqlx::query(
452            "UPDATE tasks SET available_at = ?, updated_at = ?, queue_activity_at = COALESCE(NULLIF(?, ''), queue_activity_at) WHERE workspace_id = ? AND id = ?",
453        )
454        .bind(value)
455        .bind(&ts)
456        .bind(activity_at)
457        .bind(workspace_id)
458        .bind(task_id)
459        .execute(&mut *conn)
460        .await?
461        .rows_affected(),
462        TaskField::DueOn => sqlx::query(
463            "UPDATE tasks SET due_on = ?, updated_at = ? WHERE workspace_id = ? AND id = ?",
464        )
465        .bind(value)
466        .bind(&ts)
467        .bind(workspace_id)
468        .bind(task_id)
469        .execute(&mut *conn)
470        .await?
471        .rows_affected(),
472        TaskField::Deleted => sqlx::query(
473            "UPDATE tasks SET deleted = ?, updated_at = ?, queue_activity_at = COALESCE(NULLIF(?, ''), queue_activity_at) WHERE workspace_id = ? AND id = ?",
474        )
475        .bind(deleted_value)
476        .bind(&ts)
477        .bind(activity_at)
478        .bind(workspace_id)
479        .bind(task_id)
480        .execute(&mut *conn)
481        .await?
482        .rows_affected(),
483        TaskField::IsEpic => sqlx::query(
484            "UPDATE tasks SET is_epic = ?, updated_at = ? WHERE workspace_id = ? AND id = ?",
485        )
486        .bind(epic_value)
487        .bind(&ts)
488        .bind(workspace_id)
489        .bind(task_id)
490        .execute(&mut *conn)
491        .await?
492        .rows_affected(),
493    };
494    ensure!(
495        rows_affected == 1,
496        "error task-not-found task_id={} workspace_id={}",
497        task_id,
498        workspace_id
499    );
500    Ok(())
501}