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, 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}