Skip to main content

aven_core/
api.rs

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}