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