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