1mod epics;
2pub use epics::{EpicProgress, EpicSummary};
3
4use std::fmt;
5use std::path::{Path, PathBuf};
6
7use anyhow::Error as InternalError;
8use chrono::{Days, LocalResult, NaiveDate, NaiveTime, TimeZone, Utc, Weekday};
9
10pub use crate::attachments::AttachmentBytesState as AttachmentAvailability;
11use crate::choices::{TaskPriority, TaskSource, TaskStatus};
12use crate::db::Database;
13use crate::ids::{MetadataFieldId, ProjectId, TaskId, WorkspaceId};
14use crate::metadata::{MetadataFieldUsage, TaskMetadataInput, TaskMetadataValue};
15use crate::operations::{
16 ConsumerTaskMutation as InternalConsumerTaskMutation, CreateRecurrenceSeriesParams,
17 RecurrenceSeriesDraft, RecurrenceTemplateUpdate as InternalRecurrenceTemplateUpdate,
18 TaskCreationOptions, TaskDraft, TaskUpdate as InternalTaskUpdate,
19 UpdateRecurrenceTemplateParams,
20};
21use crate::query::{
22 MAX_RECURRENCE_HISTORY_LIMIT, RecurrenceCounts as InternalRecurrenceCounts,
23 RecurrenceHistoryEntry as InternalRecurrenceHistoryEntry,
24 RecurrenceSeriesDetail as InternalRecurrenceSeriesDetail,
25 RecurrenceSeriesSummary as InternalRecurrenceSeriesSummary, SortDirection,
26 TaskAvailabilityFilter, TaskFilters, TaskListItem, TaskQueryMode, TaskSearchQuery, TaskSort,
27};
28pub use crate::query::{RecurrenceHistoryKind, SearchMatchedField};
29pub use crate::queue::{QueueBand, QueueDateKind, QueueReason};
30pub use crate::recurrence::{
31 RecurrenceDuePolicy, RecurrenceFrequency, RecurrenceOutcome, RecurrenceProjectionState,
32 RecurrenceSeriesState,
33};
34use crate::recurrence::{
35 RecurrenceRule as InternalRecurrenceRule, RecurrenceSchedule, RecurrenceSeriesId, TimeZoneId,
36 WeekdaySet,
37};
38use crate::task_fields::TaskField;
39use crate::types::{Project, RecurrenceOccurrence, RecurrenceSeries, Task};
40use crate::undo::TaskUndoSnapshot;
41use crate::workspaces::Workspace;
42
43#[derive(Clone)]
44pub struct Store {
45 database: Database,
46 #[cfg(test)]
47 fail_queue_reads: std::sync::Arc<std::sync::atomic::AtomicBool>,
48}
49
50impl Store {
51 pub async fn open(path: impl AsRef<Path>) -> Result<Self, Error> {
52 let database = Database::open(path.as_ref())
53 .await
54 .map_err(Error::database_open)?;
55 Ok(Self {
56 database,
57 #[cfg(test)]
58 fail_queue_reads: Default::default(),
59 })
60 }
61
62 pub async fn close(&self) {
64 self.database.close().await;
65 }
66
67 pub fn initialize_storage(&self) -> Result<StorageLayout, Error> {
68 let root = crate::attachments::default_blob_dir(self.database.path());
69 let objects = root.join("objects").join("sha256");
70 let trash = root.join("trash");
71 let previews = root.join("cache").join("previews");
72 for directory in [&objects, &trash, &previews] {
73 std::fs::create_dir_all(directory)
74 .map_err(|error| Error::from_internal(error.into()))?;
75 }
76 Ok(StorageLayout {
77 root,
78 staging: objects.clone(),
79 objects,
80 trash,
81 previews,
82 })
83 }
84
85 pub async fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>, Error> {
86 self.database
87 .list_workspaces()
88 .await
89 .map(|workspaces| workspaces.into_iter().map(WorkspaceRecord::from).collect())
90 .map_err(Error::from_internal)
91 }
92
93 pub async fn queue_state(&self) -> Result<QueueState, Error> {
94 let selected = self
95 .database
96 .restore_queue_workspace()
97 .await
98 .map_err(Error::from_internal)?;
99 self.queue_state_for(selected).await
100 }
101
102 pub async fn select_queue_workspace(
103 &self,
104 workspace_id: &WorkspaceId,
105 ) -> Result<QueueState, Error> {
106 let selected = self
107 .database
108 .select_queue_workspace(workspace_id)
109 .await
110 .map_err(Error::from_internal)?;
111 self.queue_state_for(selected).await
112 }
113
114 async fn queue_state_for(&self, selected: Workspace) -> Result<QueueState, Error> {
115 #[cfg(test)]
116 if self
117 .fail_queue_reads
118 .load(std::sync::atomic::Ordering::SeqCst)
119 {
120 return Err(Error::from_internal(sqlx::Error::PoolClosed.into()));
121 }
122 let workspaces = self
123 .database
124 .list_workspace_open_summaries()
125 .await
126 .map_err(Error::from_internal)?
127 .into_iter()
128 .map(|summary| WorkspaceQueueSummary {
129 workspace: summary.workspace.into(),
130 open_task_count: summary.open_task_count,
131 })
132 .collect();
133 let projects = self
134 .database
135 .list_projects(&selected.id, None)
136 .await
137 .map_err(Error::from_internal)?
138 .into_iter()
139 .map(ProjectRecord::from)
140 .collect();
141 let labels = self
142 .database
143 .list_labels(&selected.id, None)
144 .await
145 .map_err(Error::from_internal)?;
146 let queue = self.queue_report(&selected.id).await?;
147 Ok(QueueState {
148 selected_workspace: selected.into(),
149 workspaces,
150 projects,
151 labels,
152 queue,
153 })
154 }
155
156 pub async fn project_tasks(
157 &self,
158 workspace_id: &WorkspaceId,
159 project_id: &ProjectId,
160 ) -> Result<Vec<TaskListRow>, Error> {
161 self.workspace(workspace_id).await?;
162 let project = self
163 .database
164 .find_project_by_id(workspace_id, project_id)
165 .await
166 .map_err(Error::from_internal)?
167 .ok_or_else(|| Error::new(ErrorCode::NotFound, "project not found".to_string()))?;
168 self.database
169 .list_task_summary_items(
170 workspace_id,
171 TaskFilters {
172 project: Some(project.key),
173 ..TaskFilters::default()
174 },
175 TaskQueryMode::Flat,
176 TaskSort::Updated,
177 SortDirection::Desc,
178 None,
179 )
180 .await
181 .map(|items| items.into_iter().map(TaskListRow::from).collect())
182 .map_err(Error::from_internal)
183 }
184
185 pub async fn search_tasks(
186 &self,
187 workspace_id: &WorkspaceId,
188 text: &str,
189 ) -> Result<Vec<TaskSearchResult>, Error> {
190 self.workspace(workspace_id).await?;
191 let text = text.trim();
192 if text.is_empty() {
193 return Ok(Vec::new());
194 }
195 if text.chars().count() > 256 {
196 return Err(Error::new(
197 ErrorCode::Validation,
198 "search query is too long".to_string(),
199 ));
200 }
201 self.database
202 .search_task_items(
203 workspace_id,
204 TaskSearchQuery {
205 text: text.to_string(),
206 project: None,
207 metadata: Vec::new(),
208 has_metadata: Vec::new(),
209 missing_metadata: Vec::new(),
210 include_deleted: false,
211 limit: 100,
212 },
213 )
214 .await
215 .map(|results| {
216 results
217 .into_iter()
218 .map(|result| TaskSearchResult {
219 task: TaskListRow::from(result.item),
220 matched_field: result.matched_field,
221 snippet: result.snippet,
222 })
223 .collect()
224 })
225 .map_err(Error::from_internal)
226 }
227
228 pub async fn task_detail(
229 &self,
230 workspace_id: &WorkspaceId,
231 task_id: &TaskId,
232 ) -> Result<TaskDetail, Error> {
233 let workspace = self.workspace(workspace_id).await?;
234 let mut connection = self
235 .database
236 .acquire_reader()
237 .await
238 .map_err(Error::from_internal)?;
239 let task = crate::refs::get_task_in_workspace(&mut connection, &workspace, task_id)
240 .await
241 .map_err(Error::from_internal)?;
242 drop(connection);
243 let mut detail = self
244 .database
245 .task_detail(&task)
246 .await
247 .map_err(Error::from_internal)?;
248 let blob_dir = crate::attachments::default_blob_dir(self.database.path());
249 let present = detail
250 .item
251 .attachments
252 .iter()
253 .enumerate()
254 .filter(|(_, attachment)| {
255 attachment.bytes_state == crate::attachments::AttachmentBytesState::Present
256 })
257 .map(|(index, attachment)| (index, attachment.sha256.clone()))
258 .collect::<Vec<_>>();
259 let missing = crate::attachments::run_blocking(move || {
260 let mut missing = Vec::new();
261 for (index, sha256) in present {
262 if crate::operations::local_object_missing(&blob_dir, &sha256)? {
263 missing.push(index);
264 }
265 }
266 Ok(missing)
267 })
268 .await
269 .map_err(Error::from_internal)?;
270 for index in missing {
271 let attachment = &mut detail.item.attachments[index];
272 attachment.bytes_state = crate::attachments::AttachmentBytesState::Unavailable;
273 attachment.has_blob = false;
274 }
275 TaskDetail::from_detail(detail, workspace)
276 }
277
278 pub async fn attachment_bytes(
280 &self,
281 workspace_id: &WorkspaceId,
282 task_id: &TaskId,
283 attachment_id: &str,
284 ) -> Result<AttachmentRead, Error> {
285 use crate::attachments::{MAX_BLOB_BYTES, default_blob_dir, object_path, sha256_hex};
286 use std::io::Read;
287
288 crate::attachments::validate_attachment_id(attachment_id).map_err(Error::from_internal)?;
289 let workspace = self.workspace(workspace_id).await?;
290 let lease = match self
291 .database
292 .acquire_live_attachment_read_lease(&workspace, attachment_id)
293 .await
294 {
295 Ok(lease) => lease,
296 Err(error) => {
297 return match error.downcast_ref::<crate::operations::AttachmentReadFailure>() {
298 Some(crate::operations::AttachmentReadFailure::Invalidated) => {
299 Ok(AttachmentRead::Invalidated)
300 }
301 Some(crate::operations::AttachmentReadFailure::Unavailable) => {
302 Ok(AttachmentRead::Unavailable)
303 }
304 None => Err(Error::from_internal(error)),
305 };
306 }
307 };
308 let result = if lease.task_id != task_id.as_str() {
309 Ok(AttachmentRead::Invalidated)
310 } else {
311 let path = object_path(&default_blob_dir(self.database.path()), &lease.sha256);
312 match path {
313 Err(error) => Err(error),
314 Ok(path) => {
315 let expected_size = lease.byte_size;
316 let expected_hash = lease.sha256.clone();
317 crate::attachments::run_preview(move || {
318 let file = match std::fs::File::open(path) {
319 Ok(file) => file,
320 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
321 return Ok(AttachmentRead::Missing);
322 }
323 Err(error) => return Err(error.into()),
324 };
325 let mut bytes = Vec::new();
326 file.take(MAX_BLOB_BYTES as u64 + 1)
327 .read_to_end(&mut bytes)?;
328 if bytes.len() > MAX_BLOB_BYTES
329 || bytes.len() as i64 != expected_size
330 || sha256_hex(&bytes) != expected_hash
331 {
332 return Ok(AttachmentRead::Corrupt);
333 }
334 Ok(AttachmentRead::Bytes { bytes })
335 })
336 .await
337 }
338 }
339 };
340 self.database
341 .release_attachment_lease(&lease.lease_id)
342 .await
343 .map_err(Error::from_internal)?;
344 result.map_err(Error::from_internal)
345 }
346
347 pub async fn capture_queue_task(
348 &self,
349 workspace_id: &WorkspaceId,
350 input: TaskCapture,
351 ) -> Result<TaskCaptureResult, Error> {
352 let title = input.title.trim();
353 if title.is_empty() {
354 return Err(Error::new(
355 ErrorCode::Validation,
356 "task title is required".to_string(),
357 ));
358 }
359 validate_optional_date("due_on", input.due_on.as_deref())?;
360 let workspace = self.workspace(workspace_id).await?;
361 let project = input
362 .project
363 .ok_or_else(|| Error::new(ErrorCode::Validation, "project is required".to_string()))?;
364 let outcome = self
365 .database
366 .create_task_with_options(
367 &workspace,
368 TaskDraft {
369 title: title.to_string(),
370 description: input.description,
371 project: Some(project),
372 status: input.status.as_str().to_string(),
373 priority: input.priority.as_str().to_string(),
374 source: input.source,
375 labels: input.labels,
376 metadata: Vec::new(),
377 available_at: None,
378 due_on: input.due_on,
379 is_epic: false,
380 },
381 TaskCreationOptions::for_consumer_capture(),
382 )
383 .await
384 .map_err(Error::from_internal)?;
385 let expected = outcome.undo_snapshot.ok_or_else(|| {
386 Error::new(
387 ErrorCode::Internal,
388 "capture undo state is unavailable".to_string(),
389 )
390 })?;
391 let undo_token = serde_json::to_string(&CaptureUndoToken {
392 workspace_id: workspace_id.clone(),
393 task_id: outcome.task.id.clone(),
394 expected,
395 })
396 .map_err(|error| Error::from_internal(error.into()))?;
397 let state = self.queue_state_for(workspace).await.ok();
398 let display_ref = state
399 .as_ref()
400 .and_then(|state| {
401 state
402 .queue
403 .tasks
404 .iter()
405 .find(|task| task.id == outcome.task.id)
406 })
407 .map(|task| task.display_ref.clone())
408 .unwrap_or_else(|| outcome.task.id.to_string());
409 Ok(TaskCaptureResult {
410 task_id: outcome.task.id,
411 display_ref,
412 undo_token,
413 state,
414 })
415 }
416
417 pub async fn undo_queue_capture(&self, undo_token: &str) -> Result<Option<QueueState>, Error> {
418 let token: CaptureUndoToken = serde_json::from_str(undo_token).map_err(|_| {
419 Error::new(
420 ErrorCode::Validation,
421 "invalid capture undo token".to_string(),
422 )
423 })?;
424 let workspace = self.workspace(&token.workspace_id).await?;
425 self.database
426 .undo_queue_capture(&workspace, &token.task_id, &token.expected)
427 .await
428 .map_err(Error::from_internal)?;
429 Ok(self.queue_state_for(workspace).await.ok())
430 }
431
432 pub async fn update_detail_status(
434 &self,
435 workspace_id: &WorkspaceId,
436 task_id: &TaskId,
437 status: TaskStatus,
438 ) -> Result<DetailStatusReceipt, Error> {
439 let workspace = self.workspace(workspace_id).await?;
440 let mutation = InternalConsumerTaskMutation::DetailStatus {
441 status: status.as_str().to_string(),
442 expected_version: None,
443 };
444 let outcome = self
445 .database
446 .mutate_consumer_task(&workspace, task_id, &mutation)
447 .await
448 .map_err(Error::from_internal)?;
449 let status = TaskStatus::parse(&outcome.after.status)
450 .expect("persisted task status is validated by core mutations");
451 let mutation = InternalConsumerTaskMutation::DetailStatus {
452 status: status.as_str().to_string(),
453 expected_version: outcome.field_version,
454 };
455 let undo_token = outcome.changed.then(|| {
456 serde_json::to_string(&MutationUndoToken {
457 workspace_id: workspace_id.clone(),
458 task_id: task_id.clone(),
459 mutation,
460 before: outcome.before,
461 expected: outcome.after,
462 })
463 .expect("Undo snapshots contain only JSON-serializable values")
464 });
465 Ok(DetailStatusReceipt { status, undo_token })
466 }
467
468 pub async fn undo_detail_status(&self, undo_token: &str) -> Result<(), Error> {
469 let token: MutationUndoToken = serde_json::from_str(undo_token).map_err(|_| {
470 Error::new(
471 ErrorCode::Validation,
472 "invalid detail status undo token".to_string(),
473 )
474 })?;
475 if !matches!(
476 token.mutation,
477 InternalConsumerTaskMutation::DetailStatus { .. }
478 ) {
479 return Err(Error::new(
480 ErrorCode::Validation,
481 "invalid detail status undo token".to_string(),
482 ));
483 }
484 self.undo_consumer_mutation(token).await
485 }
486
487 pub async fn set_task_deleted(
489 &self,
490 workspace_id: &WorkspaceId,
491 task_id: &TaskId,
492 deleted: bool,
493 ) -> Result<TaskDeletionReceipt, Error> {
494 let workspace = self.workspace(workspace_id).await?;
495 let mutation = InternalConsumerTaskMutation::DeletedState {
496 deleted,
497 expected_version: None,
498 };
499 let outcome = self
500 .database
501 .mutate_consumer_task(&workspace, task_id, &mutation)
502 .await
503 .map_err(Error::from_internal)?;
504 let deleted = outcome.after.deleted;
505 let mutation = InternalConsumerTaskMutation::DeletedState {
506 deleted,
507 expected_version: outcome.field_version,
508 };
509 let undo_token = outcome.changed.then(|| {
510 serde_json::to_string(&MutationUndoToken {
511 workspace_id: workspace_id.clone(),
512 task_id: task_id.clone(),
513 mutation,
514 before: outcome.before,
515 expected: outcome.after,
516 })
517 .expect("Undo snapshots contain only JSON-serializable values")
518 });
519 Ok(TaskDeletionReceipt {
520 deleted,
521 undo_token,
522 })
523 }
524
525 pub async fn undo_task_deletion(&self, undo_token: &str) -> Result<(), Error> {
526 let token: MutationUndoToken = serde_json::from_str(undo_token).map_err(|_| {
527 Error::new(
528 ErrorCode::Validation,
529 "invalid task deletion undo token".to_string(),
530 )
531 })?;
532 if !matches!(
533 token.mutation,
534 InternalConsumerTaskMutation::DeletedState { .. }
535 ) {
536 return Err(Error::new(
537 ErrorCode::Validation,
538 "invalid task deletion undo token".to_string(),
539 ));
540 }
541 self.undo_consumer_mutation(token).await
542 }
543
544 async fn undo_consumer_mutation(&self, token: MutationUndoToken) -> Result<(), Error> {
545 let workspace = self.workspace(&token.workspace_id).await?;
546 self.database
547 .undo_consumer_task_mutation(
548 &workspace,
549 &token.task_id,
550 &token.mutation,
551 &token.before,
552 &token.expected,
553 )
554 .await
555 .map_err(Error::from_internal)?;
556 Ok(())
557 }
558
559 pub async fn mutate_queue_task(
560 &self,
561 workspace_id: &WorkspaceId,
562 task_id: &TaskId,
563 input: QueueMutation,
564 ) -> Result<QueueMutationResult, Error> {
565 let workspace = self.workspace(workspace_id).await?;
566 let mutation = input.into_internal()?;
567 let outcome = self
568 .database
569 .mutate_consumer_task(&workspace, task_id, &mutation)
570 .await
571 .map_err(Error::from_internal)?;
572 if !outcome.changed {
573 return Err(Error::new(
574 ErrorCode::GenerationConflict,
575 "quick action did not change the task".to_string(),
576 ));
577 }
578 let undo_token = serde_json::to_string(&MutationUndoToken {
579 workspace_id: workspace_id.clone(),
580 task_id: task_id.clone(),
581 mutation,
582 before: outcome.before,
583 expected: outcome.after,
584 })
585 .map_err(|error| Error::from_internal(error.into()))?;
586 Ok(QueueMutationResult {
587 task_id: task_id.clone(),
588 undo_token,
589 state: self.queue_state_for(workspace).await.ok(),
590 })
591 }
592
593 pub async fn undo_queue_mutation(&self, undo_token: &str) -> Result<Option<QueueState>, Error> {
594 let token: MutationUndoToken = serde_json::from_str(undo_token).map_err(|_| {
595 Error::new(
596 ErrorCode::Validation,
597 "invalid quick action undo token".to_string(),
598 )
599 })?;
600 let workspace = self.workspace(&token.workspace_id).await?;
601 self.database
602 .undo_consumer_task_mutation(
603 &workspace,
604 &token.task_id,
605 &token.mutation,
606 &token.before,
607 &token.expected,
608 )
609 .await
610 .map_err(Error::from_internal)?;
611 Ok(self.queue_state_for(workspace).await.ok())
612 }
613
614 pub async fn resolve_workspace(&self, name_or_key: &str) -> Result<WorkspaceRecord, Error> {
615 self.database
616 .find_workspace(name_or_key)
617 .await
618 .map_err(Error::from_internal)?
619 .map(WorkspaceRecord::from)
620 .ok_or_else(|| {
621 Error::new(
622 ErrorCode::NotFound,
623 format!("workspace not found: {name_or_key}"),
624 )
625 })
626 }
627
628 pub async fn list_metadata_fields(
629 &self,
630 workspace_id: &WorkspaceId,
631 ) -> Result<Vec<MetadataFieldRecord>, Error> {
632 self.workspace(workspace_id).await?;
633 self.database
634 .list_metadata_fields(workspace_id)
635 .await
636 .map(|fields| fields.into_iter().map(Into::into).collect())
637 .map_err(Error::from_internal)
638 }
639
640 pub async fn rename_metadata_field(
641 &self,
642 workspace_id: &WorkspaceId,
643 key: &str,
644 new_key: &str,
645 ) -> Result<MetadataFieldRecord, Error> {
646 let workspace = self.workspace(workspace_id).await?;
647 let field = self
648 .database
649 .rename_metadata_field(&workspace, key, new_key)
650 .await
651 .map_err(Error::from_internal)?;
652 let fields = self.list_metadata_fields(workspace_id).await?;
653 fields
654 .into_iter()
655 .find(|candidate| candidate.id == field.id)
656 .ok_or_else(|| Error::new(ErrorCode::NotFound, "metadata field not found".to_string()))
657 }
658
659 pub async fn create_task(
660 &self,
661 workspace_id: &WorkspaceId,
662 input: CreateTask,
663 ) -> Result<TaskRecord, Error> {
664 validate_project(&input.project)?;
665 validate_optional_date("available_at", input.available_at.as_deref())?;
666 validate_optional_date("due_on", input.due_on.as_deref())?;
667 let workspace = self.workspace(workspace_id).await?;
668 let outcome = self
669 .database
670 .create_task(
671 &workspace,
672 TaskDraft {
673 title: input.title,
674 description: input.description,
675 project: Some(input.project),
676 status: input.status.as_str().to_string(),
677 priority: input.priority.as_str().to_string(),
678 source: TaskSource::Api,
679 labels: Vec::new(),
680 metadata: input
681 .metadata
682 .into_iter()
683 .map(TaskMetadataInput::from)
684 .collect(),
685 available_at: input.available_at,
686 due_on: input.due_on,
687 is_epic: false,
688 },
689 )
690 .await
691 .map_err(Error::from_internal)?;
692 let metadata = self
693 .database
694 .task_metadata(workspace_id, &outcome.task.id)
695 .await
696 .map_err(Error::from_internal)?;
697 Ok(TaskRecord::with_metadata(outcome.task, metadata))
698 }
699
700 pub async fn task_edit_context(
701 &self,
702 workspace_id: &WorkspaceId,
703 task_id: &TaskId,
704 ) -> Result<TaskEditContext, Error> {
705 let detail = self.task_detail(workspace_id, task_id).await?;
706 let projects = self
707 .database
708 .list_projects(workspace_id, None)
709 .await
710 .map_err(Error::from_internal)?
711 .into_iter()
712 .map(ProjectRecord::from)
713 .collect();
714 let labels = self
715 .database
716 .list_labels(workspace_id, None)
717 .await
718 .map_err(Error::from_internal)?;
719 Ok(TaskEditContext {
720 detail,
721 projects,
722 labels,
723 })
724 }
725
726 pub async fn edit_task(
727 &self,
728 workspace_id: &WorkspaceId,
729 task_id: &TaskId,
730 input: TaskEdit,
731 ) -> Result<bool, Error> {
732 let title = input.title.map(|title| title.trim().to_string());
733 if let Some(title) = title.as_deref() {
734 if title.is_empty() {
735 return Err(Error::new(
736 ErrorCode::Validation,
737 "title must not be empty".into(),
738 ));
739 }
740 if title.chars().any(|character| {
741 matches!(
742 character,
743 '\n' | '\r' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{2028}' | '\u{2029}'
744 )
745 }) {
746 return Err(Error::new(
747 ErrorCode::Validation,
748 "title must be a single line".into(),
749 ));
750 }
751 }
752 validate_date_update("available_at", &input.available_at)?;
753 validate_date_update("due_on", &input.due_on)?;
754 let workspace = self.workspace(workspace_id).await?;
755 self.database
756 .edit_consumer_task(
757 &workspace,
758 task_id,
759 input.project_id.as_ref(),
760 InternalTaskUpdate {
761 title,
762 description: input.description,
763 status: input.status.map(|value| value.as_str().to_string()),
764 priority: input.priority.map(|value| value.as_str().to_string()),
765 available_at: input.available_at.into_internal(),
766 due_on: input.due_on.into_internal(),
767 add_labels: input.add_labels,
768 remove_labels: input.remove_labels,
769 ..InternalTaskUpdate::default()
770 },
771 )
772 .await
773 .map_err(Error::from_internal)
774 }
775
776 pub async fn update_task(
777 &self,
778 workspace_id: &WorkspaceId,
779 task_id: &TaskId,
780 input: UpdateTask,
781 ) -> Result<TaskUpdateResult, Error> {
782 if let Some(project) = input.project.as_deref() {
783 validate_project(project)?;
784 }
785 validate_date_update("available_at", &input.available_at)?;
786 validate_date_update("due_on", &input.due_on)?;
787 let workspace = self.workspace(workspace_id).await?;
788 self.fetch_task(workspace_id, task_id).await?;
789 let outcome = self
790 .database
791 .update_task(
792 &workspace,
793 task_id,
794 InternalTaskUpdate {
795 title: input.title,
796 description: input.description,
797 project: input.project,
798 status: input.status.map(|status| status.as_str().to_string()),
799 priority: input.priority.map(|priority| priority.as_str().to_string()),
800 available_at: input.available_at.into_internal(),
801 due_on: input.due_on.into_internal(),
802 set_metadata: input
803 .set_metadata
804 .into_iter()
805 .map(TaskMetadataInput::from)
806 .collect(),
807 remove_metadata: input.remove_metadata,
808 ..InternalTaskUpdate::default()
809 },
810 )
811 .await
812 .map_err(Error::from_internal)?;
813 let metadata = self
814 .database
815 .task_metadata(workspace_id, task_id)
816 .await
817 .map_err(Error::from_internal)?;
818 let related = self
819 .database
820 .task_related_links(workspace_id, task_id)
821 .await
822 .map_err(Error::from_internal)?;
823 Ok(TaskUpdateResult {
824 task: TaskRecord::with_metadata_and_related(outcome.task, metadata, related),
825 changed: outcome.changed,
826 })
827 }
828
829 pub async fn list_tasks(&self, workspace_id: &WorkspaceId) -> Result<Vec<TaskRecord>, Error> {
830 self.workspace(workspace_id).await?;
831 let filters = TaskFilters {
832 exclude_epics: true,
833 ..TaskFilters::default()
834 };
835 self.database
836 .list_base_tasks(workspace_id, filters, TaskSort::Created, SortDirection::Asc)
837 .await
838 .map(|tasks| tasks.into_iter().map(TaskRecord::from).collect())
839 .map_err(Error::from_internal)
840 }
841
842 pub async fn queue_report(&self, workspace_id: &WorkspaceId) -> Result<QueueReport, Error> {
843 self.workspace(workspace_id).await?;
844 let tasks = self
845 .database
846 .list_task_summary_items(
847 workspace_id,
848 TaskFilters {
849 hide_done: true,
850 availability: TaskAvailabilityFilter::Available,
851 ..TaskFilters::default()
852 },
853 TaskQueryMode::RankedQueue,
854 TaskSort::Created,
855 SortDirection::Asc,
856 None,
857 )
858 .await
859 .map_err(Error::from_internal)?
860 .into_iter()
861 .map(QueueTaskSummary::from)
862 .collect();
863 let unresolved_conflict_count = self
864 .database
865 .unresolved_task_conflict_count_in_workspace(workspace_id)
866 .await
867 .map_err(Error::from_internal)?
868 .clamp(0, i64::from(u32::MAX)) as u32;
869 Ok(QueueReport {
870 tasks,
871 unresolved_conflict_count,
872 })
873 }
874
875 pub async fn fetch_task(
876 &self,
877 workspace_id: &WorkspaceId,
878 task_id: &TaskId,
879 ) -> Result<TaskRecord, Error> {
880 let workspace = self.workspace(workspace_id).await?;
881 let mut connection = self
882 .database
883 .acquire_reader()
884 .await
885 .map_err(Error::from_internal)?;
886 let task = crate::refs::get_task_in_workspace(&mut connection, &workspace, task_id)
887 .await
888 .map_err(Error::from_internal)?;
889 drop(connection);
890 let metadata = self
891 .database
892 .task_metadata(workspace_id, task_id)
893 .await
894 .map_err(Error::from_internal)?;
895 let related = self
896 .database
897 .task_related_links(workspace_id, task_id)
898 .await
899 .map_err(Error::from_internal)?;
900 Ok(TaskRecord::with_metadata_and_related(
901 task, metadata, related,
902 ))
903 }
904
905 pub async fn add_related_task(
906 &self,
907 workspace_id: &WorkspaceId,
908 task_id: &TaskId,
909 related_task_id: &TaskId,
910 ) -> Result<RelatedMutationResult, Error> {
911 let workspace = self.workspace(workspace_id).await?;
912 self.database
913 .add_task_related_link(&workspace, task_id, related_task_id)
914 .await
915 .map(|outcome| RelatedMutationResult {
916 changed: outcome.changed,
917 })
918 .map_err(Error::from_internal)
919 }
920
921 pub async fn remove_related_task(
922 &self,
923 workspace_id: &WorkspaceId,
924 task_id: &TaskId,
925 related_task_id: &TaskId,
926 ) -> Result<RelatedMutationResult, Error> {
927 let workspace = self.workspace(workspace_id).await?;
928 self.database
929 .remove_task_related_link(&workspace, task_id, related_task_id)
930 .await
931 .map(|outcome| RelatedMutationResult {
932 changed: outcome.changed,
933 })
934 .map_err(Error::from_internal)
935 }
936
937 pub async fn create_recurrence_series(
938 &self,
939 workspace_id: &WorkspaceId,
940 input: CreateRecurrenceSeries,
941 ) -> Result<RecurrenceCreateResult, Error> {
942 validate_project(&input.project)?;
943 let workspace = self.workspace(workspace_id).await?;
944 let schedule = input.schedule.into_internal()?;
945 let outcome = self
946 .database
947 .create_recurrence_series(
948 &workspace,
949 CreateRecurrenceSeriesParams::new(RecurrenceSeriesDraft {
950 title: input.title,
951 description: input.description,
952 project: input.project,
953 priority: input.priority.as_str().to_string(),
954 initial_status: input.initial_status.as_str().to_string(),
955 labels: input.labels,
956 metadata: input.metadata.into_iter().map(Into::into).collect(),
957 schedule,
958 }),
959 )
960 .await
961 .map_err(Error::from_internal)?;
962 let metadata = self
963 .database
964 .task_metadata(workspace_id, &outcome.task.id)
965 .await
966 .map_err(Error::from_internal)?;
967 Ok(RecurrenceCreateResult {
968 series: RecurrenceSeriesRecord::from(outcome.series),
969 series_ref: outcome.series_ref,
970 occurrence: RecurrenceOccurrenceRecord::from(outcome.occurrence),
971 task: TaskRecord::with_metadata(outcome.task, metadata),
972 })
973 }
974
975 pub async fn update_recurrence_template(
976 &self,
977 workspace_id: &WorkspaceId,
978 series_id: &RecurrenceSeriesId,
979 input: UpdateRecurrenceTemplate,
980 ) -> Result<RecurrenceTemplateUpdateResult, Error> {
981 if let Some(project) = input.project.as_deref() {
982 validate_project(project)?;
983 }
984 let workspace = self.workspace(workspace_id).await?;
985 self.database
986 .update_recurrence_template(
987 &workspace,
988 series_id,
989 UpdateRecurrenceTemplateParams::new(InternalRecurrenceTemplateUpdate {
990 title: input.title,
991 description: input.description,
992 project: input.project,
993 priority: input.priority.map(|value| value.as_str().to_string()),
994 initial_status: input.initial_status.map(|value| value.as_str().to_string()),
995 labels: input.labels,
996 set_metadata: input.set_metadata.into_iter().map(Into::into).collect(),
997 remove_metadata: input.remove_metadata,
998 available_local_time: input.available_local_time.into_internal()?,
999 due_policy: input.due_policy,
1000 }),
1001 )
1002 .await
1003 .map(|outcome| RecurrenceTemplateUpdateResult {
1004 series: RecurrenceSeriesRecord::from(outcome.series),
1005 changed: outcome.changed,
1006 })
1007 .map_err(Error::from_internal)
1008 }
1009
1010 pub async fn resolve_recurrence_ref(
1011 &self,
1012 workspace_id: &WorkspaceId,
1013 input: &str,
1014 ) -> Result<RecurrenceRefResolution, Error> {
1015 let workspace = self.workspace(workspace_id).await?;
1016 let series = self
1017 .database
1018 .resolve_recurrence_ref(&workspace, input)
1019 .await
1020 .map_err(Error::from_internal)?;
1021 let series_ref = self
1022 .database
1023 .recurrence_series_ref(workspace_id, &series.id)
1024 .await
1025 .map_err(Error::from_internal)?;
1026 Ok(RecurrenceRefResolution {
1027 series_id: series.id,
1028 series_ref,
1029 })
1030 }
1031
1032 pub async fn list_recurrence_series(
1033 &self,
1034 workspace_id: &WorkspaceId,
1035 ) -> Result<Vec<RecurrenceSeriesSummary>, Error> {
1036 self.workspace(workspace_id).await?;
1037 self.database
1038 .list_recurrence_series(workspace_id)
1039 .await
1040 .map(|values| values.into_iter().map(Into::into).collect())
1041 .map_err(Error::from_internal)
1042 }
1043
1044 pub async fn show_recurrence_series(
1045 &self,
1046 workspace_id: &WorkspaceId,
1047 input: &str,
1048 ) -> Result<RecurrenceSeriesDetail, Error> {
1049 let resolution = self.resolve_recurrence_ref(workspace_id, input).await?;
1050 self.database
1051 .recurrence_series_detail(workspace_id, &resolution.series_id)
1052 .await
1053 .map(Into::into)
1054 .map_err(Error::from_internal)
1055 }
1056
1057 pub async fn recurrence_history(
1058 &self,
1059 workspace_id: &WorkspaceId,
1060 input: &str,
1061 offset: usize,
1062 limit: usize,
1063 ) -> Result<RecurrenceHistoryPage, Error> {
1064 if limit == 0 || limit > MAX_RECURRENCE_HISTORY_LIMIT {
1065 return Err(Error::new(
1066 ErrorCode::Validation,
1067 format!(
1068 "recurrence history limit must be between 1 and {MAX_RECURRENCE_HISTORY_LIMIT}"
1069 ),
1070 ));
1071 }
1072 let resolution = self.resolve_recurrence_ref(workspace_id, input).await?;
1073 self.database
1074 .recurrence_history(workspace_id, &resolution.series_id, offset, limit)
1075 .await
1076 .map(|page| RecurrenceHistoryPage {
1077 series_ref: page.series_ref,
1078 items: page.items.into_iter().map(Into::into).collect(),
1079 offset: page.offset,
1080 limit: page.limit,
1081 total: page.total,
1082 has_more: page.has_more,
1083 })
1084 .map_err(Error::from_internal)
1085 }
1086
1087 pub async fn resolve_recurrence_occurrence(
1088 &self,
1089 workspace_id: &WorkspaceId,
1090 task_id: &TaskId,
1091 outcome: RecurrenceOutcome,
1092 ) -> Result<RecurrenceResolveResult, Error> {
1093 let workspace = self.workspace(workspace_id).await?;
1094 self.database
1095 .resolve_recurrence_occurrence(&workspace, task_id, outcome)
1096 .await
1097 .map(|value| RecurrenceResolveResult {
1098 series: value.series.into(),
1099 occurrence: value.resolved.into(),
1100 task: value.task.into(),
1101 successor: value.successor.map(Into::into),
1102 })
1103 .map_err(Error::from_internal)
1104 }
1105
1106 pub async fn complete_recurrence_occurrence(
1107 &self,
1108 workspace_id: &WorkspaceId,
1109 task_id: &TaskId,
1110 ) -> Result<RecurrenceResolveResult, Error> {
1111 self.resolve_recurrence_occurrence(workspace_id, task_id, RecurrenceOutcome::Completed)
1112 .await
1113 }
1114
1115 pub async fn skip_recurrence_occurrence(
1116 &self,
1117 workspace_id: &WorkspaceId,
1118 task_id: &TaskId,
1119 ) -> Result<RecurrenceResolveResult, Error> {
1120 self.resolve_recurrence_occurrence(workspace_id, task_id, RecurrenceOutcome::Skipped)
1121 .await
1122 }
1123
1124 pub async fn pause_recurrence_series(
1125 &self,
1126 workspace_id: &WorkspaceId,
1127 series_id: &RecurrenceSeriesId,
1128 ) -> Result<RecurrenceStateResult, Error> {
1129 let workspace = self.workspace(workspace_id).await?;
1130 self.database
1131 .pause_recurrence_series(&workspace, series_id)
1132 .await
1133 .map(Into::into)
1134 .map_err(Error::from_internal)
1135 }
1136
1137 pub async fn resume_recurrence_series(
1138 &self,
1139 workspace_id: &WorkspaceId,
1140 series_id: &RecurrenceSeriesId,
1141 ) -> Result<RecurrenceStateResult, Error> {
1142 let workspace = self.workspace(workspace_id).await?;
1143 let at = crate::ids::now_utc();
1144 self.database
1145 .resume_recurrence_series(&workspace, series_id, at)
1146 .await
1147 .map(Into::into)
1148 .map_err(Error::from_internal)
1149 }
1150
1151 pub async fn stop_recurrence_series(
1152 &self,
1153 workspace_id: &WorkspaceId,
1154 series_id: &RecurrenceSeriesId,
1155 skip_current: bool,
1156 ) -> Result<RecurrenceStateResult, Error> {
1157 let workspace = self.workspace(workspace_id).await?;
1158 self.database
1159 .stop_recurrence_series(&workspace, series_id, skip_current)
1160 .await
1161 .map(Into::into)
1162 .map_err(Error::from_internal)
1163 }
1164
1165 pub async fn recurrence_task_report(
1166 &self,
1167 workspace_id: &WorkspaceId,
1168 expand_recurring: bool,
1169 ) -> Result<Vec<TaskSummary>, Error> {
1170 self.workspace(workspace_id).await?;
1171 self.database
1172 .list_task_items(
1173 workspace_id,
1174 TaskFilters {
1175 exclude_epics: true,
1176 expand_recurring,
1177 ..TaskFilters::default()
1178 },
1179 TaskQueryMode::Flat,
1180 TaskSort::Created,
1181 SortDirection::Asc,
1182 )
1183 .await
1184 .map(|items| items.into_iter().map(TaskSummary::from).collect())
1185 .map_err(Error::from_internal)
1186 }
1187
1188 pub async fn list_conflicts(
1189 &self,
1190 workspace_id: &WorkspaceId,
1191 ) -> Result<Vec<ConflictSummary>, Error> {
1192 let workspace = self.workspace(workspace_id).await?;
1193 self.database
1194 .list_conflicts(&workspace, None, None)
1195 .await
1196 .map_err(Error::from_internal)?
1197 .into_iter()
1198 .filter(|conflict| {
1201 !conflict.recurrence_series && !conflict.field.starts_with("metadata:")
1202 })
1203 .map(|conflict| {
1204 Ok(ConflictSummary {
1205 task_id: conflict.task_id,
1206 task_title: conflict.title,
1207 project_key: conflict.project_key,
1208 project_prefix: conflict.project_prefix,
1209 field: ConflictField::from_task_field(TaskField::parse_or_unknown(
1210 &conflict.field,
1211 )?),
1212 })
1213 })
1214 .collect::<Result<Vec<_>, InternalError>>()
1215 .map_err(Error::from_internal)
1216 }
1217
1218 pub async fn inspect_conflicts(
1219 &self,
1220 workspace_id: &WorkspaceId,
1221 task_id: &TaskId,
1222 ) -> Result<Vec<Conflict>, Error> {
1223 let workspace = self.workspace(workspace_id).await?;
1224 let details = self
1225 .database
1226 .task_conflicts(&workspace, task_id, None)
1227 .await
1228 .map_err(Error::from_internal)?;
1229 let mut connection = self
1230 .database
1231 .acquire_reader()
1232 .await
1233 .map_err(Error::from_internal)?;
1234 let mut conflicts = Vec::with_capacity(details.len());
1235 for detail in details {
1236 if detail.field.starts_with("metadata:") {
1237 continue;
1238 }
1239 let field = TaskField::parse_or_unknown(&detail.field).map_err(Error::from_internal)?;
1240 let local_value = crate::query::conflict_display_value(
1241 &mut connection,
1242 workspace_id,
1243 field.as_str(),
1244 &detail.local_value,
1245 )
1246 .await
1247 .map_err(Error::from_internal)?;
1248 let remote_value = crate::query::conflict_display_value(
1249 &mut connection,
1250 workspace_id,
1251 field.as_str(),
1252 &detail.remote_value,
1253 )
1254 .await
1255 .map_err(Error::from_internal)?;
1256 conflicts.push(Conflict {
1257 task_id: task_id.clone(),
1258 field: ConflictField::from_task_field(field),
1259 local_value: bounded_conflict_display_value(local_value),
1260 remote_value: bounded_conflict_display_value(remote_value),
1261 variant_a: detail.variant_a,
1262 variant_b: detail.variant_b,
1263 });
1264 }
1265 Ok(conflicts)
1266 }
1267
1268 pub async fn resolve_conflict(
1269 &self,
1270 workspace_id: &WorkspaceId,
1271 task_id: &TaskId,
1272 field: ConflictField,
1273 variant_a: String,
1274 variant_b: String,
1275 resolution: ConflictResolution,
1276 ) -> Result<TaskRecord, Error> {
1277 let workspace = self.workspace(workspace_id).await?;
1278 let field_name = field.as_str();
1279 let resolution = match &resolution {
1280 ConflictResolution::Local => crate::operations::ConflictResolutionValue::Local,
1281 ConflictResolution::Remote => crate::operations::ConflictResolutionValue::Remote,
1282 ConflictResolution::Explicit(value) => {
1283 crate::operations::ConflictResolutionValue::Explicit(value)
1284 }
1285 };
1286 let mut connection = self
1287 .database
1288 .acquire_writer()
1289 .await
1290 .map_err(Error::from_internal)?;
1291 crate::operations::resolve_conflict_transaction(
1292 &mut connection,
1293 &workspace,
1294 task_id,
1295 field_name,
1296 crate::operations::ExpectedConflictIdentity {
1297 variant_a: &variant_a,
1298 variant_b: &variant_b,
1299 },
1300 resolution,
1301 )
1302 .await
1303 .map(|outcome| TaskRecord::from(outcome.task))
1304 .map_err(Error::from_internal)
1305 }
1306
1307 async fn workspace(&self, workspace_id: &WorkspaceId) -> Result<Workspace, Error> {
1308 self.database
1309 .workspace_for_id(workspace_id)
1310 .await
1311 .map_err(|error| {
1312 let error = Error::from_internal(error);
1313 if error.code == ErrorCode::NotFound {
1314 Error::new(
1315 ErrorCode::NotFound,
1316 format!("workspace not found: {workspace_id}"),
1317 )
1318 } else {
1319 error
1320 }
1321 })
1322 }
1323}
1324
1325fn validate_project(project: &str) -> Result<(), Error> {
1326 if crate::projects::normalize_key(project).is_empty() {
1327 return Err(Error::new(
1328 ErrorCode::Validation,
1329 "project must contain at least one letter or number".to_string(),
1330 ));
1331 }
1332 Ok(())
1333}
1334
1335fn validate_optional_date(field: &str, value: Option<&str>) -> Result<(), Error> {
1336 let Some(value) = value else {
1337 return Ok(());
1338 };
1339 if value.is_empty() {
1340 return Err(Error::new(
1341 ErrorCode::Validation,
1342 format!("{field} must be absent or contain a date"),
1343 ));
1344 }
1345 let result = match field {
1346 "available_at" => crate::time_validation::validate_available_at_value(value),
1347 "due_on" => crate::time_validation::validate_due_on_value(value),
1348 _ => unreachable!("consumer API validates only task dates"),
1349 };
1350 result.map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
1351}
1352
1353fn validate_date_update(field: &str, update: &OptionalDateUpdate) -> Result<(), Error> {
1354 match update {
1355 OptionalDateUpdate::Unchanged | OptionalDateUpdate::Clear => Ok(()),
1356 OptionalDateUpdate::Set(value) => validate_optional_date(field, Some(value)),
1357 }
1358}
1359
1360fn parse_date(field: &str, value: &str) -> Result<NaiveDate, Error> {
1361 NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| {
1362 Error::new(
1363 ErrorCode::Validation,
1364 format!("{field} must use YYYY-MM-DD"),
1365 )
1366 })
1367}
1368
1369fn parse_local_time(field: &str, value: &str) -> Result<NaiveTime, Error> {
1370 NaiveTime::parse_from_str(value, "%H:%M")
1371 .or_else(|_| NaiveTime::parse_from_str(value, "%H:%M:%S"))
1372 .map_err(|_| {
1373 Error::new(
1374 ErrorCode::Validation,
1375 format!("{field} must use HH:MM or HH:MM:SS"),
1376 )
1377 })
1378}
1379
1380#[derive(Debug, Clone, PartialEq, Eq)]
1381pub struct StorageLayout {
1382 pub root: PathBuf,
1383 pub objects: PathBuf,
1384 pub staging: PathBuf,
1385 pub trash: PathBuf,
1386 pub previews: PathBuf,
1387}
1388
1389#[derive(Debug, Clone, PartialEq, Eq)]
1390pub struct WorkspaceRecord {
1391 pub id: WorkspaceId,
1392 pub key: String,
1393 pub name: String,
1394}
1395
1396impl From<Workspace> for WorkspaceRecord {
1397 fn from(workspace: Workspace) -> Self {
1398 Self {
1399 id: workspace.id,
1400 key: workspace.key,
1401 name: workspace.name,
1402 }
1403 }
1404}
1405
1406#[derive(Debug, Clone, PartialEq, Eq)]
1407pub struct WorkspaceQueueSummary {
1408 pub workspace: WorkspaceRecord,
1409 pub open_task_count: u32,
1410}
1411
1412#[derive(Debug, Clone, PartialEq, Eq)]
1413pub struct ProjectRecord {
1414 pub id: ProjectId,
1415 pub key: String,
1416 pub name: String,
1417}
1418
1419impl From<Project> for ProjectRecord {
1420 fn from(project: Project) -> Self {
1421 Self {
1422 id: project.id,
1423 key: project.key,
1424 name: project.name,
1425 }
1426 }
1427}
1428
1429#[derive(Debug, Clone, PartialEq, Eq)]
1430pub struct QueueState {
1431 pub selected_workspace: WorkspaceRecord,
1432 pub workspaces: Vec<WorkspaceQueueSummary>,
1433 pub projects: Vec<ProjectRecord>,
1434 pub labels: Vec<String>,
1435 pub queue: QueueReport,
1436}
1437
1438#[derive(Debug, Clone, PartialEq, Eq)]
1439pub struct TaskListRow {
1440 pub id: TaskId,
1441 pub title: String,
1442 pub display_ref: String,
1443 pub project_key: String,
1444 pub status: TaskStatus,
1445 pub priority: TaskPriority,
1446 pub due_on: Option<String>,
1447 pub is_epic: bool,
1448}
1449
1450impl From<TaskListItem> for TaskListRow {
1451 fn from(item: TaskListItem) -> Self {
1452 Self {
1453 id: item.task.id,
1454 title: item.task.title,
1455 display_ref: item.display_ref,
1456 project_key: item.task.project_key,
1457 status: item.task.status,
1458 priority: item.task.priority,
1459 due_on: item.task.due_on,
1460 is_epic: item.task.is_epic,
1461 }
1462 }
1463}
1464
1465#[derive(Debug, Clone, PartialEq, Eq)]
1466pub struct TaskSearchResult {
1467 pub task: TaskListRow,
1468 pub matched_field: SearchMatchedField,
1469 pub snippet: Option<String>,
1470}
1471
1472pub use crate::query::TaskNote;
1473
1474#[derive(Debug, Clone, PartialEq, Eq)]
1475pub struct TaskLink {
1476 pub task_id: TaskId,
1477 pub display_ref: String,
1478 pub title: String,
1479 pub project_key: Option<String>,
1480 pub status: TaskStatus,
1481 pub deleted: bool,
1482 pub unresolved: bool,
1483}
1484
1485#[derive(Debug, Clone, PartialEq, Eq)]
1486pub enum AttachmentRead {
1487 Bytes { bytes: Vec<u8> },
1488 Unavailable,
1489 Missing,
1490 Corrupt,
1491 Invalidated,
1492}
1493
1494#[derive(Debug, Clone, PartialEq, Eq)]
1495pub struct TaskAttachment {
1496 pub attachment_id: String,
1497 pub media_type: String,
1498 pub byte_size: i64,
1499 pub filename: Option<String>,
1500 pub alt_text: Option<String>,
1501 pub width: Option<i64>,
1502 pub height: Option<i64>,
1503 pub availability: AttachmentAvailability,
1504 pub has_blob: bool,
1505}
1506
1507#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1508pub enum TaskActivityKind {
1509 Created,
1510 Title,
1511 Description,
1512 Status,
1513 Priority,
1514 Project,
1515 Deletion,
1516 Availability,
1517 DueDate,
1518 Epic,
1519 Attachment,
1520 Metadata,
1521 Label,
1522 Note,
1523 Blocker,
1524 Related,
1525 Conflict,
1526 Other,
1527}
1528
1529#[derive(Debug, Clone, PartialEq, Eq)]
1530pub struct TaskActivity {
1531 pub change_id: String,
1532 pub created_at: String,
1533 pub kind: TaskActivityKind,
1534 pub summary: String,
1535 pub available_at: Option<String>,
1539 pub anchors_queue_idle: bool,
1540}
1541
1542#[derive(Debug, Clone, PartialEq, Eq)]
1543pub struct TaskDetail {
1544 pub id: TaskId,
1545 pub workspace_id: WorkspaceId,
1546 pub workspace_key: String,
1547 pub workspace_name: String,
1548 pub title: String,
1549 pub description: String,
1550 pub display_ref: String,
1551 pub project_key: String,
1552 pub project_name: String,
1553 pub status: TaskStatus,
1554 pub priority: TaskPriority,
1555 pub created_at: String,
1556 pub updated_at: String,
1557 pub available_at: Option<String>,
1558 pub due_on: Option<String>,
1559 pub deleted: bool,
1560 pub is_epic: bool,
1561 pub labels: Vec<String>,
1562 pub notes: Vec<TaskNote>,
1563 pub activity: Vec<TaskActivity>,
1564 pub blocked_by: Vec<TaskLink>,
1565 pub blocks: Vec<TaskLink>,
1566 pub related: Vec<TaskLink>,
1567 pub epic_parent: Option<TaskLink>,
1568 pub epic_children: Vec<TaskLink>,
1569 pub epic_done_count: u32,
1570 pub epic_total_count: u32,
1571 pub epic_progress: Option<EpicProgress>,
1572 pub attachments: Vec<TaskAttachment>,
1573 pub unresolved_conflict_count: u32,
1574 pub unresolved_conflict_fields: Vec<ConflictField>,
1575}
1576
1577impl TaskDetail {
1578 fn from_detail(detail: crate::query::TaskDetail, workspace: Workspace) -> Result<Self, Error> {
1579 let item = detail.item;
1580 let anchor = item.queue_idle_activity_index();
1581 let activity = item
1582 .activity
1583 .iter()
1584 .enumerate()
1585 .map(|(index, action)| TaskActivity {
1586 change_id: action.change_id.clone(),
1587 created_at: action.created_at.clone(),
1588 kind: match action.verb.as_str() {
1589 "create" => TaskActivityKind::Created,
1590 "title" => TaskActivityKind::Title,
1591 "details" => TaskActivityKind::Description,
1592 "status" => TaskActivityKind::Status,
1593 "priority" => TaskActivityKind::Priority,
1594 "project" => TaskActivityKind::Project,
1595 "delete" => TaskActivityKind::Deletion,
1596 "availability" => TaskActivityKind::Availability,
1597 "due date" => TaskActivityKind::DueDate,
1598 "epic" => TaskActivityKind::Epic,
1599 "attachment" => TaskActivityKind::Attachment,
1600 "metadata" => TaskActivityKind::Metadata,
1601 "label" => TaskActivityKind::Label,
1602 "note" => TaskActivityKind::Note,
1603 "blocker" => TaskActivityKind::Blocker,
1604 "related link" => TaskActivityKind::Related,
1605 "conflict" => TaskActivityKind::Conflict,
1606 _ => TaskActivityKind::Other,
1607 },
1608 summary: action.task_activity_summary(&item.task.title),
1609 available_at: action.task_activity_available_at(),
1610 anchors_queue_idle: anchor == Some(index),
1611 })
1612 .collect();
1613 let task = item.task;
1614 let blocked_by = detail
1615 .dependencies
1616 .depends_on
1617 .into_iter()
1618 .map(task_link_from_dependency)
1619 .collect();
1620 let blocks = detail
1621 .dependencies
1622 .blocks
1623 .into_iter()
1624 .map(task_link_from_dependency)
1625 .collect();
1626 let related = detail
1627 .related
1628 .into_iter()
1629 .map(|link| TaskLink {
1630 task_id: link.task_id,
1631 display_ref: link.display_ref,
1632 title: link.title,
1633 project_key: None,
1634 status: link.status,
1635 deleted: link.deleted,
1636 unresolved: false,
1637 })
1638 .collect();
1639 let epic_parent = item.epic_parent.map(task_link_from_enriched).transpose()?;
1640 let epic_children = item
1641 .epic_children
1642 .into_iter()
1643 .map(task_link_from_enriched)
1644 .collect::<Result<Vec<_>, _>>()?;
1645 let epic_progress = item.epic_rollup.clone().map(EpicProgress::from);
1646 let (epic_done_count, epic_total_count) = item
1647 .epic_rollup
1648 .map(|rollup| {
1649 (
1650 rollup.done.min(u32::MAX as usize) as u32,
1651 rollup.total.min(u32::MAX as usize) as u32,
1652 )
1653 })
1654 .unwrap_or_default();
1655 let attachments = item
1656 .attachments
1657 .into_iter()
1658 .map(|attachment| TaskAttachment {
1659 attachment_id: attachment.attachment_id,
1660 media_type: attachment.media_type,
1661 byte_size: attachment.byte_size,
1662 filename: attachment.filename,
1663 alt_text: attachment.alt_text,
1664 width: attachment.width,
1665 height: attachment.height,
1666 availability: attachment.bytes_state,
1667 has_blob: attachment.has_blob,
1668 })
1669 .collect();
1670 Ok(Self {
1671 id: task.id,
1672 workspace_id: task.workspace_id,
1673 workspace_key: workspace.key,
1674 workspace_name: workspace.name,
1675 title: task.title,
1676 description: task.description,
1677 display_ref: item.display_ref,
1678 project_key: task.project_key,
1679 project_name: detail.project_name,
1680 status: task.status,
1681 priority: task.priority,
1682 created_at: task.created_at,
1683 updated_at: task.updated_at,
1684 available_at: task.available_at,
1685 due_on: task.due_on,
1686 deleted: task.deleted,
1687 is_epic: task.is_epic,
1688 labels: item.labels,
1689 notes: detail.notes,
1690 activity,
1691 blocked_by,
1692 blocks,
1693 related,
1694 epic_parent,
1695 epic_children,
1696 epic_done_count,
1697 epic_total_count,
1698 epic_progress,
1699 attachments,
1700 unresolved_conflict_count: detail.conflicts.len().min(u32::MAX as usize) as u32,
1701 unresolved_conflict_fields: detail
1702 .conflicts
1703 .into_iter()
1704 .filter(|conflict| !conflict.field.starts_with("metadata:"))
1705 .map(|conflict| {
1706 TaskField::parse_or_unknown(&conflict.field)
1707 .map(ConflictField::from_task_field)
1708 .map_err(Error::from_internal)
1709 })
1710 .collect::<Result<Vec<_>, _>>()?,
1711 })
1712 }
1713}
1714
1715fn task_link_from_dependency(link: crate::query::TaskDependencyItem) -> TaskLink {
1716 TaskLink {
1717 task_id: link.task.id,
1718 display_ref: link.display_ref,
1719 title: link.task.title,
1720 project_key: Some(link.task.project_key),
1721 status: link.task.status,
1722 deleted: link.task.deleted,
1723 unresolved: link.unresolved,
1724 }
1725}
1726
1727fn task_link_from_enriched(link: crate::query::TaskDependencyLink) -> Result<TaskLink, Error> {
1728 let status =
1729 TaskStatus::parse(&link.status).map_err(|error| Error::from_internal(error.into()))?;
1730 Ok(TaskLink {
1731 task_id: link.task_id,
1732 display_ref: link.display_ref,
1733 title: link.title,
1734 project_key: None,
1735 status,
1736 deleted: false,
1737 unresolved: link.unresolved,
1738 })
1739}
1740
1741#[derive(Debug, Clone, PartialEq, Eq)]
1742pub struct TaskCapture {
1743 pub title: String,
1744 pub description: String,
1745 pub project: Option<String>,
1746 pub status: TaskStatus,
1747 pub priority: TaskPriority,
1748 pub source: TaskSource,
1749 pub due_on: Option<String>,
1750 pub labels: Vec<String>,
1751}
1752
1753#[derive(Debug, Clone, PartialEq, Eq)]
1754pub struct TaskCaptureResult {
1755 pub task_id: TaskId,
1756 pub display_ref: String,
1757 pub undo_token: String,
1758 pub state: Option<QueueState>,
1760}
1761
1762#[derive(serde::Serialize, serde::Deserialize)]
1763struct CaptureUndoToken {
1764 workspace_id: WorkspaceId,
1765 task_id: TaskId,
1766 expected: TaskUndoSnapshot,
1767}
1768
1769#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1770pub enum QueueMutationKind {
1771 Start,
1772 Done,
1773 Snooze,
1774 SetPriority,
1775}
1776
1777#[derive(Debug, Clone, PartialEq, Eq)]
1778pub struct QueueMutation {
1779 pub kind: QueueMutationKind,
1780 pub priority: Option<TaskPriority>,
1781 pub local_date: Option<String>,
1782 pub time_zone: Option<String>,
1783}
1784
1785impl QueueMutation {
1786 fn into_internal(self) -> Result<InternalConsumerTaskMutation, Error> {
1787 match self.kind {
1788 QueueMutationKind::Start => Ok(InternalConsumerTaskMutation::Start),
1789 QueueMutationKind::Done => Ok(InternalConsumerTaskMutation::Done),
1790 QueueMutationKind::SetPriority => self
1791 .priority
1792 .map(|priority| InternalConsumerTaskMutation::SetPriority {
1793 priority: priority.as_str().to_string(),
1794 })
1795 .ok_or_else(|| {
1796 Error::new(
1797 ErrorCode::Validation,
1798 "priority is required for this quick action".to_string(),
1799 )
1800 }),
1801 QueueMutationKind::Snooze => {
1802 let local_date = self.local_date.ok_or_else(|| {
1803 Error::new(
1804 ErrorCode::Validation,
1805 "local date is required for snooze".to_string(),
1806 )
1807 })?;
1808 let time_zone = self.time_zone.ok_or_else(|| {
1809 Error::new(
1810 ErrorCode::Validation,
1811 "time zone is required for snooze".to_string(),
1812 )
1813 })?;
1814 Ok(InternalConsumerTaskMutation::Snooze {
1815 available_at: tomorrow_start(&local_date, &time_zone)?,
1816 })
1817 }
1818 }
1819 }
1820}
1821
1822#[derive(Debug, Clone, PartialEq, Eq)]
1823pub struct QueueMutationResult {
1824 pub task_id: TaskId,
1825 pub undo_token: String,
1826 pub state: Option<QueueState>,
1828}
1829
1830#[derive(Debug, Clone, PartialEq, Eq)]
1831pub struct DetailStatusReceipt {
1832 pub status: TaskStatus,
1833 pub undo_token: Option<String>,
1834}
1835
1836#[derive(Debug, Clone, PartialEq, Eq)]
1837pub struct TaskDeletionReceipt {
1838 pub deleted: bool,
1839 pub undo_token: Option<String>,
1840}
1841
1842#[derive(serde::Serialize, serde::Deserialize)]
1843struct MutationUndoToken {
1844 workspace_id: WorkspaceId,
1845 task_id: TaskId,
1846 mutation: InternalConsumerTaskMutation,
1847 before: TaskUndoSnapshot,
1848 expected: TaskUndoSnapshot,
1849}
1850
1851fn tomorrow_start(local_date: &str, time_zone: &str) -> Result<String, Error> {
1852 let date = NaiveDate::parse_from_str(local_date, "%Y-%m-%d")
1853 .map_err(|_| Error::new(ErrorCode::Validation, "invalid local date".to_string()))?
1854 .checked_add_days(Days::new(1))
1855 .ok_or_else(|| Error::new(ErrorCode::Validation, "invalid local date".to_string()))?;
1856 let zone = time_zone
1857 .parse::<chrono_tz::Tz>()
1858 .map_err(|_| Error::new(ErrorCode::Validation, "invalid IANA time zone".to_string()))?;
1859 let mut local = date.and_time(NaiveTime::MIN);
1860 let resolved = loop {
1861 match zone.from_local_datetime(&local) {
1862 LocalResult::Single(value) => break value,
1863 LocalResult::Ambiguous(first, second) => break first.min(second),
1864 LocalResult::None => {
1865 local = local
1866 .checked_add_signed(chrono::Duration::minutes(1))
1867 .ok_or_else(|| {
1868 Error::new(ErrorCode::Validation, "invalid local date".to_string())
1869 })?;
1870 if local.date() != date {
1871 return Err(Error::new(
1872 ErrorCode::Validation,
1873 "time zone has no valid instant on the next local day".to_string(),
1874 ));
1875 }
1876 }
1877 }
1878 };
1879 Ok(resolved
1880 .with_timezone(&Utc)
1881 .format("%Y-%m-%dT%H:%M:%SZ")
1882 .to_string())
1883}
1884
1885#[derive(Debug, Clone, PartialEq, Eq)]
1886pub struct MetadataFieldRecord {
1887 pub id: MetadataFieldId,
1888 pub workspace_id: WorkspaceId,
1889 pub key: String,
1890 pub task_count: usize,
1891 pub series_count: usize,
1892}
1893
1894impl From<MetadataFieldUsage> for MetadataFieldRecord {
1895 fn from(value: MetadataFieldUsage) -> Self {
1896 Self {
1897 id: value.field.id,
1898 workspace_id: value.field.workspace_id,
1899 key: value.field.key,
1900 task_count: value.task_count,
1901 series_count: value.series_count,
1902 }
1903 }
1904}
1905
1906#[derive(Debug, Clone, PartialEq, Eq)]
1907pub struct MetadataValueRecord {
1908 pub field_id: MetadataFieldId,
1909 pub key: String,
1910 pub value: String,
1911}
1912
1913impl From<TaskMetadataValue> for MetadataValueRecord {
1914 fn from(value: TaskMetadataValue) -> Self {
1915 Self {
1916 field_id: value.field_id,
1917 key: value.key,
1918 value: value.value,
1919 }
1920 }
1921}
1922
1923#[derive(Debug, Clone, PartialEq, Eq)]
1924pub struct MetadataInput {
1925 pub key: String,
1926 pub value: String,
1927}
1928
1929impl From<MetadataInput> for TaskMetadataInput {
1930 fn from(input: MetadataInput) -> Self {
1931 Self {
1932 expected_field_id: None,
1933 key: input.key,
1934 value: input.value,
1935 }
1936 }
1937}
1938
1939#[derive(Debug, Clone, PartialEq, Eq)]
1940pub struct CreateTask {
1941 pub title: String,
1942 pub description: String,
1943 pub project: String,
1944 pub status: TaskStatus,
1945 pub priority: TaskPriority,
1946 pub metadata: Vec<MetadataInput>,
1947 pub available_at: Option<String>,
1948 pub due_on: Option<String>,
1949}
1950
1951#[derive(Debug, Clone, PartialEq, Eq)]
1952pub struct TaskEditContext {
1953 pub detail: TaskDetail,
1954 pub projects: Vec<ProjectRecord>,
1955 pub labels: Vec<String>,
1956}
1957
1958#[derive(Debug, Clone, PartialEq, Eq, Default)]
1959pub struct TaskEdit {
1960 pub title: Option<String>,
1961 pub description: Option<String>,
1962 pub project_id: Option<ProjectId>,
1963 pub status: Option<TaskStatus>,
1964 pub priority: Option<TaskPriority>,
1965 pub add_labels: Vec<String>,
1966 pub remove_labels: Vec<String>,
1967 pub available_at: OptionalDateUpdate,
1968 pub due_on: OptionalDateUpdate,
1969}
1970
1971#[derive(Debug, Clone, PartialEq, Eq, Default)]
1972pub struct UpdateTask {
1973 pub title: Option<String>,
1974 pub description: Option<String>,
1975 pub project: Option<String>,
1976 pub status: Option<TaskStatus>,
1977 pub priority: Option<TaskPriority>,
1978 pub set_metadata: Vec<MetadataInput>,
1979 pub remove_metadata: Vec<String>,
1980 pub available_at: OptionalDateUpdate,
1981 pub due_on: OptionalDateUpdate,
1982}
1983
1984#[derive(Debug, Clone, PartialEq, Eq, Default)]
1985pub enum OptionalDateUpdate {
1986 #[default]
1987 Unchanged,
1988 Set(String),
1989 Clear,
1990}
1991
1992impl OptionalDateUpdate {
1993 fn into_internal(self) -> Option<Option<String>> {
1994 match self {
1995 Self::Unchanged => None,
1996 Self::Set(value) => Some(Some(value)),
1997 Self::Clear => Some(None),
1998 }
1999 }
2000}
2001
2002#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2003pub enum RecurrenceWeekday {
2004 Monday,
2005 Tuesday,
2006 Wednesday,
2007 Thursday,
2008 Friday,
2009 Saturday,
2010 Sunday,
2011}
2012
2013impl From<RecurrenceWeekday> for Weekday {
2014 fn from(value: RecurrenceWeekday) -> Self {
2015 match value {
2016 RecurrenceWeekday::Monday => Self::Mon,
2017 RecurrenceWeekday::Tuesday => Self::Tue,
2018 RecurrenceWeekday::Wednesday => Self::Wed,
2019 RecurrenceWeekday::Thursday => Self::Thu,
2020 RecurrenceWeekday::Friday => Self::Fri,
2021 RecurrenceWeekday::Saturday => Self::Sat,
2022 RecurrenceWeekday::Sunday => Self::Sun,
2023 }
2024 }
2025}
2026
2027impl From<Weekday> for RecurrenceWeekday {
2028 fn from(value: Weekday) -> Self {
2029 match value {
2030 Weekday::Mon => Self::Monday,
2031 Weekday::Tue => Self::Tuesday,
2032 Weekday::Wed => Self::Wednesday,
2033 Weekday::Thu => Self::Thursday,
2034 Weekday::Fri => Self::Friday,
2035 Weekday::Sat => Self::Saturday,
2036 Weekday::Sun => Self::Sunday,
2037 }
2038 }
2039}
2040
2041#[derive(Debug, Clone, PartialEq, Eq)]
2042pub struct RecurrenceRule {
2043 pub frequency: RecurrenceFrequency,
2044 pub interval: u32,
2045 pub weekdays: Vec<RecurrenceWeekday>,
2046}
2047
2048impl RecurrenceRule {
2049 fn into_internal(self) -> Result<InternalRecurrenceRule, Error> {
2050 InternalRecurrenceRule::new(
2051 self.frequency,
2052 self.interval,
2053 WeekdaySet::from_weekdays(self.weekdays.into_iter().map(Into::into)),
2054 )
2055 .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
2056 }
2057}
2058
2059impl From<InternalRecurrenceRule> for RecurrenceRule {
2060 fn from(value: InternalRecurrenceRule) -> Self {
2061 Self {
2062 frequency: value.frequency(),
2063 interval: value.interval(),
2064 weekdays: value.weekdays_set().iter().map(Into::into).collect(),
2065 }
2066 }
2067}
2068
2069#[derive(Debug, Clone, PartialEq, Eq)]
2070pub struct RecurrenceScheduleInput {
2071 pub rule: RecurrenceRule,
2072 pub timezone: String,
2073 pub start_on: String,
2074 pub available_local_time: Option<String>,
2075 pub due_policy: RecurrenceDuePolicy,
2076}
2077
2078impl RecurrenceScheduleInput {
2079 fn into_internal(self) -> Result<RecurrenceSchedule, Error> {
2080 let timezone = self
2081 .timezone
2082 .parse::<TimeZoneId>()
2083 .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))?;
2084 let start_on = parse_date("start_on", &self.start_on)?;
2085 let available_local_time = self
2086 .available_local_time
2087 .map(|value| parse_local_time("available_local_time", &value))
2088 .transpose()?;
2089 Ok(RecurrenceSchedule::new(
2090 self.rule.into_internal()?,
2091 timezone,
2092 start_on,
2093 available_local_time,
2094 self.due_policy,
2095 ))
2096 }
2097}
2098
2099#[derive(Debug, Clone, PartialEq, Eq)]
2100pub struct CreateRecurrenceSeries {
2101 pub title: String,
2102 pub description: String,
2103 pub project: String,
2104 pub priority: TaskPriority,
2105 pub initial_status: TaskStatus,
2106 pub labels: Vec<String>,
2107 pub metadata: Vec<MetadataInput>,
2108 pub schedule: RecurrenceScheduleInput,
2109}
2110
2111#[derive(Debug, Clone, PartialEq, Eq, Default)]
2112pub enum OptionalLocalTimeUpdate {
2113 #[default]
2114 Unchanged,
2115 Set(String),
2116 Clear,
2117}
2118
2119impl OptionalLocalTimeUpdate {
2120 fn into_internal(self) -> Result<Option<Option<NaiveTime>>, Error> {
2121 match self {
2122 Self::Unchanged => Ok(None),
2123 Self::Set(value) => Ok(Some(Some(parse_local_time(
2124 "available_local_time",
2125 &value,
2126 )?))),
2127 Self::Clear => Ok(Some(None)),
2128 }
2129 }
2130}
2131
2132#[derive(Debug, Clone, PartialEq, Eq, Default)]
2133pub struct UpdateRecurrenceTemplate {
2134 pub title: Option<String>,
2135 pub description: Option<String>,
2136 pub project: Option<String>,
2137 pub priority: Option<TaskPriority>,
2138 pub initial_status: Option<TaskStatus>,
2139 pub labels: Option<Vec<String>>,
2140 pub set_metadata: Vec<MetadataInput>,
2141 pub remove_metadata: Vec<String>,
2142 pub available_local_time: OptionalLocalTimeUpdate,
2143 pub due_policy: Option<RecurrenceDuePolicy>,
2144}
2145
2146#[derive(Debug, Clone, PartialEq, Eq)]
2147pub struct RecurrenceSeriesRecord {
2148 pub workspace_id: WorkspaceId,
2149 pub id: RecurrenceSeriesId,
2150 pub title: String,
2151 pub description: String,
2152 pub project_id: ProjectId,
2153 pub priority: TaskPriority,
2154 pub initial_status: TaskStatus,
2155 pub rule: RecurrenceRule,
2156 pub timezone: String,
2157 pub start_on: String,
2158 pub available_local_time: Option<String>,
2159 pub due_policy: RecurrenceDuePolicy,
2160 pub state: RecurrenceSeriesState,
2161 pub stopped_at: Option<String>,
2162 pub created_at: String,
2163 pub updated_at: String,
2164}
2165
2166impl From<RecurrenceSeries> for RecurrenceSeriesRecord {
2167 fn from(value: RecurrenceSeries) -> Self {
2168 Self {
2169 workspace_id: value.workspace_id,
2170 id: value.id,
2171 title: value.title,
2172 description: value.description,
2173 project_id: value.project_id,
2174 priority: value.priority,
2175 initial_status: value.initial_status,
2176 rule: value.rule.into(),
2177 timezone: value.timezone.to_string(),
2178 start_on: value.start_on.to_string(),
2179 available_local_time: value
2180 .available_local_time
2181 .map(|time| time.format("%H:%M:%S").to_string()),
2182 due_policy: value.due_policy,
2183 state: value.state,
2184 stopped_at: value.stopped_at,
2185 created_at: value.created_at,
2186 updated_at: value.updated_at,
2187 }
2188 }
2189}
2190
2191#[derive(Debug, Clone, PartialEq, Eq)]
2192pub struct RecurrenceOccurrenceRecord {
2193 pub series_id: RecurrenceSeriesId,
2194 pub slot_on: String,
2195 pub task_id: Option<TaskId>,
2196 pub outcome: Option<RecurrenceOutcome>,
2197 pub resolved_at: Option<String>,
2198 pub projection_state: RecurrenceProjectionState,
2199 pub archived_at: Option<String>,
2200}
2201
2202impl From<RecurrenceOccurrence> for RecurrenceOccurrenceRecord {
2203 fn from(value: RecurrenceOccurrence) -> Self {
2204 Self {
2205 series_id: value.series_id,
2206 slot_on: value.slot_on.to_string(),
2207 task_id: value.task_id,
2208 outcome: value.outcome,
2209 resolved_at: value.resolved_at,
2210 projection_state: value.projection_state,
2211 archived_at: value.archived_at,
2212 }
2213 }
2214}
2215
2216#[derive(Debug, Clone, PartialEq, Eq)]
2217pub struct RecurrenceCreateResult {
2218 pub series: RecurrenceSeriesRecord,
2219 pub series_ref: String,
2220 pub occurrence: RecurrenceOccurrenceRecord,
2221 pub task: TaskRecord,
2222}
2223
2224#[derive(Debug, Clone, PartialEq, Eq)]
2225pub struct RecurrenceTemplateUpdateResult {
2226 pub series: RecurrenceSeriesRecord,
2227 pub changed: bool,
2228}
2229
2230#[derive(Debug, Clone, PartialEq, Eq)]
2231pub struct RecurrenceResolveResult {
2232 pub series: RecurrenceSeriesRecord,
2233 pub occurrence: RecurrenceOccurrenceRecord,
2234 pub task: TaskRecord,
2235 pub successor: Option<TaskRecord>,
2236}
2237
2238#[derive(Debug, Clone, PartialEq, Eq)]
2239pub struct RecurrenceStateResult {
2240 pub series: RecurrenceSeriesRecord,
2241 pub occurrence: Option<RecurrenceOccurrenceRecord>,
2242}
2243
2244impl From<crate::operations::RecurrenceStateOutcome> for RecurrenceStateResult {
2245 fn from(value: crate::operations::RecurrenceStateOutcome) -> Self {
2246 Self {
2247 series: value.series.into(),
2248 occurrence: value.occurrence.map(Into::into),
2249 }
2250 }
2251}
2252
2253#[derive(Debug, Clone, PartialEq, Eq)]
2254pub struct RecurrenceRefResolution {
2255 pub series_id: RecurrenceSeriesId,
2256 pub series_ref: String,
2257}
2258
2259#[derive(Debug, Clone, PartialEq, Eq, Default)]
2260pub struct RecurrenceCounts {
2261 pub completed: u64,
2262 pub skipped: u64,
2263 pub missed: u64,
2264 pub pause_intervals: u64,
2265 pub latest_slot_on: Option<String>,
2266 pub latest_outcome: Option<RecurrenceOutcome>,
2267}
2268
2269impl From<InternalRecurrenceCounts> for RecurrenceCounts {
2270 fn from(value: InternalRecurrenceCounts) -> Self {
2271 Self {
2272 completed: value.completed as u64,
2273 skipped: value.skipped as u64,
2274 missed: value.missed as u64,
2275 pause_intervals: value.pause_intervals as u64,
2276 latest_slot_on: value.latest_slot_on,
2277 latest_outcome: value.latest_outcome,
2278 }
2279 }
2280}
2281
2282#[derive(Debug, Clone, PartialEq, Eq)]
2283pub struct RecurrenceSeriesSummary {
2284 pub series: RecurrenceSeriesRecord,
2285 pub series_ref: String,
2286 pub rule_label: String,
2287 pub current_slot_on: Option<String>,
2288 pub current_task_ref: Option<String>,
2289 pub counts: RecurrenceCounts,
2290}
2291
2292impl From<InternalRecurrenceSeriesSummary> for RecurrenceSeriesSummary {
2293 fn from(value: InternalRecurrenceSeriesSummary) -> Self {
2294 Self {
2295 series: value.series.into(),
2296 series_ref: value.series_ref,
2297 rule_label: value.rule_label,
2298 current_slot_on: value.current_slot_on,
2299 current_task_ref: value.current_task_ref,
2300 counts: value.counts.into(),
2301 }
2302 }
2303}
2304
2305#[derive(Debug, Clone, PartialEq, Eq)]
2306pub struct RecurrenceSeriesConflict {
2307 pub field: String,
2308 pub variant_a: String,
2309 pub local_value: String,
2310 pub variant_b: String,
2311 pub remote_value: String,
2312}
2313
2314#[derive(Debug, Clone, PartialEq, Eq)]
2315pub struct RecurrenceSeriesDetail {
2316 pub series: RecurrenceSeriesRecord,
2317 pub labels: Vec<String>,
2318 pub metadata: Vec<MetadataValueRecord>,
2319 pub summary: RecurrenceSeriesSummary,
2320 pub current_occurrence: Option<RecurrenceOccurrenceRecord>,
2321 pub lifecycle_conflicts: Vec<RecurrenceSeriesConflict>,
2322}
2323
2324impl From<InternalRecurrenceSeriesDetail> for RecurrenceSeriesDetail {
2325 fn from(value: InternalRecurrenceSeriesDetail) -> Self {
2326 Self {
2327 series: value.series.into(),
2328 labels: value.labels,
2329 metadata: value.metadata.into_iter().map(Into::into).collect(),
2330 summary: value.summary.into(),
2331 current_occurrence: value.current_occurrence.map(Into::into),
2332 lifecycle_conflicts: value
2333 .lifecycle_conflicts
2334 .into_iter()
2335 .map(|conflict| RecurrenceSeriesConflict {
2336 field: conflict.field,
2337 variant_a: conflict.variant_a,
2338 local_value: conflict.local_value,
2339 variant_b: conflict.variant_b,
2340 remote_value: conflict.remote_value,
2341 })
2342 .collect(),
2343 }
2344 }
2345}
2346
2347#[derive(Debug, Clone, PartialEq, Eq)]
2348pub struct RecurrenceHistoryRow {
2349 pub kind: RecurrenceHistoryKind,
2350 pub slot_on: Option<String>,
2351 pub interval_started_at: Option<String>,
2352 pub interval_ended_at: Option<String>,
2353 pub task_id: Option<TaskId>,
2354 pub task_ref: Option<String>,
2355 pub openable: bool,
2356 pub archived_projection: bool,
2357 pub resolved_at: Option<String>,
2358}
2359
2360impl From<InternalRecurrenceHistoryEntry> for RecurrenceHistoryRow {
2361 fn from(value: InternalRecurrenceHistoryEntry) -> Self {
2362 Self {
2363 kind: value.kind,
2364 slot_on: value.slot_on,
2365 interval_started_at: value.interval_started_at,
2366 interval_ended_at: value.interval_ended_at,
2367 task_id: value.task_id,
2368 task_ref: value.task_ref,
2369 openable: value.openable,
2370 archived_projection: value.archived_projection,
2371 resolved_at: value.resolved_at,
2372 }
2373 }
2374}
2375
2376#[derive(Debug, Clone, PartialEq, Eq)]
2377pub struct RecurrenceHistoryPage {
2378 pub series_ref: String,
2379 pub items: Vec<RecurrenceHistoryRow>,
2380 pub offset: usize,
2381 pub limit: usize,
2382 pub total: usize,
2383 pub has_more: bool,
2384}
2385
2386#[derive(Debug, Clone, PartialEq, Eq)]
2387pub struct TaskRecurrenceSummary {
2388 pub series_id: RecurrenceSeriesId,
2389 pub series_ref: String,
2390 pub slot_on: String,
2391 pub rule_label: String,
2392 pub timezone: String,
2393 pub lifecycle: RecurrenceSeriesState,
2394 pub outcome: Option<RecurrenceOutcome>,
2395 pub projection_state: RecurrenceProjectionState,
2396}
2397
2398#[derive(Debug, Clone, PartialEq, Eq)]
2399pub struct RecurrenceTaskGroup {
2400 pub series_id: RecurrenceSeriesId,
2401 pub series_ref: String,
2402 pub counts: RecurrenceCounts,
2403}
2404
2405#[derive(Debug, Clone, PartialEq, Eq)]
2406pub enum QueueDate {
2407 Due { on: String, kind: QueueDateKind },
2408 Deferred { at: String },
2409}
2410
2411#[derive(Debug, Clone, PartialEq, Eq)]
2412pub struct QueueLabelSummary {
2413 pub first: String,
2414 pub remaining_count: u32,
2415}
2416
2417#[derive(Debug, Clone, PartialEq, Eq)]
2418pub struct QueueTaskSummary {
2419 pub id: TaskId,
2420 pub title: String,
2421 pub display_ref: String,
2422 pub project_key: String,
2423 pub status: TaskStatus,
2424 pub priority: TaskPriority,
2425 pub due_on: Option<String>,
2426 pub band: QueueBand,
2427 pub reason: Option<QueueReason>,
2428 pub date: Option<QueueDate>,
2429 pub label: Option<QueueLabelSummary>,
2430 pub is_epic: bool,
2431 pub live_attachment_count: u32,
2432 pub has_conflict: bool,
2433 pub unresolved_blocker_count: i64,
2434}
2435
2436impl From<TaskListItem> for QueueTaskSummary {
2437 fn from(value: TaskListItem) -> Self {
2438 let date = if value.queue.has_deferred_date {
2439 value
2440 .task
2441 .available_at
2442 .clone()
2443 .map(|at| QueueDate::Deferred { at })
2444 } else {
2445 value.queue.date_kind.and_then(|kind| {
2446 value
2447 .task
2448 .due_on
2449 .clone()
2450 .map(|on| QueueDate::Due { on, kind })
2451 })
2452 };
2453 let label = value
2454 .labels
2455 .first()
2456 .cloned()
2457 .map(|first| QueueLabelSummary {
2458 first,
2459 remaining_count: value.labels.len().saturating_sub(1).min(u32::MAX as usize) as u32,
2460 });
2461 Self {
2462 id: value.task.id,
2463 title: value.task.title,
2464 display_ref: value.display_ref,
2465 project_key: value.task.project_key,
2466 status: value.task.status,
2467 priority: value.task.priority,
2468 due_on: value.task.due_on,
2469 band: value.queue.band,
2470 reason: value.queue.reason,
2471 date,
2472 label,
2473 is_epic: value.task.is_epic,
2474 live_attachment_count: value.live_attachment_count,
2475 has_conflict: value.has_conflict,
2476 unresolved_blocker_count: value.unresolved_blocker_count,
2477 }
2478 }
2479}
2480
2481#[derive(Debug, Clone, PartialEq, Eq)]
2482pub struct QueueReport {
2483 pub tasks: Vec<QueueTaskSummary>,
2484 pub unresolved_conflict_count: u32,
2485}
2486
2487#[derive(Debug, Clone, PartialEq, Eq)]
2488pub struct TaskSummary {
2489 pub task: TaskRecord,
2490 pub display_ref: String,
2491 pub recurrence: Option<TaskRecurrenceSummary>,
2492 pub recurrence_group: Option<RecurrenceTaskGroup>,
2493}
2494
2495impl From<TaskListItem> for TaskSummary {
2496 fn from(value: TaskListItem) -> Self {
2497 Self {
2498 task: value.task.into(),
2499 display_ref: value.display_ref,
2500 recurrence: value.recurrence.map(|summary| TaskRecurrenceSummary {
2501 series_id: summary.series_id,
2502 series_ref: summary.series_ref,
2503 slot_on: summary.slot_on,
2504 rule_label: summary.rule_label,
2505 timezone: summary.timezone,
2506 lifecycle: summary.lifecycle,
2507 outcome: summary.outcome,
2508 projection_state: summary.projection_state,
2509 }),
2510 recurrence_group: value.recurrence_group.map(|group| RecurrenceTaskGroup {
2511 series_id: group.series_id,
2512 series_ref: group.series_ref,
2513 counts: group.counts.into(),
2514 }),
2515 }
2516 }
2517}
2518
2519#[derive(Debug, Clone, PartialEq, Eq)]
2520pub struct RelatedTaskRecord {
2521 pub task_id: TaskId,
2522 pub display_ref: String,
2523 pub title: String,
2524 pub status: TaskStatus,
2525 pub priority: TaskPriority,
2526 pub deleted: bool,
2527 pub linked_at: String,
2528}
2529
2530impl From<crate::query::TaskRelatedLink> for RelatedTaskRecord {
2531 fn from(value: crate::query::TaskRelatedLink) -> Self {
2532 Self {
2533 task_id: value.task_id,
2534 display_ref: value.display_ref,
2535 title: value.title,
2536 status: value.status,
2537 priority: value.priority,
2538 deleted: value.deleted,
2539 linked_at: value.linked_at,
2540 }
2541 }
2542}
2543
2544#[derive(Debug, Clone, PartialEq, Eq)]
2545pub struct RelatedMutationResult {
2546 pub changed: bool,
2547}
2548
2549#[derive(Debug, Clone, PartialEq, Eq)]
2550pub struct TaskRecord {
2551 pub id: TaskId,
2552 pub workspace_id: WorkspaceId,
2553 pub title: String,
2554 pub description: String,
2555 pub project_id: ProjectId,
2556 pub project_key: String,
2557 pub project_prefix: String,
2558 pub status: TaskStatus,
2559 pub priority: TaskPriority,
2560 pub created_at: String,
2561 pub updated_at: String,
2562 pub available_at: Option<String>,
2563 pub due_on: Option<String>,
2564 pub metadata: Vec<MetadataValueRecord>,
2565 pub related: Vec<RelatedTaskRecord>,
2566}
2567
2568impl From<Task> for TaskRecord {
2569 fn from(task: Task) -> Self {
2570 Self {
2571 id: task.id,
2572 workspace_id: task.workspace_id,
2573 title: task.title,
2574 description: task.description,
2575 project_id: task.project_id,
2576 project_key: task.project_key,
2577 project_prefix: task.project_prefix,
2578 status: task.status,
2579 priority: task.priority,
2580 created_at: task.created_at,
2581 updated_at: task.updated_at,
2582 available_at: task.available_at,
2583 due_on: task.due_on,
2584 metadata: Vec::new(),
2585 related: Vec::new(),
2586 }
2587 }
2588}
2589
2590impl TaskRecord {
2591 fn with_metadata(task: Task, metadata: Vec<TaskMetadataValue>) -> Self {
2592 Self::with_metadata_and_related(task, metadata, Vec::new())
2593 }
2594
2595 fn with_metadata_and_related(
2596 task: Task,
2597 metadata: Vec<TaskMetadataValue>,
2598 related: Vec<crate::query::TaskRelatedLink>,
2599 ) -> Self {
2600 let mut record = Self::from(task);
2601 record.metadata = metadata.into_iter().map(Into::into).collect();
2602 record.related = related.into_iter().map(Into::into).collect();
2603 record
2604 }
2605}
2606
2607#[derive(Debug, Clone, PartialEq, Eq)]
2608pub struct TaskUpdateResult {
2609 pub task: TaskRecord,
2610 pub changed: bool,
2611}
2612
2613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2614pub enum ConflictField {
2615 Title,
2616 Description,
2617 Project,
2618 Status,
2619 Priority,
2620 AvailableAt,
2621 DueOn,
2622 Deleted,
2623 IsEpic,
2624}
2625
2626impl ConflictField {
2627 pub const fn as_str(self) -> &'static str {
2628 match self {
2629 Self::Title => "title",
2630 Self::Description => "description",
2631 Self::Project => "project",
2632 Self::Status => "status",
2633 Self::Priority => "priority",
2634 Self::AvailableAt => "available_at",
2635 Self::DueOn => "due_on",
2636 Self::Deleted => "deleted",
2637 Self::IsEpic => "is_epic",
2638 }
2639 }
2640
2641 fn from_task_field(field: TaskField) -> Self {
2642 match field {
2643 TaskField::Title => Self::Title,
2644 TaskField::Description => Self::Description,
2645 TaskField::Project => Self::Project,
2646 TaskField::Status => Self::Status,
2647 TaskField::Priority => Self::Priority,
2648 TaskField::AvailableAt => Self::AvailableAt,
2649 TaskField::DueOn => Self::DueOn,
2650 TaskField::Deleted => Self::Deleted,
2651 TaskField::IsEpic => Self::IsEpic,
2652 }
2653 }
2654}
2655
2656#[derive(Debug, Clone, PartialEq, Eq)]
2657pub struct ConflictSummary {
2658 pub task_id: TaskId,
2659 pub task_title: String,
2660 pub project_key: String,
2661 pub project_prefix: String,
2662 pub field: ConflictField,
2663}
2664
2665#[derive(Debug, Clone, PartialEq, Eq)]
2666pub struct Conflict {
2667 pub task_id: TaskId,
2668 pub field: ConflictField,
2669 pub local_value: String,
2670 pub remote_value: String,
2671 pub variant_a: String,
2672 pub variant_b: String,
2673}
2674
2675#[derive(Debug, Clone, PartialEq, Eq)]
2676pub enum ConflictResolution {
2677 Local,
2678 Remote,
2679 Explicit(String),
2680}
2681
2682fn bounded_conflict_display_value(value: String) -> String {
2683 const MAX_CHARACTERS: usize = 512;
2684 let mut characters = value.chars();
2685 let preview: String = characters.by_ref().take(MAX_CHARACTERS).collect();
2686 if characters.next().is_some() {
2687 format!("{preview}…")
2688 } else {
2689 preview
2690 }
2691}
2692
2693#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2694pub enum ErrorCode {
2695 SyncCompatibility,
2696 Validation,
2697 NotFound,
2698 OpenConflict,
2699 GenerationConflict,
2700 Database,
2701 Internal,
2702}
2703
2704impl ErrorCode {
2705 pub const fn as_str(self) -> &'static str {
2706 match self {
2707 Self::SyncCompatibility => "sync_compatibility",
2708 Self::Validation => "validation",
2709 Self::NotFound => "not_found",
2710 Self::OpenConflict => "open_conflict",
2711 Self::GenerationConflict => "generation_conflict",
2712 Self::Database => "database",
2713 Self::Internal => "internal",
2714 }
2715 }
2716}
2717
2718#[derive(Debug, Clone, PartialEq, Eq)]
2719pub struct Error {
2720 pub code: ErrorCode,
2721 pub message: String,
2722}
2723
2724impl Error {
2725 fn new(code: ErrorCode, message: String) -> Self {
2726 Self { code, message }
2727 }
2728
2729 fn database_open(error: InternalError) -> Self {
2730 Self::new(ErrorCode::Database, error.to_string())
2731 }
2732
2733 fn from_internal(error: InternalError) -> Self {
2734 if error
2735 .downcast_ref::<crate::sync::protocol::SyncCompatibilityError>()
2736 .is_some()
2737 || error
2738 .downcast_ref::<crate::sync::protocol::SharedOperationCompatibilityError>()
2739 .is_some()
2740 {
2741 return Self::new(ErrorCode::SyncCompatibility, error.to_string());
2742 }
2743 let code = error
2744 .chain()
2745 .find_map(|cause| cause.downcast_ref::<crate::error::CoreError>())
2746 .map(|error| match error.kind() {
2747 crate::error::ErrorKind::Validation => ErrorCode::Validation,
2748 crate::error::ErrorKind::NotFound => ErrorCode::NotFound,
2749 crate::error::ErrorKind::OpenConflict => ErrorCode::OpenConflict,
2750 crate::error::ErrorKind::GenerationConflict => ErrorCode::GenerationConflict,
2751 })
2752 .unwrap_or_else(|| {
2753 if error
2754 .chain()
2755 .filter_map(|cause| cause.downcast_ref::<sqlx::Error>())
2756 .any(|error| matches!(error, sqlx::Error::RowNotFound))
2757 {
2758 ErrorCode::NotFound
2759 } else if error
2760 .chain()
2761 .any(|cause| cause.downcast_ref::<sqlx::Error>().is_some())
2762 {
2763 ErrorCode::Database
2764 } else {
2765 ErrorCode::Internal
2766 }
2767 });
2768 Self::new(code, error.to_string())
2769 }
2770}
2771
2772impl fmt::Display for Error {
2773 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2774 write!(formatter, "{}: {}", self.code.as_str(), self.message)
2775 }
2776}
2777
2778impl std::error::Error for Error {}
2779
2780#[cfg(test)]
2781mod tests {
2782 use super::*;
2783
2784 #[tokio::test]
2785 async fn queue_capture_receipt_survives_post_commit_read_failure() {
2786 let directory = tempfile::tempdir().unwrap();
2787 let store = Store::open(directory.path().join("capture.sqlite"))
2788 .await
2789 .unwrap();
2790 let workspace = store.resolve_workspace("default").await.unwrap();
2791 store
2792 .create_task(
2793 &workspace.id,
2794 CreateTask {
2795 title: "Seed".into(),
2796 description: String::new(),
2797 project: "ios".into(),
2798 status: TaskStatus::Done,
2799 priority: TaskPriority::None,
2800 metadata: Vec::new(),
2801 available_at: None,
2802 due_on: None,
2803 },
2804 )
2805 .await
2806 .unwrap();
2807 store
2808 .fail_queue_reads
2809 .store(true, std::sync::atomic::Ordering::SeqCst);
2810 let result = store
2811 .capture_queue_task(
2812 &workspace.id,
2813 TaskCapture {
2814 title: "Captured".into(),
2815 description: String::new(),
2816 project: Some("ios".into()),
2817 status: TaskStatus::Inbox,
2818 priority: TaskPriority::None,
2819 source: TaskSource::Ios,
2820 due_on: None,
2821 labels: Vec::new(),
2822 },
2823 )
2824 .await;
2825 let tasks = store.list_tasks(&workspace.id).await.unwrap();
2826 assert_eq!(
2827 tasks.iter().filter(|task| task.title == "Captured").count(),
2828 1
2829 );
2830 assert!(
2831 result.is_ok(),
2832 "committed capture lost its receipt: {result:?}"
2833 );
2834 let captured = result.unwrap();
2835 assert!(captured.state.is_none());
2836 assert_eq!(captured.display_ref, captured.task_id.to_string());
2837 assert!(!captured.undo_token.is_empty());
2838 let mutation_target = store
2839 .capture_queue_task(
2840 &workspace.id,
2841 TaskCapture {
2842 title: "Mutation target".into(),
2843 description: String::new(),
2844 project: Some("ios".into()),
2845 status: TaskStatus::Inbox,
2846 priority: TaskPriority::None,
2847 source: TaskSource::Ios,
2848 due_on: None,
2849 labels: Vec::new(),
2850 },
2851 )
2852 .await
2853 .unwrap();
2854 let changed = store
2856 .mutate_queue_task(
2857 &workspace.id,
2858 &mutation_target.task_id,
2859 QueueMutation {
2860 kind: QueueMutationKind::SetPriority,
2861 priority: Some(TaskPriority::High),
2862 local_date: None,
2863 time_zone: None,
2864 },
2865 )
2866 .await
2867 .unwrap();
2868 assert_eq!(changed.task_id, mutation_target.task_id);
2869 assert!(changed.state.is_none());
2870 assert_eq!(
2871 store
2872 .task_detail(&workspace.id, &mutation_target.task_id)
2873 .await
2874 .unwrap()
2875 .priority,
2876 TaskPriority::High
2877 );
2878 assert!(
2879 store
2880 .undo_queue_mutation(&changed.undo_token)
2881 .await
2882 .unwrap()
2883 .is_none()
2884 );
2885 assert_eq!(
2886 store
2887 .task_detail(&workspace.id, &mutation_target.task_id)
2888 .await
2889 .unwrap()
2890 .priority,
2891 TaskPriority::None
2892 );
2893 assert!(
2894 store
2895 .undo_queue_capture(&captured.undo_token)
2896 .await
2897 .unwrap()
2898 .is_none()
2899 );
2900 store
2901 .fail_queue_reads
2902 .store(false, std::sync::atomic::Ordering::SeqCst);
2903 let state = store.queue_state().await.unwrap();
2904 assert!(
2905 state
2906 .queue
2907 .tasks
2908 .iter()
2909 .all(|task| task.id != captured.task_id)
2910 );
2911 }
2912
2913 #[test]
2914 fn error_codes_are_stable_and_internal_errors_are_distinct() {
2915 assert_eq!(ErrorCode::Validation.as_str(), "validation");
2916 assert_eq!(ErrorCode::NotFound.as_str(), "not_found");
2917 assert_eq!(ErrorCode::OpenConflict.as_str(), "open_conflict");
2918 assert_eq!(
2919 ErrorCode::GenerationConflict.as_str(),
2920 "generation_conflict"
2921 );
2922 assert_eq!(ErrorCode::Database.as_str(), "database");
2923 assert_eq!(ErrorCode::Internal.as_str(), "internal");
2924 assert_eq!(
2925 Error::from_internal(
2926 crate::error::CoreError::generation_conflict(
2927 "error recurrence-generation-conflict slot=2026-08-01 field=task"
2928 )
2929 .into()
2930 )
2931 .code,
2932 ErrorCode::GenerationConflict
2933 );
2934 assert_eq!(
2935 Error::from_internal(
2936 crate::error::CoreError::not_found("error recurrence-series-not-found").into()
2937 )
2938 .code,
2939 ErrorCode::NotFound
2940 );
2941 assert_eq!(
2942 Error::from_internal(anyhow::anyhow!("unexpected invariant")).code,
2943 ErrorCode::Internal
2944 );
2945 assert_eq!(
2946 Error::from_internal(sqlx::Error::PoolClosed.into()).code,
2947 ErrorCode::Database
2948 );
2949 }
2950}