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, ¤t, 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 if let Some(changed) =
213 crate::operations::route_recurrence_task_field(conn, workspace, task_id, field, value)
214 .await?
215 {
216 return Ok(changed);
217 }
218 let task_field = TaskField::parse_or_unknown(field)?;
219 if task_field.is_project() {
220 let project = resolve_or_create_project_in_workspace(conn, &workspace.id, value).await?;
221 set_task_project(conn, workspace, task_id, &project).await
222 } else {
223 set_task_scalar_field(conn, workspace, task_id, task_field, value).await
224 }
225}
226
227pub(crate) async fn set_task_project(
228 conn: &mut SqliteConnection,
229 workspace: &Workspace,
230 task_id: &crate::ids::TaskId,
231 project: &Project,
232) -> Result<bool> {
233 crate::operations::route_recurrence_task_field(
234 conn,
235 workspace,
236 task_id,
237 "project",
238 project.id.as_str(),
239 )
240 .await?;
241 let field = TaskField::Project.as_str();
242 let current = current_task(conn, &workspace.id, task_id).await?;
243 if current.project_id == project.id {
244 return Ok(false);
245 }
246 if conflict_exists(conn, &workspace.id, task_id, field).await? {
247 return Err(anyhow::Error::new(OpenConflictError {
248 task_id: task_id.clone(),
249 field,
250 }));
251 }
252 debug!(task_id = %task_id, field = %field, "task field mutation started");
253 let base = field_version(conn, task_id, field).await?;
254 apply_project_id_in_workspace(conn, &workspace.id, task_id, &project.id).await?;
255 let payload = TaskField::project_payload(&workspace.id, &workspace.key, project);
256 finish_task_field_change(conn, task_id, field, payload, base.as_deref()).await?;
257 Ok(true)
258}
259
260async fn set_task_scalar_field(
261 conn: &mut SqliteConnection,
262 workspace: &Workspace,
263 task_id: &crate::ids::TaskId,
264 task_field: TaskField,
265 value: &str,
266) -> Result<bool> {
267 task_field.validate_value(value)?;
268
269 let field = task_field.as_str();
270 let current = current_task(conn, &workspace.id, task_id).await?;
271 if task_field.current_value(¤t) == value {
272 return Ok(false);
273 }
274 if conflict_exists(conn, &workspace.id, task_id, field).await? {
275 return Err(anyhow::Error::new(OpenConflictError {
276 task_id: task_id.clone(),
277 field,
278 }));
279 }
280 debug!(task_id = %task_id, field = %field, "task field mutation started");
281 let base = field_version(conn, task_id, field).await?;
282 apply_scalar_field_value_in_workspace(conn, &workspace.id, task_id, task_field, value).await?;
283 let payload = task_field.scalar_payload(&workspace.id, &workspace.key, value)?;
284 finish_task_field_change(conn, task_id, field, payload, base.as_deref()).await?;
285 Ok(true)
286}
287
288async fn finish_task_field_change(
289 conn: &mut SqliteConnection,
290 task_id: &crate::ids::TaskId,
291 field: &str,
292 payload: serde_json::Value,
293 base: Option<&str>,
294) -> Result<()> {
295 let change_id = insert_change(
296 conn,
297 "task",
298 task_id,
299 Some(field),
300 op_type::SET_FIELD,
301 payload,
302 base,
303 )
304 .await?;
305 set_field_version(conn, task_id, field, &change_id).await?;
306 info!(
307 task_id = %task_id,
308 field = %field,
309 change_id = %change_id,
310 "task field mutated"
311 );
312 Ok(())
313}
314
315async fn current_task(
316 conn: &mut SqliteConnection,
317 workspace_id: &WorkspaceId,
318 task_id: &crate::ids::TaskId,
319) -> Result<Task> {
320 let row = sqlx::query(
321 "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id,
322 p.key AS project_key, p.prefix AS project_prefix, t.status,
323 t.priority, t.source, t.created_at, t.updated_at, t.queue_activity_at,
324 t.available_at, t.due_on, t.deleted, t.is_epic
325 FROM tasks t
326 JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
327 WHERE t.workspace_id = ? AND t.id = ?",
328 )
329 .bind(workspace_id)
330 .bind(task_id)
331 .fetch_optional(&mut *conn)
332 .await?
333 .ok_or_else(|| {
334 anyhow::anyhow!(
335 "error task-not-found task_id={} workspace_id={}",
336 task_id,
337 workspace_id
338 )
339 })?;
340 task_from_row(&row)
341}
342
343#[allow(dead_code)]
344pub async fn apply_field_value(
345 conn: &mut SqliteConnection,
346 workspace_id: &WorkspaceId,
347 task_id: &crate::ids::TaskId,
348 field: &str,
349 value: &str,
350) -> Result<()> {
351 apply_field_value_in_workspace(conn, workspace_id, task_id, field, value).await
352}
353
354pub async fn apply_project_id_in_workspace(
355 conn: &mut SqliteConnection,
356 workspace_id: &WorkspaceId,
357 task_id: &crate::ids::TaskId,
358 project_id: &ProjectId,
359) -> Result<()> {
360 let project_exists = sqlx::query_scalar::<_, i64>(
361 "SELECT count(*) FROM projects WHERE workspace_id = ? AND id = ? AND deleted = 0",
362 )
363 .bind(workspace_id)
364 .bind(project_id)
365 .fetch_one(&mut *conn)
366 .await?
367 > 0;
368 if !project_exists {
369 bail!("error unknown-project-id id={project_id}");
370 }
371 let ts = now();
372 let rows_affected = sqlx::query(
373 "UPDATE tasks SET project_id = ?, updated_at = ? WHERE workspace_id = ? AND id = ?",
374 )
375 .bind(project_id)
376 .bind(&ts)
377 .bind(workspace_id)
378 .bind(task_id)
379 .execute(&mut *conn)
380 .await?
381 .rows_affected();
382 ensure!(
383 rows_affected == 1,
384 "error task-not-found task_id={} workspace_id={}",
385 task_id,
386 workspace_id
387 );
388 Ok(())
389}
390
391pub async fn apply_field_value_in_workspace(
392 conn: &mut SqliteConnection,
393 workspace_id: &WorkspaceId,
394 task_id: &crate::ids::TaskId,
395 field: &str,
396 value: &str,
397) -> Result<()> {
398 let task_field = TaskField::parse_or_unknown(field)?;
399 apply_scalar_field_value_in_workspace(conn, workspace_id, task_id, task_field, value).await
400}
401
402async fn apply_scalar_field_value_in_workspace(
403 conn: &mut SqliteConnection,
404 workspace_id: &WorkspaceId,
405 task_id: &crate::ids::TaskId,
406 task_field: TaskField,
407 value: &str,
408) -> Result<()> {
409 task_field.validate_value(value)?;
410
411 let ts = now();
412 let activity_at = if task_field.updates_queue_activity() {
413 ts.as_str()
414 } else {
415 ""
416 };
417 let deleted_value = i64::from(value == "1");
418 let epic_value = i64::from(value == "1");
419 let rows_affected = match task_field {
420 TaskField::Title => sqlx::query(
421 "UPDATE tasks SET title = ?, updated_at = ?, queue_activity_at = COALESCE(NULLIF(?, ''), queue_activity_at) WHERE workspace_id = ? AND id = ?",
422 )
423 .bind(value)
424 .bind(&ts)
425 .bind(activity_at)
426 .bind(workspace_id)
427 .bind(task_id)
428 .execute(&mut *conn)
429 .await?
430 .rows_affected(),
431 TaskField::Description => sqlx::query(
432 "UPDATE tasks SET description = ?, updated_at = ?, queue_activity_at = COALESCE(NULLIF(?, ''), queue_activity_at) WHERE workspace_id = ? AND id = ?",
433 )
434 .bind(value)
435 .bind(&ts)
436 .bind(activity_at)
437 .bind(workspace_id)
438 .bind(task_id)
439 .execute(&mut *conn)
440 .await?
441 .rows_affected(),
442 TaskField::Project => bail!("error project-update-requires-project-id"),
443 TaskField::Status => sqlx::query(
444 "UPDATE tasks SET status = ?, updated_at = ?, queue_activity_at = COALESCE(NULLIF(?, ''), queue_activity_at) WHERE workspace_id = ? AND id = ?",
445 )
446 .bind(value)
447 .bind(&ts)
448 .bind(activity_at)
449 .bind(workspace_id)
450 .bind(task_id)
451 .execute(&mut *conn)
452 .await?
453 .rows_affected(),
454 TaskField::Priority => sqlx::query(
455 "UPDATE tasks SET priority = ?, updated_at = ?, queue_activity_at = COALESCE(NULLIF(?, ''), queue_activity_at) WHERE workspace_id = ? AND id = ?",
456 )
457 .bind(value)
458 .bind(&ts)
459 .bind(activity_at)
460 .bind(workspace_id)
461 .bind(task_id)
462 .execute(&mut *conn)
463 .await?
464 .rows_affected(),
465 TaskField::AvailableAt => sqlx::query(
466 "UPDATE tasks SET available_at = ?, updated_at = ?, queue_activity_at = COALESCE(NULLIF(?, ''), queue_activity_at) WHERE workspace_id = ? AND id = ?",
467 )
468 .bind(value)
469 .bind(&ts)
470 .bind(activity_at)
471 .bind(workspace_id)
472 .bind(task_id)
473 .execute(&mut *conn)
474 .await?
475 .rows_affected(),
476 TaskField::DueOn => sqlx::query(
477 "UPDATE tasks SET due_on = ?, updated_at = ? WHERE workspace_id = ? AND id = ?",
478 )
479 .bind(value)
480 .bind(&ts)
481 .bind(workspace_id)
482 .bind(task_id)
483 .execute(&mut *conn)
484 .await?
485 .rows_affected(),
486 TaskField::Deleted => sqlx::query(
487 "UPDATE tasks SET deleted = ?, updated_at = ?, queue_activity_at = COALESCE(NULLIF(?, ''), queue_activity_at) WHERE workspace_id = ? AND id = ?",
488 )
489 .bind(deleted_value)
490 .bind(&ts)
491 .bind(activity_at)
492 .bind(workspace_id)
493 .bind(task_id)
494 .execute(&mut *conn)
495 .await?
496 .rows_affected(),
497 TaskField::IsEpic => sqlx::query(
498 "UPDATE tasks SET is_epic = ?, updated_at = ? WHERE workspace_id = ? AND id = ?",
499 )
500 .bind(epic_value)
501 .bind(&ts)
502 .bind(workspace_id)
503 .bind(task_id)
504 .execute(&mut *conn)
505 .await?
506 .rows_affected(),
507 };
508 ensure!(
509 rows_affected == 1,
510 "error task-not-found task_id={} workspace_id={}",
511 task_id,
512 workspace_id
513 );
514 Ok(())
515}