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::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 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 commands
181 }
182 TaskCreationUndo::TuiEpicChild { epic_id } => vec![UndoCommand::AddEpicChild {
183 epic_id,
184 child_id: task.id.clone(),
185 }],
186 };
187 record_tui_undo(conn, &workspace.id, UndoPayload { commands }).await
188}
189
190pub(super) fn append_created_label_undo_commands(
191 commands: &mut Vec<UndoCommand>,
192 created_labels: &[CreatedLabel],
193) {
194 commands.extend(
195 created_labels
196 .iter()
197 .map(|label| UndoCommand::DeleteCreatedLabel {
198 label: label.name.clone(),
199 create_change_id: label.change_id.clone(),
200 }),
201 );
202}
203
204pub(super) fn validate_task_draft(draft: &TaskDraft) -> Result<()> {
205 TaskStatus::parse(&draft.status)?;
206 TaskPriority::parse(&draft.priority)?;
207 crate::metadata::validate_metadata_update(&draft.metadata, &[])?;
208 if let Some(available_at) = draft.available_at.as_deref() {
209 crate::time_validation::validate_available_at_value(available_at)?;
210 }
211 if let Some(due_on) = draft.due_on.as_deref() {
212 crate::time_validation::validate_due_on_value(due_on)?;
213 }
214 Ok(())
215}
216
217pub(super) fn initial_status(draft: &TaskDraft) -> Result<(TaskStatus, TaskPriority)> {
218 let status = TaskStatus::parse(&draft.status)?;
219 let priority = TaskPriority::parse(&draft.priority)?;
220 let status = if status == TaskStatus::Inbox && priority.promotes_inbox_to_todo() {
221 TaskStatus::Todo
222 } else {
223 status
224 };
225 Ok((status, priority))
226}
227
228pub(super) async fn insert_task(
229 conn: &mut SqliteConnection,
230 workspace: &Workspace,
231 draft: TaskDraft,
232 create_missing_labels: bool,
233 require_existing_project: bool,
234) -> Result<InsertedTask> {
235 let (status, priority) = initial_status(&draft)?;
236 let available_at = draft.available_at.as_deref().unwrap_or("");
237 let due_on = draft.due_on.as_deref().unwrap_or("");
238 let id = TaskId::new();
239 let ts = now();
240 let project = draft
241 .project
242 .as_deref()
243 .ok_or_else(|| anyhow::anyhow!("error project-required"))?;
244 let project = if require_existing_project {
245 resolve_existing_project_in_workspace(conn, &workspace.id, project).await?
246 } else {
247 resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?
248 };
249 let (labels, created_labels) = if create_missing_labels {
250 let resolution =
251 resolve_or_create_labels_in_workspace(conn, workspace, &draft.labels).await?;
252 (resolution.names, resolution.created)
253 } else {
254 (
255 resolve_labels_in_workspace(conn, &workspace.id, &draft.labels).await?,
256 Vec::new(),
257 )
258 };
259 sqlx::query(
260 "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)
261 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
262 )
263 .bind(&workspace.id)
264 .bind(&id)
265 .bind(&draft.title)
266 .bind(&draft.description)
267 .bind(&project.id)
268 .bind(status.as_str())
269 .bind(priority.as_str())
270 .bind(draft.source.as_str())
271 .bind(&ts)
272 .bind(&ts)
273 .bind(&ts)
274 .bind(available_at)
275 .bind(due_on)
276 .bind(i64::from(draft.is_epic))
277 .execute(&mut *conn)
278 .await?;
279 for label in &labels {
280 sqlx::query(
281 "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
282 )
283 .bind(&workspace.id)
284 .bind(&id)
285 .bind(label)
286 .execute(&mut *conn)
287 .await?;
288 }
289 let metadata =
290 crate::metadata::insert_initial_task_metadata(conn, workspace, &id, &draft.metadata, &ts)
291 .await?;
292 let change_id = append_change(
293 conn,
294 ChangeEntity::Task,
295 &id,
296 None,
297 op_type::CREATE_TASK,
298 ChangePayload::workspace(workspace)
299 .set("title", draft.title)
300 .set("description", draft.description)
301 .set("project_id", project.id.clone())
302 .set("project_key", project.key.clone())
303 .set("project_name", project.name.clone())
304 .set("project_prefix", project.prefix.clone())
305 .set("status", status.as_str())
306 .set("priority", priority.as_str())
307 .set("source", draft.source.as_str())
308 .set("available_at", available_at)
309 .set("due_on", due_on)
310 .set("is_epic", if draft.is_epic { "1" } else { "0" })
311 .set("labels", &labels)
312 .set("metadata", &metadata)
313 .set("created_at", ts),
314 )
315 .await?;
316 sqlx::query(
317 "INSERT OR IGNORE INTO task_workspace_history(task_id, workspace_id)
318 VALUES (?, ?)",
319 )
320 .bind(&id)
321 .bind(&workspace.id)
322 .execute(&mut *conn)
323 .await?;
324 for field in TaskField::VERSIONED {
325 set_field_version(conn, &id, field.as_str(), &change_id).await?;
326 }
327 for value in &metadata {
328 set_field_version(
329 conn,
330 &id,
331 &format!("metadata:{}", value.field_id),
332 &change_id,
333 )
334 .await?;
335 }
336 Ok(InsertedTask {
337 id,
338 change_id,
339 project_key: project.key,
340 label_count: labels.len(),
341 created_labels,
342 })
343}