1use super::*;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct IosEpicProgress {
5 pub total: u32,
6 pub open: u32,
7 pub done: u32,
8 pub canceled: u32,
9}
10
11impl From<crate::query::EpicRollup> for IosEpicProgress {
12 fn from(value: crate::query::EpicRollup) -> Self {
13 Self {
14 total: value.total.min(u32::MAX as usize) as u32,
15 open: value.open.min(u32::MAX as usize) as u32,
16 done: value.done.min(u32::MAX as usize) as u32,
17 canceled: value.canceled.min(u32::MAX as usize) as u32,
18 }
19 }
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct IosEpicSummary {
24 pub task: IosTaskListRow,
25 pub progress: IosEpicProgress,
26}
27
28impl Store {
29 pub async fn ios_epics(
30 &self,
31 workspace_id: &WorkspaceId,
32 ) -> Result<Vec<IosEpicSummary>, Error> {
33 self.workspace(workspace_id).await?;
34 let items = self
35 .database
36 .list_task_summary_items(
37 workspace_id,
38 TaskFilters {
39 epics_only: true,
40 ..TaskFilters::default()
41 },
42 TaskQueryMode::Flat,
43 TaskSort::Updated,
44 SortDirection::Desc,
45 None,
46 )
47 .await
48 .map_err(Error::from_internal)?;
49 Ok(items
50 .into_iter()
51 .map(|mut item| IosEpicSummary {
52 progress: item.epic_rollup.take().unwrap_or_default().into(),
53 task: item.into(),
54 })
55 .collect())
56 }
57
58 pub async fn ios_epic_candidates(
59 &self,
60 workspace_id: &WorkspaceId,
61 epic_id: &TaskId,
62 ) -> Result<Vec<IosTaskListRow>, Error> {
63 let workspace = self.workspace(workspace_id).await?;
64 let epic = self
65 .database
66 .resolve_task_ref(&workspace, epic_id.as_str())
67 .await
68 .map_err(Error::from_internal)?;
69 if epic.deleted || !epic.is_epic {
70 return Err(Error::new(
71 ErrorCode::Validation,
72 "epic is unavailable".into(),
73 ));
74 }
75 self.database
76 .list_task_summary_items(
77 workspace_id,
78 TaskFilters {
79 project: Some(epic.project_key),
80 exclude_epics: true,
81 without_epic_link: true,
82 ..TaskFilters::default()
83 },
84 TaskQueryMode::Flat,
85 TaskSort::Updated,
86 SortDirection::Desc,
87 None,
88 )
89 .await
90 .map(|items| items.into_iter().map(Into::into).collect())
91 .map_err(Error::from_internal)
92 }
93
94 pub async fn create_ios_epic(
95 &self,
96 workspace_id: &WorkspaceId,
97 input: IosTaskCapture,
98 ) -> Result<TaskId, Error> {
99 self.create_ios_epic_task(workspace_id, None, input).await
100 }
101
102 pub async fn create_ios_epic_child(
103 &self,
104 workspace_id: &WorkspaceId,
105 epic_id: &TaskId,
106 input: IosTaskCapture,
107 ) -> Result<TaskId, Error> {
108 self.create_ios_epic_task(workspace_id, Some(epic_id.clone()), input)
109 .await
110 }
111
112 async fn create_ios_epic_task(
113 &self,
114 workspace_id: &WorkspaceId,
115 epic_id: Option<TaskId>,
116 input: IosTaskCapture,
117 ) -> Result<TaskId, Error> {
118 validate_optional_date("due_on", input.due_on.as_deref())?;
119 let workspace = self.workspace(workspace_id).await?;
120 let project = input
121 .project
122 .ok_or_else(|| Error::new(ErrorCode::Validation, "project is required".into()))?;
123 let outcome = self
124 .database
125 .create_task_with_options(
126 &workspace,
127 TaskDraft {
128 title: input.title.trim().to_string(),
129 description: input.description,
130 project: Some(project),
131 status: TaskStatus::Inbox.as_str().into(),
132 priority: input.priority.as_str().into(),
133 source: TaskSource::Api,
134 labels: input.labels,
135 metadata: Vec::new(),
136 available_at: None,
137 due_on: input.due_on,
138 is_epic: epic_id.is_none(),
139 },
140 TaskCreationOptions::for_ios_epic(epic_id),
141 )
142 .await
143 .map_err(Error::from_internal)?;
144 Ok(outcome.task.id)
145 }
146
147 pub async fn set_ios_epic_child(
148 &self,
149 workspace_id: &WorkspaceId,
150 epic_id: &TaskId,
151 child_id: &TaskId,
152 linked: bool,
153 ) -> Result<bool, Error> {
154 let workspace = self.workspace(workspace_id).await?;
155 self.database
156 .set_ios_epic_child(&workspace, epic_id, child_id, linked)
157 .await
158 .map_err(Error::from_internal)
159 }
160}