1use std::collections::{BTreeMap, BTreeSet, HashMap};
2
3use anyhow::{Result, ensure};
4use sqlx::SqliteConnection;
5
6use super::creation::{initial_status, insert_task, validate_task_draft};
7use super::mutation::{
8 append_task_undo_commands, apply_task_update, update_requests_inbox_promotion,
9 validate_task_update,
10};
11use super::notes::add_note_in_transaction;
12use super::{TaskDraft, TaskUpdate};
13use crate::choices::{TaskPriority, TaskStatus};
14use crate::db::{Database, begin_immediate, conflict_exists};
15use crate::error::CoreError;
16use crate::ids::{ProjectId, TaskId, WorkspaceId};
17use crate::labels::resolve_labels_in_workspace;
18use crate::projects::{find_project_by_id_in_workspace, resolve_existing_project_in_workspace};
19use crate::refs::{DisplayRefContext, get_task_in_workspace};
20use crate::task_fields::TaskField;
21use crate::types::{Project, Task};
22use crate::undo::{TaskUndoSnapshot, UndoCommand, apply_consumer_undo_commands, task_snapshot};
23use crate::workspaces::{Workspace, workspace_for_id};
24
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26pub enum ChangeTarget {
27 Existing(TaskId),
28 Local(String),
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct CreatedTaskRef {
34 pub task_id: TaskId,
35 pub display_ref: String,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ChangeSetOutcome {
40 pub created: BTreeMap<String, CreatedTaskRef>,
41 pub undo_token: String,
42}
43
44pub(crate) enum ChangeSetOperation {
45 Create {
46 local_id: String,
47 draft: TaskDraft,
48 },
49 Update {
50 target: ChangeTarget,
51 project_id: Option<ProjectId>,
52 update: TaskUpdate,
53 },
54 AddNote {
55 target: ChangeTarget,
56 body: String,
57 },
58}
59
60pub(crate) struct IndexedChangeSetError {
61 pub operation_index: Option<usize>,
62 pub error: anyhow::Error,
63}
64
65impl From<anyhow::Error> for IndexedChangeSetError {
66 fn from(error: anyhow::Error) -> Self {
67 Self {
68 operation_index: None,
69 error,
70 }
71 }
72}
73
74impl IndexedChangeSetError {
75 fn at(index: usize, error: anyhow::Error) -> Self {
76 Self {
77 operation_index: Some(index),
78 error,
79 }
80 }
81}
82
83pub(crate) struct AppliedChangeSet {
84 pub outcome: ChangeSetOutcome,
85 pub note_ids: Vec<String>,
86}
87
88#[derive(serde::Serialize, serde::Deserialize)]
89#[serde(tag = "kind", rename = "change_set")]
90pub(crate) struct ChangeSetUndoToken {
91 workspace_id: WorkspaceId,
92 expected: BTreeMap<TaskId, TaskUndoSnapshot>,
93 notes: Vec<NoteGuard>,
94 groups: Vec<InverseGroup>,
95}
96
97#[derive(serde::Serialize, serde::Deserialize)]
98struct NoteGuard {
99 task_id: TaskId,
100 note_id: String,
101 body: String,
102 change_id: String,
103}
104
105#[derive(serde::Serialize, serde::Deserialize)]
106struct InverseGroup {
107 task_id: TaskId,
108 queue_activity_before: Option<String>,
109 commands: Vec<UndoCommand>,
110}
111
112impl Database {
113 pub(crate) async fn apply_consumer_change_set(
114 &self,
115 workspace_id: &WorkspaceId,
116 ops: Vec<ChangeSetOperation>,
117 ) -> std::result::Result<AppliedChangeSet, IndexedChangeSetError> {
118 let mut conn = self.acquire_writer().await?;
119 let mut tx = begin_immediate(&mut conn)
120 .await
121 .map_err(anyhow::Error::from)?;
122 let workspace = workspace_for_id(&mut tx, workspace_id).await?;
123 validate_operations(&mut tx, &workspace, &ops).await?;
124 let mut locals = BTreeMap::new();
125 let mut token = ChangeSetUndoToken {
126 workspace_id: workspace_id.clone(),
127 expected: BTreeMap::new(),
128 notes: Vec::new(),
129 groups: Vec::new(),
130 };
131 for (index, op) in ops.into_iter().enumerate() {
132 execute_operation(&mut tx, &workspace, op, &mut locals, &mut token)
133 .await
134 .map_err(|error| IndexedChangeSetError::at(index, error))?;
135 }
136 for group in &token.groups {
138 if let std::collections::btree_map::Entry::Vacant(entry) =
139 token.expected.entry(group.task_id.clone())
140 {
141 entry.insert(task_snapshot(&mut tx, workspace_id, &group.task_id).await?);
142 }
143 }
144 let refs = DisplayRefContext::for_workspace(&mut tx, workspace_id).await?;
145 let mut created = BTreeMap::new();
146 for (local_id, task_id) in locals {
147 let task = get_task_in_workspace(&mut tx, &workspace, &task_id).await?;
148 created.insert(
149 local_id,
150 CreatedTaskRef {
151 task_id,
152 display_ref: refs.display_ref(&task),
153 },
154 );
155 }
156 let undo_token = serde_json::to_string(&token).map_err(anyhow::Error::from)?;
157 let note_ids = token.notes.into_iter().map(|note| note.note_id).collect();
158 tx.commit().await.map_err(anyhow::Error::from)?;
159 Ok(AppliedChangeSet {
160 outcome: ChangeSetOutcome {
161 created,
162 undo_token,
163 },
164 note_ids,
165 })
166 }
167
168 pub(crate) async fn undo_consumer_change_set(&self, token: ChangeSetUndoToken) -> Result<()> {
169 let mut conn = self.acquire_writer().await?;
170 let mut tx = begin_immediate(&mut conn).await?;
171 workspace_for_id(&mut tx, &token.workspace_id).await?;
172 for (task_id, expected) in &token.expected {
173 let current = task_snapshot(&mut tx, &token.workspace_id, task_id).await?;
174 ensure!(
175 current == *expected,
176 CoreError::generation_conflict("task changed after the change set")
177 );
178 }
179 for note in &token.notes {
180 let current: Option<(String, String)> = sqlx::query_as(
181 "SELECT body, change_id FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
182 ).bind(&token.workspace_id).bind(¬e.task_id).bind(¬e.note_id)
183 .fetch_optional(&mut *tx).await?;
184 ensure!(
185 current
186 .as_ref()
187 .is_some_and(|(body, change)| body == ¬e.body && change == ¬e.change_id),
188 CoreError::generation_conflict("note changed after the change set")
189 );
190 }
191 for group in token.groups.into_iter().rev() {
192 apply_consumer_undo_commands(&mut tx, &token.workspace_id, &group.commands).await?;
193 if let Some(activity) = group.queue_activity_before {
194 sqlx::query(
195 "UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?",
196 )
197 .bind(activity)
198 .bind(&token.workspace_id)
199 .bind(&group.task_id)
200 .execute(&mut *tx)
201 .await?;
202 }
203 }
204 tx.commit().await?;
205 Ok(())
206 }
207}
208
209async fn validate_operations(
210 conn: &mut SqliteConnection,
211 workspace: &Workspace,
212 ops: &[ChangeSetOperation],
213) -> std::result::Result<(), IndexedChangeSetError> {
214 let mut tasks = HashMap::<ChangeTarget, Task>::new();
215 let at = crate::ids::now_utc();
216 for (index, op) in ops.iter().enumerate() {
217 let result: Result<()> = async {
218 match op {
219 ChangeSetOperation::Create { local_id, draft } => {
220 ensure!(
221 !local_id.trim().is_empty(),
222 CoreError::validation("local ID must not be empty")
223 );
224 let target = ChangeTarget::Local(local_id.clone());
225 ensure!(
226 !tasks.contains_key(&target),
227 CoreError::validation("duplicate local ID")
228 );
229 validate_task_draft(draft)?;
230 let project = resolve_existing_project_in_workspace(
231 conn,
232 &workspace.id,
233 draft
234 .project
235 .as_deref()
236 .expect("capture requires a project"),
237 )
238 .await?;
239 resolve_labels_in_workspace(conn, &workspace.id, &draft.labels).await?;
240 let (status, priority) = initial_status(draft)?;
241 tasks.insert(
242 target,
243 Task {
244 id: TaskId::new(),
245 workspace_id: workspace.id.clone(),
246 title: draft.title.clone(),
247 description: draft.description.clone(),
248 project_id: project.id,
249 project_key: project.key,
250 project_prefix: project.prefix,
251 status,
252 priority,
253 source: draft.source,
254 created_at: String::new(),
255 updated_at: String::new(),
256 queue_activity_at: String::new(),
257 available_at: draft.available_at.clone(),
258 due_on: draft.due_on.clone(),
259 deleted: false,
260 is_epic: false,
261 },
262 );
263 }
264 ChangeSetOperation::Update {
265 target,
266 project_id,
267 update,
268 } => {
269 let task = effective_task(conn, workspace, target, &mut tasks).await?;
270 validate_task_update(update)?;
271 let project = match project_id {
272 Some(id) => Some(require_project(conn, workspace, id).await?),
273 None => None,
274 };
275 let fields = consumer_update_fields(task, project.as_ref(), update);
276 let target_status = fields
277 .iter()
278 .find(|(field, _)| *field == TaskField::Status)
279 .map(|(_, value)| TaskStatus::parse(value))
280 .transpose()?;
281 if matches!(target, ChangeTarget::Existing(_)) {
282 crate::operations::recurrence::validate_consumer_recurrence_mutation(
283 conn,
284 workspace,
285 &task.id,
286 task.status,
287 target_status,
288 at,
289 )
290 .await?;
291 }
292 for (field, value) in fields {
293 if field.current_value(task) != value
294 && matches!(target, ChangeTarget::Existing(_))
295 {
296 ensure!(
297 !conflict_exists(conn, &workspace.id, &task.id, field.as_str())
298 .await?,
299 CoreError::open_conflict(format!(
300 "error conflicted-field ref={} field={}",
301 task.id,
302 field.as_str()
303 ))
304 );
305 }
306 set_effective_field(task, field, value)?;
307 }
308 resolve_labels_in_workspace(conn, &workspace.id, &update.add_labels).await?;
309 resolve_labels_in_workspace(conn, &workspace.id, &update.remove_labels).await?;
310 if matches!(target, ChangeTarget::Existing(_)) {
311 crate::metadata::validate_task_metadata_result(
312 conn,
313 &workspace.id,
314 &task.id,
315 &[],
316 &[],
317 )
318 .await?;
319 }
320 }
321 ChangeSetOperation::AddNote { target, .. } => {
322 let task = effective_task(conn, workspace, target, &mut tasks).await?;
323 if matches!(target, ChangeTarget::Existing(_)) {
324 crate::operations::recurrence::validate_consumer_recurrence_mutation(
325 conn,
326 workspace,
327 &task.id,
328 task.status,
329 None,
330 at,
331 )
332 .await?;
333 }
334 }
335 }
336 Ok(())
337 }
338 .await;
339 result.map_err(|error| IndexedChangeSetError::at(index, error))?;
340 }
341 Ok(())
342}
343
344async fn effective_task<'a>(
345 conn: &mut SqliteConnection,
346 workspace: &Workspace,
347 target: &ChangeTarget,
348 tasks: &'a mut HashMap<ChangeTarget, Task>,
349) -> Result<&'a mut Task> {
350 if !tasks.contains_key(target) {
351 let ChangeTarget::Existing(task_id) = target else {
352 return Err(
353 CoreError::validation("local target must refer to an earlier create").into(),
354 );
355 };
356 let task = get_task_in_workspace(conn, workspace, task_id).await?;
357 ensure!(!task.deleted, CoreError::not_found("task is deleted"));
358 tasks.insert(target.clone(), task);
359 }
360 Ok(tasks.get_mut(target).expect("effective task loaded"))
361}
362
363async fn require_project(
364 conn: &mut SqliteConnection,
365 workspace: &Workspace,
366 id: &ProjectId,
367) -> Result<Project> {
368 find_project_by_id_in_workspace(conn, &workspace.id, id)
369 .await?
370 .ok_or_else(|| CoreError::not_found("selected project is unavailable").into())
371}
372
373fn consumer_update_fields(
374 task: &Task,
375 project: Option<&Project>,
376 update: &TaskUpdate,
377) -> Vec<(TaskField, String)> {
378 let mut fields = Vec::new();
379 if let Some(project) = project {
380 fields.push((TaskField::Project, project.id.to_string()));
381 }
382 if let Some(value) = &update.title {
383 fields.push((TaskField::Title, value.clone()));
384 }
385 if let Some(value) = &update.description {
386 fields.push((TaskField::Description, value.clone()));
387 }
388 let promote = task.status == TaskStatus::Inbox && update_requests_inbox_promotion(update);
389 if promote {
390 fields.push((TaskField::Status, "todo".into()));
391 } else if let Some(value) = &update.status {
392 fields.push((TaskField::Status, value.clone()));
393 }
394 if let Some(value) = &update.priority {
395 fields.push((TaskField::Priority, value.clone()));
396 }
397 if let Some(value) = &update.available_at {
398 fields.push((TaskField::AvailableAt, value.clone().unwrap_or_default()));
399 }
400 if let Some(value) = &update.due_on {
401 fields.push((TaskField::DueOn, value.clone().unwrap_or_default()));
402 }
403 fields
404}
405
406fn set_effective_field(task: &mut Task, field: TaskField, value: String) -> Result<()> {
407 match field {
408 TaskField::Title => task.title = value,
409 TaskField::Description => task.description = value,
410 TaskField::Project => task.project_id = value.parse()?,
411 TaskField::Status => task.status = TaskStatus::parse(&value)?,
412 TaskField::Priority => task.priority = TaskPriority::parse(&value)?,
413 TaskField::AvailableAt => task.available_at = (!value.is_empty()).then_some(value),
414 TaskField::DueOn => task.due_on = (!value.is_empty()).then_some(value),
415 TaskField::Deleted | TaskField::IsEpic => {
416 unreachable!("consumer edit does not expose these fields")
417 }
418 }
419 Ok(())
420}
421
422fn resolve_target(target: ChangeTarget, locals: &BTreeMap<String, TaskId>) -> TaskId {
423 match target {
424 ChangeTarget::Existing(id) => id,
425 ChangeTarget::Local(local) => locals.get(&local).expect("local target validated").clone(),
426 }
427}
428
429async fn execute_operation(
430 conn: &mut SqliteConnection,
431 workspace: &Workspace,
432 op: ChangeSetOperation,
433 locals: &mut BTreeMap<String, TaskId>,
434 token: &mut ChangeSetUndoToken,
435) -> Result<()> {
436 let group = match op {
437 ChangeSetOperation::Create { local_id, draft } => {
438 let inserted = insert_task(conn, workspace, draft, false, true).await?;
439 let expected = task_snapshot(conn, &workspace.id, &inserted.id).await?;
440 locals.insert(local_id, inserted.id.clone());
441 InverseGroup {
442 task_id: inserted.id.clone(),
443 queue_activity_before: None,
444 commands: vec![UndoCommand::DeleteCreatedTask {
445 task_id: inserted.id,
446 create_change_id: None,
447 expected,
448 attachment_ids: Vec::new(),
449 attachment_change_ids: Vec::new(),
450 }],
451 }
452 }
453 ChangeSetOperation::Update {
454 target,
455 project_id,
456 update,
457 } => {
458 let task_id = resolve_target(target, locals);
459 let before = task_snapshot(conn, &workspace.id, &task_id).await?;
460 if let Some(id) = project_id {
461 let project = require_project(conn, workspace, &id).await?;
462 crate::mutation::set_task_project(conn, workspace, &task_id, &project).await?;
463 }
464 apply_task_update(conn, workspace, &task_id, &update, &mut BTreeSet::new()).await?;
465 let after = task_snapshot(conn, &workspace.id, &task_id).await?;
466 let mut commands = Vec::new();
467 append_task_undo_commands(&task_id, &before, &after, &mut commands);
468 InverseGroup {
469 task_id,
470 queue_activity_before: Some(before.queue_activity_at),
471 commands,
472 }
473 }
474 ChangeSetOperation::AddNote { target, body } => {
475 let task_id = resolve_target(target, locals);
476 let before = get_task_in_workspace(conn, workspace, &task_id).await?;
477 let note = add_note_in_transaction(conn, workspace, &task_id, body.clone()).await?;
478 token.notes.push(NoteGuard {
479 task_id: task_id.clone(),
480 note_id: note.note_id.clone(),
481 body,
482 change_id: note.change_id.clone(),
483 });
484 InverseGroup {
485 task_id: task_id.clone(),
486 queue_activity_before: Some(before.queue_activity_at),
487 commands: vec![UndoCommand::DeleteCreatedNote {
488 task_id,
489 note_id: note.note_id,
490 note_add_change_id: note.change_id,
491 restoration_change_ids: Vec::new(),
492 }],
493 }
494 }
495 };
496 if !group.commands.is_empty() {
497 token.groups.push(group);
498 }
499 Ok(())
500}