Skip to main content

aven_core/operations/tasks/
creation.rs

1use super::{InsertedTask, TaskCreationOptions, TaskCreationUndo, TaskDraft, TaskOutcome};
2use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
3use crate::choices::{TaskPriority, TaskStatus};
4use crate::db::{Database, begin_immediate, set_field_version};
5use crate::ids::{TaskId, now};
6use crate::labels::CreatedLabel;
7use crate::labels::{resolve_labels_in_workspace, resolve_or_create_labels_in_workspace};
8use crate::projects::{
9    resolve_existing_project_in_workspace, resolve_or_create_project_in_workspace,
10};
11use crate::refs::{DisplayRefContext, get_task_in_workspace};
12use crate::task_fields::TaskField;
13use crate::types::Task;
14use crate::undo::{UndoCommand, UndoPayload, record_tui_undo, task_snapshot};
15use crate::workspaces::Workspace;
16use anyhow::Result;
17use sqlx::SqliteConnection;
18use tracing::info;
19
20impl Database {
21    pub async fn create_task(
22        &self,
23        workspace: &Workspace,
24        draft: TaskDraft,
25    ) -> Result<TaskOutcome> {
26        self.create_task_with_undo(workspace, draft, TaskCreationUndo::None)
27            .await
28    }
29
30    pub async fn create_task_with_undo(
31        &self,
32        workspace: &Workspace,
33        draft: TaskDraft,
34        undo: TaskCreationUndo,
35    ) -> Result<TaskOutcome> {
36        self.create_task_with_options(workspace, draft, TaskCreationOptions::standalone(undo))
37            .await
38    }
39
40    pub async fn create_task_with_options(
41        &self,
42        workspace: &Workspace,
43        draft: TaskDraft,
44        options: TaskCreationOptions,
45    ) -> Result<TaskOutcome> {
46        let mut conn = self.acquire_writer().await?;
47        create_task_with_epic(&mut conn, workspace, draft, options).await
48    }
49
50    pub async fn create_task_for_epic(
51        &self,
52        workspace: &Workspace,
53        draft: TaskDraft,
54        epic_id: &TaskId,
55    ) -> Result<TaskOutcome> {
56        self.create_task_for_epic_with_undo(workspace, draft, epic_id, TaskCreationUndo::None)
57            .await
58    }
59
60    pub async fn create_task_for_epic_with_undo(
61        &self,
62        workspace: &Workspace,
63        draft: TaskDraft,
64        epic_id: &TaskId,
65        undo: TaskCreationUndo,
66    ) -> Result<TaskOutcome> {
67        let mut conn = self.acquire_writer().await?;
68        create_task_with_epic(
69            &mut conn,
70            workspace,
71            draft,
72            TaskCreationOptions::for_epic(epic_id.clone(), undo),
73        )
74        .await
75    }
76}
77
78#[cfg(any(test, feature = "test-support"))]
79pub async fn create_task(
80    conn: &mut SqliteConnection,
81    workspace: &Workspace,
82    draft: TaskDraft,
83) -> Result<TaskOutcome> {
84    create_task_with_epic(
85        conn,
86        workspace,
87        draft,
88        TaskCreationOptions::standalone(TaskCreationUndo::None),
89    )
90    .await
91}
92
93async fn create_task_with_epic(
94    conn: &mut SqliteConnection,
95    workspace: &Workspace,
96    draft: TaskDraft,
97    options: TaskCreationOptions,
98) -> Result<TaskOutcome> {
99    validate_task_draft(&draft)?;
100    let TaskCreationOptions {
101        epic_id,
102        undo,
103        create_missing_labels,
104        require_existing_project,
105        capture_undo_snapshot,
106        require_existing_epic,
107    } = options;
108    let mut tx = begin_immediate(conn).await?;
109    if require_existing_epic && let Some(epic_id) = &epic_id {
110        crate::operations::epics::require_consumer_epic(&mut tx, workspace, epic_id).await?;
111    }
112    let inserted = insert_task(
113        &mut tx,
114        workspace,
115        draft,
116        create_missing_labels,
117        require_existing_project,
118    )
119    .await?;
120    if let Some(epic_id) = epic_id {
121        crate::operations::add_task_to_epic_in_transaction(
122            &mut tx,
123            workspace,
124            &inserted.id,
125            &epic_id,
126        )
127        .await?;
128    }
129    let task = get_task_in_workspace(&mut tx, workspace, &inserted.id).await?;
130    record_task_creation_undo(
131        &mut tx,
132        workspace,
133        &task,
134        &inserted,
135        Vec::new(),
136        Vec::new(),
137        undo,
138    )
139    .await?;
140    let undo_snapshot = if capture_undo_snapshot {
141        Some(task_snapshot(&mut tx, &workspace.id, &task.id).await?)
142    } else {
143        None
144    };
145    tx.commit().await?;
146    info!(
147        task_id = %inserted.id,
148        project_key = %inserted.project_key,
149        label_count = inserted.label_count,
150        "task created"
151    );
152    Ok(TaskOutcome {
153        task,
154        create_change_id: Some(inserted.change_id),
155        attachment_change_ids: Vec::new(),
156        undo_snapshot,
157    })
158}
159
160pub(super) async fn record_task_creation_undo(
161    conn: &mut SqliteConnection,
162    workspace: &Workspace,
163    task: &Task,
164    inserted: &InsertedTask,
165    attachment_ids: Vec<String>,
166    attachment_change_ids: Vec<String>,
167    undo: TaskCreationUndo,
168) -> Result<()> {
169    let (summary, commands) = match undo {
170        TaskCreationUndo::None => return Ok(()),
171        TaskCreationUndo::TuiTask => {
172            let mut commands = vec![UndoCommand::DeleteCreatedTask {
173                task_id: task.id.clone(),
174                create_change_id: Some(inserted.change_id.clone()),
175                expected: task_snapshot(conn, &workspace.id, &task.id).await?,
176                attachment_ids,
177                attachment_change_ids,
178            }];
179            append_created_label_undo_commands(&mut commands, &inserted.created_labels);
180            (format!("task {}", task.id), commands)
181        }
182        TaskCreationUndo::TuiEpicChild {
183            epic_id,
184            epic_display_ref,
185        } => {
186            let display_refs = DisplayRefContext::for_workspace(conn, &workspace.id).await?;
187            let child_ref = display_refs.display_ref(task);
188            (
189                format!("add {child_ref} to {epic_display_ref}"),
190                vec![UndoCommand::AddEpicChild {
191                    epic_id,
192                    child_id: task.id.clone(),
193                }],
194            )
195        }
196    };
197    record_tui_undo(conn, &workspace.id, &summary, UndoPayload { commands }).await
198}
199
200pub(super) fn append_created_label_undo_commands(
201    commands: &mut Vec<UndoCommand>,
202    created_labels: &[CreatedLabel],
203) {
204    commands.extend(
205        created_labels
206            .iter()
207            .map(|label| UndoCommand::DeleteCreatedLabel {
208                label: label.name.clone(),
209                create_change_id: label.change_id.clone(),
210            }),
211    );
212}
213
214pub(super) fn validate_task_draft(draft: &TaskDraft) -> Result<()> {
215    TaskStatus::parse(&draft.status)?;
216    TaskPriority::parse(&draft.priority)?;
217    crate::metadata::validate_metadata_update(&draft.metadata, &[])?;
218    if let Some(available_at) = draft.available_at.as_deref() {
219        crate::time_validation::validate_available_at_value(available_at)?;
220    }
221    if let Some(due_on) = draft.due_on.as_deref() {
222        crate::time_validation::validate_due_on_value(due_on)?;
223    }
224    Ok(())
225}
226
227pub(super) async fn insert_task(
228    conn: &mut SqliteConnection,
229    workspace: &Workspace,
230    draft: TaskDraft,
231    create_missing_labels: bool,
232    require_existing_project: bool,
233) -> Result<InsertedTask> {
234    let status = TaskStatus::parse(&draft.status)?;
235    let priority = TaskPriority::parse(&draft.priority)?;
236    let status = if status == TaskStatus::Inbox && priority.promotes_inbox_to_todo() {
237        TaskStatus::Todo
238    } else {
239        status
240    };
241    let available_at = draft.available_at.as_deref().unwrap_or("");
242    let due_on = draft.due_on.as_deref().unwrap_or("");
243    let id = TaskId::new();
244    let ts = now();
245    let project = draft
246        .project
247        .as_deref()
248        .ok_or_else(|| anyhow::anyhow!("error project-required"))?;
249    let project = if require_existing_project {
250        resolve_existing_project_in_workspace(conn, &workspace.id, project).await?
251    } else {
252        resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?
253    };
254    let (labels, created_labels) = if create_missing_labels {
255        let resolution =
256            resolve_or_create_labels_in_workspace(conn, workspace, &draft.labels).await?;
257        (resolution.names, resolution.created)
258    } else {
259        (
260            resolve_labels_in_workspace(conn, &workspace.id, &draft.labels).await?,
261            Vec::new(),
262        )
263    };
264    sqlx::query(
265        "INSERT INTO tasks(workspace_id, id, title, description, project_id, status, priority, source, created_at, updated_at, queue_activity_at, available_at, due_on, is_epic)
266         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
267    )
268    .bind(&workspace.id)
269    .bind(&id)
270    .bind(&draft.title)
271    .bind(&draft.description)
272    .bind(&project.id)
273    .bind(status.as_str())
274    .bind(priority.as_str())
275    .bind(draft.source.as_str())
276    .bind(&ts)
277    .bind(&ts)
278    .bind(&ts)
279    .bind(available_at)
280    .bind(due_on)
281    .bind(i64::from(draft.is_epic))
282    .execute(&mut *conn)
283    .await?;
284    for label in &labels {
285        sqlx::query(
286            "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
287        )
288        .bind(&workspace.id)
289        .bind(&id)
290        .bind(label)
291        .execute(&mut *conn)
292        .await?;
293    }
294    let metadata =
295        crate::metadata::insert_initial_task_metadata(conn, workspace, &id, &draft.metadata, &ts)
296            .await?;
297    let change_id = append_change(
298        conn,
299        ChangeEntity::Task,
300        &id,
301        None,
302        op_type::CREATE_TASK,
303        ChangePayload::workspace(workspace)
304            .set("title", draft.title)
305            .set("description", draft.description)
306            .set("project_id", project.id.clone())
307            .set("project_key", project.key.clone())
308            .set("project_name", project.name.clone())
309            .set("project_prefix", project.prefix.clone())
310            .set("status", status.as_str())
311            .set("priority", priority.as_str())
312            .set("source", draft.source.as_str())
313            .set("available_at", available_at)
314            .set("due_on", due_on)
315            .set("is_epic", if draft.is_epic { "1" } else { "0" })
316            .set("labels", &labels)
317            .set("metadata", &metadata)
318            .set("created_at", ts),
319    )
320    .await?;
321    for field in TaskField::VERSIONED {
322        set_field_version(conn, &id, field.as_str(), &change_id).await?;
323    }
324    for value in &metadata {
325        set_field_version(
326            conn,
327            &id,
328            &format!("metadata:{}", value.field_id),
329            &change_id,
330        )
331        .await?;
332    }
333    Ok(InsertedTask {
334        id,
335        change_id,
336        project_key: project.key,
337        label_count: labels.len(),
338        created_labels,
339    })
340}