1use anyhow::Result;
2use axum::extract::{Path as AxumPath, State};
3use axum::http::HeaderMap;
4use axum::response::Response;
5use axum::Form;
6
7use crate::db::{self, Store, TaskId};
8use crate::ops;
9
10use super::helpers::{mutation_error, mutation_response};
11use super::AppState;
12
13#[derive(serde::Deserialize)]
14pub(super) struct ProjectForm {
15 name: String,
16 #[serde(default)]
17 bind_path: String,
18}
19
20#[derive(serde::Deserialize)]
21pub(super) struct CreateForm {
22 title: String,
23 #[serde(default)]
24 description: String,
25 #[serde(default = "default_task_type")]
26 task_type: String,
27 #[serde(default = "default_priority")]
28 priority: String,
29 #[serde(default = "default_effort")]
30 effort: String,
31 #[serde(default)]
32 labels: String,
33 #[serde(default)]
34 parent: String,
35}
36
37fn default_task_type() -> String {
38 "task".to_string()
39}
40
41fn default_priority() -> String {
42 "medium".to_string()
43}
44
45fn default_effort() -> String {
46 "medium".to_string()
47}
48
49#[derive(serde::Deserialize)]
50pub(super) struct UpdateForm {
51 #[serde(default)]
52 title: Option<String>,
53 #[serde(default)]
54 description: Option<String>,
55 #[serde(default)]
56 status: Option<String>,
57 #[serde(default)]
58 priority: Option<String>,
59 #[serde(default)]
60 effort: Option<String>,
61 #[serde(default)]
62 redirect: Option<String>,
63}
64
65#[derive(serde::Deserialize)]
66pub(super) struct LogForm {
67 message: String,
68}
69
70#[derive(serde::Deserialize)]
71pub(super) struct LabelForm {
72 /// "add" or "rm"
73 action: String,
74 label: String,
75}
76
77#[derive(serde::Deserialize)]
78pub(super) struct DepForm {
79 /// "add" or "rm"
80 action: String,
81 blocker: String,
82}
83
84pub(super) async fn create_project_handler(
85 State(state): State<AppState>,
86 headers: HeaderMap,
87 Form(form): Form<ProjectForm>,
88) -> Response {
89 let root = state.data_root.clone();
90 let result = tokio::task::spawn_blocking(move || -> Result<String> {
91 let bind = if form.bind_path.is_empty() {
92 None
93 } else {
94 Some(std::path::PathBuf::from(&form.bind_path))
95 };
96 ops::init_project(&root, &form.name, bind.as_deref())?;
97 Ok(form.name)
98 })
99 .await;
100
101 match result {
102 Ok(Ok(name)) => {
103 let redirect = format!("/projects/{name}");
104 mutation_response(&headers, &redirect, serde_json::json!({"name": name}))
105 }
106 Ok(Err(e)) => mutation_error(&headers, 400, &e),
107 Err(e) => mutation_error(&headers, 500, &e.into()),
108 }
109}
110
111pub(super) async fn create_handler(
112 State(state): State<AppState>,
113 AxumPath(name): AxumPath<String>,
114 headers: HeaderMap,
115 Form(form): Form<CreateForm>,
116) -> Response {
117 let root = state.data_root.clone();
118 let result = tokio::task::spawn_blocking(move || -> Result<(db::Task, String)> {
119 let store = Store::open(&root, &name)?;
120
121 let parent = if form.parent.is_empty() {
122 None
123 } else {
124 Some(db::resolve_task_id(&store, &form.parent, false)?)
125 };
126
127 let labels: Vec<String> = form
128 .labels
129 .split(',')
130 .map(str::trim)
131 .filter(|l| !l.is_empty())
132 .map(String::from)
133 .collect();
134
135 let task = ops::create_task(
136 &store,
137 ops::CreateOpts {
138 title: form.title,
139 description: form.description,
140 task_type: form.task_type,
141 priority: db::parse_priority(&form.priority)?,
142 effort: db::parse_effort(&form.effort)?,
143 parent,
144 labels,
145 },
146 )?;
147
148 let redirect = format!("/projects/{}/tasks/{}", name, task.id.as_str());
149 Ok((task, redirect))
150 })
151 .await;
152
153 match result {
154 Ok(Ok((task, redirect))) => mutation_response(
155 &headers,
156 &redirect,
157 serde_json::to_value(&task).unwrap_or_default(),
158 ),
159 Ok(Err(e)) => mutation_error(&headers, 400, &e),
160 Err(e) => mutation_error(&headers, 500, &e.into()),
161 }
162}
163
164pub(super) async fn update_handler(
165 State(state): State<AppState>,
166 AxumPath((name, id)): AxumPath<(String, String)>,
167 headers: HeaderMap,
168 Form(form): Form<UpdateForm>,
169) -> Response {
170 let root = state.data_root.clone();
171 let result = tokio::task::spawn_blocking(move || -> Result<(db::Task, String)> {
172 let store = Store::open(&root, &name)?;
173 let task_id = db::resolve_task_id(&store, &id, false)?;
174
175 let task = ops::update_task(
176 &store,
177 &task_id,
178 ops::UpdateOpts {
179 status: form
180 .status
181 .as_deref()
182 .filter(|s| !s.is_empty())
183 .map(db::parse_status)
184 .transpose()?,
185 priority: form
186 .priority
187 .as_deref()
188 .filter(|s| !s.is_empty())
189 .map(db::parse_priority)
190 .transpose()?,
191 effort: form
192 .effort
193 .as_deref()
194 .filter(|s| !s.is_empty())
195 .map(db::parse_effort)
196 .transpose()?,
197 title: form.title.filter(|s| !s.is_empty()),
198 description: form.description,
199 },
200 )?;
201
202 let redirect = form
203 .redirect
204 .filter(|r| r.starts_with('/'))
205 .unwrap_or_else(|| format!("/projects/{}/tasks/{}", name, task.id.as_str()));
206 Ok((task, redirect))
207 })
208 .await;
209
210 match result {
211 Ok(Ok((task, redirect))) => mutation_response(
212 &headers,
213 &redirect,
214 serde_json::to_value(&task).unwrap_or_default(),
215 ),
216 Ok(Err(e)) => mutation_error(&headers, 400, &e),
217 Err(e) => mutation_error(&headers, 500, &e.into()),
218 }
219}
220
221pub(super) async fn log_handler(
222 State(state): State<AppState>,
223 AxumPath((name, id)): AxumPath<(String, String)>,
224 headers: HeaderMap,
225 Form(form): Form<LogForm>,
226) -> Response {
227 let root = state.data_root.clone();
228 let result = tokio::task::spawn_blocking(move || -> Result<(db::LogEntry, String)> {
229 let store = Store::open(&root, &name)?;
230 let task_id = db::resolve_task_id(&store, &id, false)?;
231 let entry = ops::add_log(&store, &task_id, &form.message)?;
232 let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str());
233 Ok((entry, redirect))
234 })
235 .await;
236
237 match result {
238 Ok(Ok((entry, redirect))) => mutation_response(
239 &headers,
240 &redirect,
241 serde_json::to_value(&entry).unwrap_or_default(),
242 ),
243 Ok(Err(e)) => mutation_error(&headers, 400, &e),
244 Err(e) => mutation_error(&headers, 500, &e.into()),
245 }
246}
247
248pub(super) async fn done_handler(
249 State(state): State<AppState>,
250 AxumPath((name, id)): AxumPath<(String, String)>,
251 headers: HeaderMap,
252) -> Response {
253 let root = state.data_root.clone();
254 let result = tokio::task::spawn_blocking(move || -> Result<(TaskId, String)> {
255 let store = Store::open(&root, &name)?;
256 let task_id = db::resolve_task_id(&store, &id, false)?;
257 ops::mark_done(&store, &task_id)?;
258 let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str());
259 Ok((task_id, redirect))
260 })
261 .await;
262
263 match result {
264 Ok(Ok((task_id, redirect))) => mutation_response(
265 &headers,
266 &redirect,
267 serde_json::json!({"id": task_id, "status": "closed"}),
268 ),
269 Ok(Err(e)) => mutation_error(&headers, 400, &e),
270 Err(e) => mutation_error(&headers, 500, &e.into()),
271 }
272}
273
274pub(super) async fn reopen_handler(
275 State(state): State<AppState>,
276 AxumPath((name, id)): AxumPath<(String, String)>,
277 headers: HeaderMap,
278) -> Response {
279 let root = state.data_root.clone();
280 let result = tokio::task::spawn_blocking(move || -> Result<(TaskId, String)> {
281 let store = Store::open(&root, &name)?;
282 let task_id = db::resolve_task_id(&store, &id, false)?;
283 ops::reopen_task(&store, &task_id)?;
284 let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str());
285 Ok((task_id, redirect))
286 })
287 .await;
288
289 match result {
290 Ok(Ok((task_id, redirect))) => mutation_response(
291 &headers,
292 &redirect,
293 serde_json::json!({"id": task_id, "status": "open"}),
294 ),
295 Ok(Err(e)) => mutation_error(&headers, 400, &e),
296 Err(e) => mutation_error(&headers, 500, &e.into()),
297 }
298}
299
300pub(super) async fn label_handler(
301 State(state): State<AppState>,
302 AxumPath((name, id)): AxumPath<(String, String)>,
303 headers: HeaderMap,
304 Form(form): Form<LabelForm>,
305) -> Response {
306 let root = state.data_root.clone();
307 let result = tokio::task::spawn_blocking(move || -> Result<String> {
308 let store = Store::open(&root, &name)?;
309 let task_id = db::resolve_task_id(&store, &id, false)?;
310 match form.action.as_str() {
311 "add" => ops::add_label(&store, &task_id, &form.label)?,
312 "rm" => ops::remove_label(&store, &task_id, &form.label)?,
313 other => anyhow::bail!("unknown label action '{other}'; expected 'add' or 'rm'"),
314 }
315 Ok(format!("/projects/{}/tasks/{}", name, task_id.as_str()))
316 })
317 .await;
318
319 match result {
320 Ok(Ok(redirect)) => mutation_response(&headers, &redirect, serde_json::json!({"ok": true})),
321 Ok(Err(e)) => mutation_error(&headers, 400, &e),
322 Err(e) => mutation_error(&headers, 500, &e.into()),
323 }
324}
325
326pub(super) async fn dep_handler(
327 State(state): State<AppState>,
328 AxumPath((name, id)): AxumPath<(String, String)>,
329 headers: HeaderMap,
330 Form(form): Form<DepForm>,
331) -> Response {
332 let root = state.data_root.clone();
333 let result = tokio::task::spawn_blocking(move || -> Result<String> {
334 let store = Store::open(&root, &name)?;
335 let child_id = db::resolve_task_id(&store, &id, false)?;
336 let blocker_id = db::resolve_task_id(&store, &form.blocker, form.action == "rm")?;
337 match form.action.as_str() {
338 "add" => ops::add_dep(&store, &child_id, &blocker_id)?,
339 "rm" => ops::remove_dep(&store, &child_id, &blocker_id)?,
340 other => anyhow::bail!("unknown dep action '{other}'; expected 'add' or 'rm'"),
341 }
342 Ok(format!("/projects/{}/tasks/{}", name, child_id.as_str()))
343 })
344 .await;
345
346 match result {
347 Ok(Ok(redirect)) => mutation_response(&headers, &redirect, serde_json::json!({"ok": true})),
348 Ok(Err(e)) => mutation_error(&headers, 400, &e),
349 Err(e) => mutation_error(&headers, 500, &e.into()),
350 }
351}
352
353pub(super) async fn delete_handler(
354 State(state): State<AppState>,
355 AxumPath((name, id)): AxumPath<(String, String)>,
356 headers: HeaderMap,
357) -> Response {
358 let root = state.data_root.clone();
359 let result = tokio::task::spawn_blocking(move || -> Result<(ops::DeleteResult, String)> {
360 let store = Store::open(&root, &name)?;
361 let task_id = db::resolve_task_id(&store, &id, false)?;
362 let dr = ops::soft_delete(&store, &[task_id], false)?;
363 let redirect = format!("/projects/{}", name);
364 Ok((dr, redirect))
365 })
366 .await;
367
368 match result {
369 Ok(Ok((dr, redirect))) => {
370 let json_body = serde_json::json!({
371 "deleted_ids": dr.deleted_ids.iter().map(ToString::to_string).collect::<Vec<_>>(),
372 "unblocked_ids": dr.unblocked_ids.iter().map(ToString::to_string).collect::<Vec<_>>(),
373 });
374 mutation_response(&headers, &redirect, json_body)
375 }
376 Ok(Err(e)) => mutation_error(&headers, 400, &e),
377 Err(e) => mutation_error(&headers, 500, &e.into()),
378 }
379}