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 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(¤t) == 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}