1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use anyhow::Error as InternalError;
5use chrono::{NaiveDate, NaiveTime, Weekday};
6
7use crate::choices::{TaskPriority, TaskSource, TaskStatus};
8use crate::db::Database;
9use crate::ids::{ProjectId, TaskId, WorkspaceId};
10use crate::operations::{
11 CreateRecurrenceSeriesParams, RecurrenceSeriesDraft,
12 RecurrenceTemplateUpdate as InternalRecurrenceTemplateUpdate, TaskDraft,
13 TaskUpdate as InternalTaskUpdate, UpdateRecurrenceTemplateParams,
14};
15pub use crate::query::RecurrenceHistoryKind;
16use crate::query::{
17 MAX_RECURRENCE_HISTORY_LIMIT, RecurrenceCounts as InternalRecurrenceCounts,
18 RecurrenceHistoryEntry as InternalRecurrenceHistoryEntry,
19 RecurrenceSeriesDetail as InternalRecurrenceSeriesDetail,
20 RecurrenceSeriesSummary as InternalRecurrenceSeriesSummary, SortDirection, TaskFilters,
21 TaskListItem, TaskQueryMode, TaskSort,
22};
23pub use crate::recurrence::{
24 RecurrenceDuePolicy, RecurrenceFrequency, RecurrenceOutcome, RecurrenceProjectionState,
25 RecurrenceSeriesState,
26};
27use crate::recurrence::{
28 RecurrenceRule as InternalRecurrenceRule, RecurrenceSchedule, RecurrenceSeriesId, TimeZoneId,
29 WeekdaySet,
30};
31use crate::sync::SyncSession;
32use crate::task_fields::TaskField;
33use crate::types::{RecurrenceOccurrence, RecurrenceSeries, Task};
34use crate::workspaces::Workspace;
35
36#[derive(Clone)]
37pub struct Store {
38 database: Database,
39}
40
41impl Store {
42 pub async fn open(path: impl AsRef<Path>) -> Result<Self, Error> {
43 let database = Database::open(path.as_ref())
44 .await
45 .map_err(Error::database_open)?;
46 Ok(Self { database })
47 }
48
49 pub fn initialize_storage(&self) -> Result<StorageLayout, Error> {
50 let root = crate::attachments::default_blob_dir(self.database.path());
51 let objects = root.join("objects").join("sha256");
52 let trash = root.join("trash");
53 let previews = root.join("cache").join("previews");
54 for directory in [&objects, &trash, &previews] {
55 std::fs::create_dir_all(directory)
56 .map_err(|error| Error::from_internal(error.into()))?;
57 }
58 Ok(StorageLayout {
59 root,
60 staging: objects.clone(),
61 objects,
62 trash,
63 previews,
64 })
65 }
66
67 pub async fn start_sync_session(
68 &self,
69 server: String,
70 auth_token: Option<String>,
71 page_budget: Option<usize>,
72 ) -> Result<SyncSession, Error> {
73 if !crate::sync::wire::sync_server_url_is_valid(&server) {
74 return Err(Error::new(
75 ErrorCode::Validation,
76 "invalid sync server URL".to_string(),
77 ));
78 }
79 SyncSession::start(self.database.clone(), server, auth_token, page_budget)
80 .await
81 .map_err(Error::from_internal)
82 }
83
84 pub async fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>, Error> {
85 self.database
86 .list_workspaces()
87 .await
88 .map(|workspaces| workspaces.into_iter().map(WorkspaceRecord::from).collect())
89 .map_err(Error::from_internal)
90 }
91
92 pub async fn resolve_workspace(&self, name_or_key: &str) -> Result<WorkspaceRecord, Error> {
93 self.database
94 .find_workspace(name_or_key)
95 .await
96 .map_err(Error::from_internal)?
97 .map(WorkspaceRecord::from)
98 .ok_or_else(|| {
99 Error::new(
100 ErrorCode::NotFound,
101 format!("workspace not found: {name_or_key}"),
102 )
103 })
104 }
105
106 pub async fn create_task(
107 &self,
108 workspace_id: &WorkspaceId,
109 input: CreateTask,
110 ) -> Result<TaskRecord, Error> {
111 validate_project(&input.project)?;
112 validate_optional_date("available_at", input.available_at.as_deref())?;
113 validate_optional_date("due_on", input.due_on.as_deref())?;
114 let workspace = self.workspace(workspace_id).await?;
115 self.database
116 .create_task(
117 &workspace,
118 TaskDraft {
119 title: input.title,
120 description: input.description,
121 project: Some(input.project),
122 status: input.status.as_str().to_string(),
123 priority: input.priority.as_str().to_string(),
124 source: TaskSource::Api,
125 labels: Vec::new(),
126 available_at: input.available_at,
127 due_on: input.due_on,
128 is_epic: false,
129 },
130 )
131 .await
132 .map(|outcome| TaskRecord::from(outcome.task))
133 .map_err(Error::from_internal)
134 }
135
136 pub async fn update_task(
137 &self,
138 workspace_id: &WorkspaceId,
139 task_id: &TaskId,
140 input: UpdateTask,
141 ) -> Result<TaskUpdateResult, Error> {
142 if let Some(project) = input.project.as_deref() {
143 validate_project(project)?;
144 }
145 validate_date_update("available_at", &input.available_at)?;
146 validate_date_update("due_on", &input.due_on)?;
147 let workspace = self.workspace(workspace_id).await?;
148 self.fetch_task(workspace_id, task_id).await?;
149 self.database
150 .update_task(
151 &workspace,
152 task_id,
153 InternalTaskUpdate {
154 title: input.title,
155 description: input.description,
156 project: input.project,
157 status: input.status.map(|status| status.as_str().to_string()),
158 priority: input.priority.map(|priority| priority.as_str().to_string()),
159 available_at: input.available_at.into_internal(),
160 due_on: input.due_on.into_internal(),
161 ..InternalTaskUpdate::default()
162 },
163 )
164 .await
165 .map(|outcome| TaskUpdateResult {
166 task: TaskRecord::from(outcome.task),
167 changed: outcome.changed,
168 })
169 .map_err(Error::from_internal)
170 }
171
172 pub async fn list_tasks(&self, workspace_id: &WorkspaceId) -> Result<Vec<TaskRecord>, Error> {
173 self.workspace(workspace_id).await?;
174 let filters = TaskFilters {
175 exclude_epics: true,
176 ..TaskFilters::default()
177 };
178 self.database
179 .list_task_items(
180 workspace_id,
181 filters,
182 TaskQueryMode::Flat,
183 TaskSort::Created,
184 SortDirection::Asc,
185 )
186 .await
187 .map(|items| {
188 items
189 .into_iter()
190 .map(|item| TaskRecord::from(item.task))
191 .collect()
192 })
193 .map_err(Error::from_internal)
194 }
195
196 pub async fn fetch_task(
197 &self,
198 workspace_id: &WorkspaceId,
199 task_id: &TaskId,
200 ) -> Result<TaskRecord, Error> {
201 let workspace = self.workspace(workspace_id).await?;
202 let mut connection = self
203 .database
204 .acquire_reader()
205 .await
206 .map_err(Error::from_internal)?;
207 crate::refs::get_task_in_workspace(&mut connection, &workspace, task_id)
208 .await
209 .map(TaskRecord::from)
210 .map_err(Error::from_internal)
211 }
212
213 pub async fn create_recurrence_series(
214 &self,
215 workspace_id: &WorkspaceId,
216 input: CreateRecurrenceSeries,
217 ) -> Result<RecurrenceCreateResult, Error> {
218 validate_project(&input.project)?;
219 let workspace = self.workspace(workspace_id).await?;
220 let schedule = input.schedule.into_internal()?;
221 self.database
222 .create_recurrence_series(
223 &workspace,
224 CreateRecurrenceSeriesParams::new(RecurrenceSeriesDraft {
225 title: input.title,
226 description: input.description,
227 project: input.project,
228 priority: input.priority.as_str().to_string(),
229 initial_status: input.initial_status.as_str().to_string(),
230 labels: input.labels,
231 schedule,
232 }),
233 )
234 .await
235 .map(|outcome| RecurrenceCreateResult {
236 series: RecurrenceSeriesRecord::from(outcome.series),
237 series_ref: outcome.series_ref,
238 occurrence: RecurrenceOccurrenceRecord::from(outcome.occurrence),
239 task: TaskRecord::from(outcome.task),
240 })
241 .map_err(Error::from_internal)
242 }
243
244 pub async fn update_recurrence_template(
245 &self,
246 workspace_id: &WorkspaceId,
247 series_id: &RecurrenceSeriesId,
248 input: UpdateRecurrenceTemplate,
249 ) -> Result<RecurrenceTemplateUpdateResult, Error> {
250 if let Some(project) = input.project.as_deref() {
251 validate_project(project)?;
252 }
253 let workspace = self.workspace(workspace_id).await?;
254 self.database
255 .update_recurrence_template(
256 &workspace,
257 series_id,
258 UpdateRecurrenceTemplateParams::new(InternalRecurrenceTemplateUpdate {
259 title: input.title,
260 description: input.description,
261 project: input.project,
262 priority: input.priority.map(|value| value.as_str().to_string()),
263 initial_status: input.initial_status.map(|value| value.as_str().to_string()),
264 labels: input.labels,
265 available_local_time: input.available_local_time.into_internal()?,
266 due_policy: input.due_policy,
267 }),
268 )
269 .await
270 .map(|outcome| RecurrenceTemplateUpdateResult {
271 series: RecurrenceSeriesRecord::from(outcome.series),
272 changed: outcome.changed,
273 })
274 .map_err(Error::from_internal)
275 }
276
277 pub async fn resolve_recurrence_ref(
278 &self,
279 workspace_id: &WorkspaceId,
280 input: &str,
281 ) -> Result<RecurrenceRefResolution, Error> {
282 let workspace = self.workspace(workspace_id).await?;
283 let series = self
284 .database
285 .resolve_recurrence_ref(&workspace, input)
286 .await
287 .map_err(Error::from_internal)?;
288 let series_ref = self
289 .database
290 .recurrence_series_ref(workspace_id, &series.id)
291 .await
292 .map_err(Error::from_internal)?;
293 Ok(RecurrenceRefResolution {
294 series_id: series.id,
295 series_ref,
296 })
297 }
298
299 pub async fn list_recurrence_series(
300 &self,
301 workspace_id: &WorkspaceId,
302 ) -> Result<Vec<RecurrenceSeriesSummary>, Error> {
303 self.workspace(workspace_id).await?;
304 self.database
305 .list_recurrence_series(workspace_id)
306 .await
307 .map(|values| values.into_iter().map(Into::into).collect())
308 .map_err(Error::from_internal)
309 }
310
311 pub async fn show_recurrence_series(
312 &self,
313 workspace_id: &WorkspaceId,
314 input: &str,
315 ) -> Result<RecurrenceSeriesDetail, Error> {
316 let resolution = self.resolve_recurrence_ref(workspace_id, input).await?;
317 self.database
318 .recurrence_series_detail(workspace_id, &resolution.series_id)
319 .await
320 .map(Into::into)
321 .map_err(Error::from_internal)
322 }
323
324 pub async fn recurrence_history(
325 &self,
326 workspace_id: &WorkspaceId,
327 input: &str,
328 offset: usize,
329 limit: usize,
330 ) -> Result<RecurrenceHistoryPage, Error> {
331 if limit == 0 || limit > MAX_RECURRENCE_HISTORY_LIMIT {
332 return Err(Error::new(
333 ErrorCode::Validation,
334 format!(
335 "recurrence history limit must be between 1 and {MAX_RECURRENCE_HISTORY_LIMIT}"
336 ),
337 ));
338 }
339 let resolution = self.resolve_recurrence_ref(workspace_id, input).await?;
340 self.database
341 .recurrence_history(workspace_id, &resolution.series_id, offset, limit)
342 .await
343 .map(|page| RecurrenceHistoryPage {
344 series_ref: page.series_ref,
345 items: page.items.into_iter().map(Into::into).collect(),
346 offset: page.offset,
347 limit: page.limit,
348 total: page.total,
349 has_more: page.has_more,
350 })
351 .map_err(Error::from_internal)
352 }
353
354 pub async fn resolve_recurrence_occurrence(
355 &self,
356 workspace_id: &WorkspaceId,
357 task_id: &TaskId,
358 outcome: RecurrenceOutcome,
359 ) -> Result<RecurrenceResolveResult, Error> {
360 let workspace = self.workspace(workspace_id).await?;
361 self.database
362 .resolve_recurrence_occurrence(&workspace, task_id, outcome)
363 .await
364 .map(|value| RecurrenceResolveResult {
365 series: value.series.into(),
366 occurrence: value.resolved.into(),
367 task: value.task.into(),
368 successor: value.successor.map(Into::into),
369 })
370 .map_err(Error::from_internal)
371 }
372
373 pub async fn complete_recurrence_occurrence(
374 &self,
375 workspace_id: &WorkspaceId,
376 task_id: &TaskId,
377 ) -> Result<RecurrenceResolveResult, Error> {
378 self.resolve_recurrence_occurrence(workspace_id, task_id, RecurrenceOutcome::Completed)
379 .await
380 }
381
382 pub async fn skip_recurrence_occurrence(
383 &self,
384 workspace_id: &WorkspaceId,
385 task_id: &TaskId,
386 ) -> Result<RecurrenceResolveResult, Error> {
387 self.resolve_recurrence_occurrence(workspace_id, task_id, RecurrenceOutcome::Skipped)
388 .await
389 }
390
391 pub async fn pause_recurrence_series(
392 &self,
393 workspace_id: &WorkspaceId,
394 series_id: &RecurrenceSeriesId,
395 ) -> Result<RecurrenceStateResult, Error> {
396 let workspace = self.workspace(workspace_id).await?;
397 self.database
398 .pause_recurrence_series(&workspace, series_id)
399 .await
400 .map(Into::into)
401 .map_err(Error::from_internal)
402 }
403
404 pub async fn resume_recurrence_series(
405 &self,
406 workspace_id: &WorkspaceId,
407 series_id: &RecurrenceSeriesId,
408 ) -> Result<RecurrenceStateResult, Error> {
409 let workspace = self.workspace(workspace_id).await?;
410 let at = chrono::DateTime::parse_from_rfc3339(&crate::ids::now())
411 .map_err(|error| Error::from_internal(error.into()))?
412 .with_timezone(&chrono::Utc);
413 self.database
414 .resume_recurrence_series(&workspace, series_id, at)
415 .await
416 .map(Into::into)
417 .map_err(Error::from_internal)
418 }
419
420 pub async fn stop_recurrence_series(
421 &self,
422 workspace_id: &WorkspaceId,
423 series_id: &RecurrenceSeriesId,
424 skip_current: bool,
425 ) -> Result<RecurrenceStateResult, Error> {
426 let workspace = self.workspace(workspace_id).await?;
427 self.database
428 .stop_recurrence_series(&workspace, series_id, skip_current)
429 .await
430 .map(Into::into)
431 .map_err(Error::from_internal)
432 }
433
434 pub async fn recurrence_task_report(
435 &self,
436 workspace_id: &WorkspaceId,
437 expand_recurring: bool,
438 ) -> Result<Vec<TaskSummary>, Error> {
439 self.workspace(workspace_id).await?;
440 self.database
441 .list_task_items(
442 workspace_id,
443 TaskFilters {
444 exclude_epics: true,
445 expand_recurring,
446 ..TaskFilters::default()
447 },
448 TaskQueryMode::Flat,
449 TaskSort::Created,
450 SortDirection::Asc,
451 )
452 .await
453 .map(|items| items.into_iter().map(TaskSummary::from).collect())
454 .map_err(Error::from_internal)
455 }
456
457 pub async fn list_conflicts(
458 &self,
459 workspace_id: &WorkspaceId,
460 ) -> Result<Vec<ConflictSummary>, Error> {
461 let workspace = self.workspace(workspace_id).await?;
462 self.database
463 .list_conflicts(&workspace, None, None)
464 .await
465 .map_err(Error::from_internal)?
466 .into_iter()
467 .map(|conflict| {
468 Ok(ConflictSummary {
469 task_id: conflict.task_id,
470 task_title: conflict.title,
471 project_key: conflict.project_key,
472 project_prefix: conflict.project_prefix,
473 field: ConflictField::from_task_field(TaskField::parse_or_unknown(
474 &conflict.field,
475 )?),
476 })
477 })
478 .collect::<Result<Vec<_>, InternalError>>()
479 .map_err(Error::from_internal)
480 }
481
482 pub async fn inspect_conflicts(
483 &self,
484 workspace_id: &WorkspaceId,
485 task_id: &TaskId,
486 ) -> Result<Vec<Conflict>, Error> {
487 let workspace = self.workspace(workspace_id).await?;
488 let details = self
489 .database
490 .task_conflicts(&workspace, task_id, None)
491 .await
492 .map_err(Error::from_internal)?;
493 let mut conflicts = Vec::with_capacity(details.len());
494 for detail in details {
495 let field = TaskField::parse_or_unknown(&detail.field).map_err(Error::from_internal)?;
496 let local_value = self
497 .database
498 .conflict_display_value(workspace_id, field.as_str(), &detail.local_value)
499 .await
500 .map_err(Error::from_internal)?;
501 let remote_value = self
502 .database
503 .conflict_display_value(workspace_id, field.as_str(), &detail.remote_value)
504 .await
505 .map_err(Error::from_internal)?;
506 conflicts.push(Conflict {
507 task_id: task_id.clone(),
508 field: ConflictField::from_task_field(field),
509 local_value,
510 remote_value,
511 });
512 }
513 Ok(conflicts)
514 }
515
516 pub async fn resolve_conflict(
517 &self,
518 workspace_id: &WorkspaceId,
519 task_id: &TaskId,
520 field: ConflictField,
521 choice: ConflictChoice,
522 ) -> Result<TaskRecord, Error> {
523 let workspace = self.workspace(workspace_id).await?;
524 let field_name = field.as_str();
525 let choice = match choice {
526 ConflictChoice::Local => crate::operations::ConflictValueChoice::Local,
527 ConflictChoice::Remote => crate::operations::ConflictValueChoice::Remote,
528 };
529 let mut connection = self
530 .database
531 .acquire_writer()
532 .await
533 .map_err(Error::from_internal)?;
534 crate::operations::resolve_conflict_choice(
535 &mut connection,
536 &workspace,
537 task_id,
538 field_name,
539 choice,
540 )
541 .await
542 .map(|outcome| TaskRecord::from(outcome.task))
543 .map_err(Error::from_internal)
544 }
545
546 async fn workspace(&self, workspace_id: &WorkspaceId) -> Result<Workspace, Error> {
547 self.database
548 .list_workspaces()
549 .await
550 .map_err(Error::from_internal)?
551 .into_iter()
552 .find(|workspace| workspace.id == *workspace_id)
553 .ok_or_else(|| {
554 Error::new(
555 ErrorCode::NotFound,
556 format!("workspace not found: {workspace_id}"),
557 )
558 })
559 }
560}
561
562fn validate_project(project: &str) -> Result<(), Error> {
563 if crate::projects::normalize_key(project).is_empty() {
564 return Err(Error::new(
565 ErrorCode::Validation,
566 "project must contain at least one letter or number".to_string(),
567 ));
568 }
569 Ok(())
570}
571
572fn validate_optional_date(field: &str, value: Option<&str>) -> Result<(), Error> {
573 let Some(value) = value else {
574 return Ok(());
575 };
576 if value.is_empty() {
577 return Err(Error::new(
578 ErrorCode::Validation,
579 format!("{field} must be absent or contain a date"),
580 ));
581 }
582 let result = match field {
583 "available_at" => crate::time_validation::validate_available_at_value(value),
584 "due_on" => crate::time_validation::validate_due_on_value(value),
585 _ => unreachable!("consumer API validates only task dates"),
586 };
587 result.map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
588}
589
590fn validate_date_update(field: &str, update: &OptionalDateUpdate) -> Result<(), Error> {
591 match update {
592 OptionalDateUpdate::Unchanged | OptionalDateUpdate::Clear => Ok(()),
593 OptionalDateUpdate::Set(value) => validate_optional_date(field, Some(value)),
594 }
595}
596
597fn parse_date(field: &str, value: &str) -> Result<NaiveDate, Error> {
598 NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| {
599 Error::new(
600 ErrorCode::Validation,
601 format!("{field} must use YYYY-MM-DD"),
602 )
603 })
604}
605
606fn parse_local_time(field: &str, value: &str) -> Result<NaiveTime, Error> {
607 NaiveTime::parse_from_str(value, "%H:%M")
608 .or_else(|_| NaiveTime::parse_from_str(value, "%H:%M:%S"))
609 .map_err(|_| {
610 Error::new(
611 ErrorCode::Validation,
612 format!("{field} must use HH:MM or HH:MM:SS"),
613 )
614 })
615}
616
617#[derive(Debug, Clone, PartialEq, Eq)]
618pub struct StorageLayout {
619 pub root: PathBuf,
620 pub objects: PathBuf,
621 pub staging: PathBuf,
622 pub trash: PathBuf,
623 pub previews: PathBuf,
624}
625
626#[derive(Debug, Clone, PartialEq, Eq)]
627pub struct WorkspaceRecord {
628 pub id: WorkspaceId,
629 pub key: String,
630 pub name: String,
631}
632
633impl From<Workspace> for WorkspaceRecord {
634 fn from(workspace: Workspace) -> Self {
635 Self {
636 id: workspace.id,
637 key: workspace.key,
638 name: workspace.name,
639 }
640 }
641}
642
643#[derive(Debug, Clone, PartialEq, Eq)]
644pub struct CreateTask {
645 pub title: String,
646 pub description: String,
647 pub project: String,
648 pub status: TaskStatus,
649 pub priority: TaskPriority,
650 pub available_at: Option<String>,
651 pub due_on: Option<String>,
652}
653
654#[derive(Debug, Clone, PartialEq, Eq, Default)]
655pub struct UpdateTask {
656 pub title: Option<String>,
657 pub description: Option<String>,
658 pub project: Option<String>,
659 pub status: Option<TaskStatus>,
660 pub priority: Option<TaskPriority>,
661 pub available_at: OptionalDateUpdate,
662 pub due_on: OptionalDateUpdate,
663}
664
665#[derive(Debug, Clone, PartialEq, Eq, Default)]
666pub enum OptionalDateUpdate {
667 #[default]
668 Unchanged,
669 Set(String),
670 Clear,
671}
672
673impl OptionalDateUpdate {
674 fn into_internal(self) -> Option<Option<String>> {
675 match self {
676 Self::Unchanged => None,
677 Self::Set(value) => Some(Some(value)),
678 Self::Clear => Some(None),
679 }
680 }
681}
682
683#[derive(Debug, Clone, Copy, PartialEq, Eq)]
684pub enum RecurrenceWeekday {
685 Monday,
686 Tuesday,
687 Wednesday,
688 Thursday,
689 Friday,
690 Saturday,
691 Sunday,
692}
693
694impl From<RecurrenceWeekday> for Weekday {
695 fn from(value: RecurrenceWeekday) -> Self {
696 match value {
697 RecurrenceWeekday::Monday => Self::Mon,
698 RecurrenceWeekday::Tuesday => Self::Tue,
699 RecurrenceWeekday::Wednesday => Self::Wed,
700 RecurrenceWeekday::Thursday => Self::Thu,
701 RecurrenceWeekday::Friday => Self::Fri,
702 RecurrenceWeekday::Saturday => Self::Sat,
703 RecurrenceWeekday::Sunday => Self::Sun,
704 }
705 }
706}
707
708impl From<Weekday> for RecurrenceWeekday {
709 fn from(value: Weekday) -> Self {
710 match value {
711 Weekday::Mon => Self::Monday,
712 Weekday::Tue => Self::Tuesday,
713 Weekday::Wed => Self::Wednesday,
714 Weekday::Thu => Self::Thursday,
715 Weekday::Fri => Self::Friday,
716 Weekday::Sat => Self::Saturday,
717 Weekday::Sun => Self::Sunday,
718 }
719 }
720}
721
722#[derive(Debug, Clone, PartialEq, Eq)]
723pub struct RecurrenceRule {
724 pub frequency: RecurrenceFrequency,
725 pub interval: u32,
726 pub weekdays: Vec<RecurrenceWeekday>,
727}
728
729impl RecurrenceRule {
730 fn into_internal(self) -> Result<InternalRecurrenceRule, Error> {
731 InternalRecurrenceRule::new(
732 self.frequency,
733 self.interval,
734 WeekdaySet::from_weekdays(self.weekdays.into_iter().map(Into::into)),
735 )
736 .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
737 }
738}
739
740impl From<InternalRecurrenceRule> for RecurrenceRule {
741 fn from(value: InternalRecurrenceRule) -> Self {
742 Self {
743 frequency: value.frequency(),
744 interval: value.interval(),
745 weekdays: value.weekdays_set().iter().map(Into::into).collect(),
746 }
747 }
748}
749
750#[derive(Debug, Clone, PartialEq, Eq)]
751pub struct RecurrenceScheduleInput {
752 pub rule: RecurrenceRule,
753 pub timezone: String,
754 pub start_on: String,
755 pub available_local_time: Option<String>,
756 pub due_policy: RecurrenceDuePolicy,
757}
758
759impl RecurrenceScheduleInput {
760 fn into_internal(self) -> Result<RecurrenceSchedule, Error> {
761 let timezone = self
762 .timezone
763 .parse::<TimeZoneId>()
764 .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))?;
765 let start_on = parse_date("start_on", &self.start_on)?;
766 let available_local_time = self
767 .available_local_time
768 .map(|value| parse_local_time("available_local_time", &value))
769 .transpose()?;
770 Ok(RecurrenceSchedule::new(
771 self.rule.into_internal()?,
772 timezone,
773 start_on,
774 available_local_time,
775 self.due_policy,
776 ))
777 }
778}
779
780#[derive(Debug, Clone, PartialEq, Eq)]
781pub struct CreateRecurrenceSeries {
782 pub title: String,
783 pub description: String,
784 pub project: String,
785 pub priority: TaskPriority,
786 pub initial_status: TaskStatus,
787 pub labels: Vec<String>,
788 pub schedule: RecurrenceScheduleInput,
789}
790
791#[derive(Debug, Clone, PartialEq, Eq, Default)]
792pub enum OptionalLocalTimeUpdate {
793 #[default]
794 Unchanged,
795 Set(String),
796 Clear,
797}
798
799impl OptionalLocalTimeUpdate {
800 fn into_internal(self) -> Result<Option<Option<NaiveTime>>, Error> {
801 match self {
802 Self::Unchanged => Ok(None),
803 Self::Set(value) => Ok(Some(Some(parse_local_time(
804 "available_local_time",
805 &value,
806 )?))),
807 Self::Clear => Ok(Some(None)),
808 }
809 }
810}
811
812#[derive(Debug, Clone, PartialEq, Eq, Default)]
813pub struct UpdateRecurrenceTemplate {
814 pub title: Option<String>,
815 pub description: Option<String>,
816 pub project: Option<String>,
817 pub priority: Option<TaskPriority>,
818 pub initial_status: Option<TaskStatus>,
819 pub labels: Option<Vec<String>>,
820 pub available_local_time: OptionalLocalTimeUpdate,
821 pub due_policy: Option<RecurrenceDuePolicy>,
822}
823
824#[derive(Debug, Clone, PartialEq, Eq)]
825pub struct RecurrenceSeriesRecord {
826 pub workspace_id: WorkspaceId,
827 pub id: RecurrenceSeriesId,
828 pub title: String,
829 pub description: String,
830 pub project_id: ProjectId,
831 pub priority: TaskPriority,
832 pub initial_status: TaskStatus,
833 pub rule: RecurrenceRule,
834 pub timezone: String,
835 pub start_on: String,
836 pub available_local_time: Option<String>,
837 pub due_policy: RecurrenceDuePolicy,
838 pub state: RecurrenceSeriesState,
839 pub stopped_at: Option<String>,
840 pub created_at: String,
841 pub updated_at: String,
842}
843
844impl From<RecurrenceSeries> for RecurrenceSeriesRecord {
845 fn from(value: RecurrenceSeries) -> Self {
846 Self {
847 workspace_id: value.workspace_id,
848 id: value.id,
849 title: value.title,
850 description: value.description,
851 project_id: value.project_id,
852 priority: value.priority,
853 initial_status: value.initial_status,
854 rule: value.rule.into(),
855 timezone: value.timezone.to_string(),
856 start_on: value.start_on.to_string(),
857 available_local_time: value
858 .available_local_time
859 .map(|time| time.format("%H:%M:%S").to_string()),
860 due_policy: value.due_policy,
861 state: value.state,
862 stopped_at: value.stopped_at,
863 created_at: value.created_at,
864 updated_at: value.updated_at,
865 }
866 }
867}
868
869#[derive(Debug, Clone, PartialEq, Eq)]
870pub struct RecurrenceOccurrenceRecord {
871 pub series_id: RecurrenceSeriesId,
872 pub slot_on: String,
873 pub task_id: Option<TaskId>,
874 pub outcome: Option<RecurrenceOutcome>,
875 pub resolved_at: Option<String>,
876 pub projection_state: RecurrenceProjectionState,
877 pub archived_at: Option<String>,
878}
879
880impl From<RecurrenceOccurrence> for RecurrenceOccurrenceRecord {
881 fn from(value: RecurrenceOccurrence) -> Self {
882 Self {
883 series_id: value.series_id,
884 slot_on: value.slot_on.to_string(),
885 task_id: value.task_id,
886 outcome: value.outcome,
887 resolved_at: value.resolved_at,
888 projection_state: value.projection_state,
889 archived_at: value.archived_at,
890 }
891 }
892}
893
894#[derive(Debug, Clone, PartialEq, Eq)]
895pub struct RecurrenceCreateResult {
896 pub series: RecurrenceSeriesRecord,
897 pub series_ref: String,
898 pub occurrence: RecurrenceOccurrenceRecord,
899 pub task: TaskRecord,
900}
901
902#[derive(Debug, Clone, PartialEq, Eq)]
903pub struct RecurrenceTemplateUpdateResult {
904 pub series: RecurrenceSeriesRecord,
905 pub changed: bool,
906}
907
908#[derive(Debug, Clone, PartialEq, Eq)]
909pub struct RecurrenceResolveResult {
910 pub series: RecurrenceSeriesRecord,
911 pub occurrence: RecurrenceOccurrenceRecord,
912 pub task: TaskRecord,
913 pub successor: Option<TaskRecord>,
914}
915
916#[derive(Debug, Clone, PartialEq, Eq)]
917pub struct RecurrenceStateResult {
918 pub series: RecurrenceSeriesRecord,
919 pub occurrence: Option<RecurrenceOccurrenceRecord>,
920}
921
922impl From<crate::operations::RecurrenceStateOutcome> for RecurrenceStateResult {
923 fn from(value: crate::operations::RecurrenceStateOutcome) -> Self {
924 Self {
925 series: value.series.into(),
926 occurrence: value.occurrence.map(Into::into),
927 }
928 }
929}
930
931#[derive(Debug, Clone, PartialEq, Eq)]
932pub struct RecurrenceRefResolution {
933 pub series_id: RecurrenceSeriesId,
934 pub series_ref: String,
935}
936
937#[derive(Debug, Clone, PartialEq, Eq, Default)]
938pub struct RecurrenceCounts {
939 pub completed: u64,
940 pub skipped: u64,
941 pub missed: u64,
942 pub pause_intervals: u64,
943 pub latest_slot_on: Option<String>,
944 pub latest_outcome: Option<RecurrenceOutcome>,
945}
946
947impl From<InternalRecurrenceCounts> for RecurrenceCounts {
948 fn from(value: InternalRecurrenceCounts) -> Self {
949 Self {
950 completed: value.completed as u64,
951 skipped: value.skipped as u64,
952 missed: value.missed as u64,
953 pause_intervals: value.pause_intervals as u64,
954 latest_slot_on: value.latest_slot_on,
955 latest_outcome: value.latest_outcome,
956 }
957 }
958}
959
960#[derive(Debug, Clone, PartialEq, Eq)]
961pub struct RecurrenceSeriesSummary {
962 pub series: RecurrenceSeriesRecord,
963 pub series_ref: String,
964 pub rule_label: String,
965 pub current_slot_on: Option<String>,
966 pub current_task_ref: Option<String>,
967 pub counts: RecurrenceCounts,
968}
969
970impl From<InternalRecurrenceSeriesSummary> for RecurrenceSeriesSummary {
971 fn from(value: InternalRecurrenceSeriesSummary) -> Self {
972 Self {
973 series: value.series.into(),
974 series_ref: value.series_ref,
975 rule_label: value.rule_label,
976 current_slot_on: value.current_slot_on,
977 current_task_ref: value.current_task_ref,
978 counts: value.counts.into(),
979 }
980 }
981}
982
983#[derive(Debug, Clone, PartialEq, Eq)]
984pub struct RecurrenceSeriesConflict {
985 pub field: String,
986 pub variant_a: String,
987 pub local_value: String,
988 pub variant_b: String,
989 pub remote_value: String,
990}
991
992#[derive(Debug, Clone, PartialEq, Eq)]
993pub struct RecurrenceSeriesDetail {
994 pub series: RecurrenceSeriesRecord,
995 pub labels: Vec<String>,
996 pub summary: RecurrenceSeriesSummary,
997 pub current_occurrence: Option<RecurrenceOccurrenceRecord>,
998 pub lifecycle_conflicts: Vec<RecurrenceSeriesConflict>,
999}
1000
1001impl From<InternalRecurrenceSeriesDetail> for RecurrenceSeriesDetail {
1002 fn from(value: InternalRecurrenceSeriesDetail) -> Self {
1003 Self {
1004 series: value.series.into(),
1005 labels: value.labels,
1006 summary: value.summary.into(),
1007 current_occurrence: value.current_occurrence.map(Into::into),
1008 lifecycle_conflicts: value
1009 .lifecycle_conflicts
1010 .into_iter()
1011 .map(|conflict| RecurrenceSeriesConflict {
1012 field: conflict.field,
1013 variant_a: conflict.variant_a,
1014 local_value: conflict.local_value,
1015 variant_b: conflict.variant_b,
1016 remote_value: conflict.remote_value,
1017 })
1018 .collect(),
1019 }
1020 }
1021}
1022
1023#[derive(Debug, Clone, PartialEq, Eq)]
1024pub struct RecurrenceHistoryRow {
1025 pub kind: RecurrenceHistoryKind,
1026 pub slot_on: Option<String>,
1027 pub interval_started_at: Option<String>,
1028 pub interval_ended_at: Option<String>,
1029 pub task_id: Option<TaskId>,
1030 pub task_ref: Option<String>,
1031 pub openable: bool,
1032 pub archived_projection: bool,
1033 pub resolved_at: Option<String>,
1034}
1035
1036impl From<InternalRecurrenceHistoryEntry> for RecurrenceHistoryRow {
1037 fn from(value: InternalRecurrenceHistoryEntry) -> Self {
1038 Self {
1039 kind: value.kind,
1040 slot_on: value.slot_on,
1041 interval_started_at: value.interval_started_at,
1042 interval_ended_at: value.interval_ended_at,
1043 task_id: value.task_id,
1044 task_ref: value.task_ref,
1045 openable: value.openable,
1046 archived_projection: value.archived_projection,
1047 resolved_at: value.resolved_at,
1048 }
1049 }
1050}
1051
1052#[derive(Debug, Clone, PartialEq, Eq)]
1053pub struct RecurrenceHistoryPage {
1054 pub series_ref: String,
1055 pub items: Vec<RecurrenceHistoryRow>,
1056 pub offset: usize,
1057 pub limit: usize,
1058 pub total: usize,
1059 pub has_more: bool,
1060}
1061
1062#[derive(Debug, Clone, PartialEq, Eq)]
1063pub struct TaskRecurrenceSummary {
1064 pub series_id: RecurrenceSeriesId,
1065 pub series_ref: String,
1066 pub slot_on: String,
1067 pub rule_label: String,
1068 pub timezone: String,
1069 pub lifecycle: RecurrenceSeriesState,
1070 pub outcome: Option<RecurrenceOutcome>,
1071 pub projection_state: RecurrenceProjectionState,
1072}
1073
1074#[derive(Debug, Clone, PartialEq, Eq)]
1075pub struct RecurrenceTaskGroup {
1076 pub series_id: RecurrenceSeriesId,
1077 pub series_ref: String,
1078 pub counts: RecurrenceCounts,
1079}
1080
1081#[derive(Debug, Clone, PartialEq, Eq)]
1082pub struct TaskSummary {
1083 pub task: TaskRecord,
1084 pub display_ref: String,
1085 pub recurrence: Option<TaskRecurrenceSummary>,
1086 pub recurrence_group: Option<RecurrenceTaskGroup>,
1087}
1088
1089impl From<TaskListItem> for TaskSummary {
1090 fn from(value: TaskListItem) -> Self {
1091 Self {
1092 task: value.task.into(),
1093 display_ref: value.display_ref,
1094 recurrence: value.recurrence.map(|summary| TaskRecurrenceSummary {
1095 series_id: summary.series_id,
1096 series_ref: summary.series_ref,
1097 slot_on: summary.slot_on,
1098 rule_label: summary.rule_label,
1099 timezone: summary.timezone,
1100 lifecycle: summary.lifecycle,
1101 outcome: summary.outcome,
1102 projection_state: summary.projection_state,
1103 }),
1104 recurrence_group: value.recurrence_group.map(|group| RecurrenceTaskGroup {
1105 series_id: group.series_id,
1106 series_ref: group.series_ref,
1107 counts: group.counts.into(),
1108 }),
1109 }
1110 }
1111}
1112
1113#[derive(Debug, Clone, PartialEq, Eq)]
1114pub struct TaskRecord {
1115 pub id: TaskId,
1116 pub workspace_id: WorkspaceId,
1117 pub title: String,
1118 pub description: String,
1119 pub project_id: ProjectId,
1120 pub project_key: String,
1121 pub project_prefix: String,
1122 pub status: TaskStatus,
1123 pub priority: TaskPriority,
1124 pub created_at: String,
1125 pub updated_at: String,
1126 pub available_at: Option<String>,
1127 pub due_on: Option<String>,
1128}
1129
1130impl From<Task> for TaskRecord {
1131 fn from(task: Task) -> Self {
1132 Self {
1133 id: task.id,
1134 workspace_id: task.workspace_id,
1135 title: task.title,
1136 description: task.description,
1137 project_id: task.project_id,
1138 project_key: task.project_key,
1139 project_prefix: task.project_prefix,
1140 status: task.status,
1141 priority: task.priority,
1142 created_at: task.created_at,
1143 updated_at: task.updated_at,
1144 available_at: task.available_at,
1145 due_on: task.due_on,
1146 }
1147 }
1148}
1149
1150#[derive(Debug, Clone, PartialEq, Eq)]
1151pub struct TaskUpdateResult {
1152 pub task: TaskRecord,
1153 pub changed: bool,
1154}
1155
1156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1157pub enum ConflictField {
1158 Title,
1159 Description,
1160 Project,
1161 Status,
1162 Priority,
1163 AvailableAt,
1164 DueOn,
1165 Deleted,
1166 IsEpic,
1167}
1168
1169impl ConflictField {
1170 pub const fn as_str(self) -> &'static str {
1171 match self {
1172 Self::Title => "title",
1173 Self::Description => "description",
1174 Self::Project => "project",
1175 Self::Status => "status",
1176 Self::Priority => "priority",
1177 Self::AvailableAt => "available_at",
1178 Self::DueOn => "due_on",
1179 Self::Deleted => "deleted",
1180 Self::IsEpic => "is_epic",
1181 }
1182 }
1183
1184 fn from_task_field(field: TaskField) -> Self {
1185 match field {
1186 TaskField::Title => Self::Title,
1187 TaskField::Description => Self::Description,
1188 TaskField::Project => Self::Project,
1189 TaskField::Status => Self::Status,
1190 TaskField::Priority => Self::Priority,
1191 TaskField::AvailableAt => Self::AvailableAt,
1192 TaskField::DueOn => Self::DueOn,
1193 TaskField::Deleted => Self::Deleted,
1194 TaskField::IsEpic => Self::IsEpic,
1195 }
1196 }
1197}
1198
1199#[derive(Debug, Clone, PartialEq, Eq)]
1200pub struct ConflictSummary {
1201 pub task_id: TaskId,
1202 pub task_title: String,
1203 pub project_key: String,
1204 pub project_prefix: String,
1205 pub field: ConflictField,
1206}
1207
1208#[derive(Debug, Clone, PartialEq, Eq)]
1209pub struct Conflict {
1210 pub task_id: TaskId,
1211 pub field: ConflictField,
1212 pub local_value: String,
1213 pub remote_value: String,
1214}
1215
1216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1217pub enum ConflictChoice {
1218 Local,
1219 Remote,
1220}
1221
1222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1223pub enum ErrorCode {
1224 Validation,
1225 NotFound,
1226 OpenConflict,
1227 GenerationConflict,
1228 Database,
1229 Internal,
1230}
1231
1232impl ErrorCode {
1233 pub const fn as_str(self) -> &'static str {
1234 match self {
1235 Self::Validation => "validation",
1236 Self::NotFound => "not_found",
1237 Self::OpenConflict => "open_conflict",
1238 Self::GenerationConflict => "generation_conflict",
1239 Self::Database => "database",
1240 Self::Internal => "internal",
1241 }
1242 }
1243}
1244
1245#[derive(Debug, Clone, PartialEq, Eq)]
1246pub struct Error {
1247 pub code: ErrorCode,
1248 pub message: String,
1249}
1250
1251impl Error {
1252 fn new(code: ErrorCode, message: String) -> Self {
1253 Self { code, message }
1254 }
1255
1256 fn database_open(error: InternalError) -> Self {
1257 Self::new(ErrorCode::Database, error.to_string())
1258 }
1259
1260 fn from_internal(error: InternalError) -> Self {
1261 let code = error
1262 .chain()
1263 .find_map(|cause| cause.downcast_ref::<crate::error::CoreError>())
1264 .map(|error| match error.kind() {
1265 crate::error::ErrorKind::Validation => ErrorCode::Validation,
1266 crate::error::ErrorKind::NotFound => ErrorCode::NotFound,
1267 crate::error::ErrorKind::OpenConflict => ErrorCode::OpenConflict,
1268 crate::error::ErrorKind::GenerationConflict => ErrorCode::GenerationConflict,
1269 })
1270 .unwrap_or_else(|| {
1271 if error
1272 .chain()
1273 .filter_map(|cause| cause.downcast_ref::<sqlx::Error>())
1274 .any(|error| matches!(error, sqlx::Error::RowNotFound))
1275 {
1276 ErrorCode::NotFound
1277 } else if error
1278 .chain()
1279 .any(|cause| cause.downcast_ref::<sqlx::Error>().is_some())
1280 {
1281 ErrorCode::Database
1282 } else {
1283 ErrorCode::Internal
1284 }
1285 });
1286 Self::new(code, error.to_string())
1287 }
1288}
1289
1290impl fmt::Display for Error {
1291 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1292 write!(formatter, "{}: {}", self.code.as_str(), self.message)
1293 }
1294}
1295
1296impl std::error::Error for Error {}
1297
1298#[cfg(test)]
1299mod tests {
1300 use super::*;
1301
1302 #[test]
1303 fn error_codes_are_stable_and_internal_errors_are_distinct() {
1304 assert_eq!(ErrorCode::Validation.as_str(), "validation");
1305 assert_eq!(ErrorCode::NotFound.as_str(), "not_found");
1306 assert_eq!(ErrorCode::OpenConflict.as_str(), "open_conflict");
1307 assert_eq!(
1308 ErrorCode::GenerationConflict.as_str(),
1309 "generation_conflict"
1310 );
1311 assert_eq!(ErrorCode::Database.as_str(), "database");
1312 assert_eq!(ErrorCode::Internal.as_str(), "internal");
1313 assert_eq!(
1314 Error::from_internal(
1315 crate::error::CoreError::generation_conflict(
1316 "error recurrence-generation-conflict slot=2026-08-01 field=task"
1317 )
1318 .into()
1319 )
1320 .code,
1321 ErrorCode::GenerationConflict
1322 );
1323 assert_eq!(
1324 Error::from_internal(
1325 crate::error::CoreError::not_found("error recurrence-series-not-found").into()
1326 )
1327 .code,
1328 ErrorCode::NotFound
1329 );
1330 assert_eq!(
1331 Error::from_internal(anyhow::anyhow!("unexpected invariant")).code,
1332 ErrorCode::Internal
1333 );
1334 assert_eq!(
1335 Error::from_internal(sqlx::Error::PoolClosed.into()).code,
1336 ErrorCode::Database
1337 );
1338 }
1339}