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