mutations.rs

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