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