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