Skip to main content

wipe_daemon/
api.rs

1//! HTTP + WebSocket API handlers over `wipe-core`.
2
3use std::path::PathBuf;
4
5use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
6use axum::extract::{Multipart, Path, Query, State};
7use axum::http::{header, StatusCode};
8use axum::response::{IntoResponse, Response};
9use axum::Json;
10use chrono::Utc;
11use serde::Deserialize;
12use serde_json::{json, Value};
13use tokio::sync::broadcast;
14
15use wipe_core::model::{Board, IdentityKind, Ticket};
16use wipe_core::ops::{self, NewTicket, TicketPatch};
17use wipe_core::{git, Store};
18
19/// Shared server state.
20#[derive(Clone)]
21pub struct AppState {
22    /// The project the daemon was started in (default target).
23    pub current: PathBuf,
24    /// Broadcast channel for live-update notifications.
25    pub tx: broadcast::Sender<String>,
26}
27
28/// An error that renders as a JSON `{ok:false,error}` body.
29pub struct ApiError(anyhow::Error);
30
31impl<E: Into<anyhow::Error>> From<E> for ApiError {
32    fn from(e: E) -> Self {
33        ApiError(e.into())
34    }
35}
36
37impl IntoResponse for ApiError {
38    fn into_response(self) -> Response {
39        let body = Json(json!({ "ok": false, "error": self.0.to_string() }));
40        (StatusCode::BAD_REQUEST, body).into_response()
41    }
42}
43
44type ApiResult = Result<Json<Value>, ApiError>;
45
46/// Query string carrying an optional project root.
47#[derive(Debug, Deserialize)]
48pub struct ProjectQuery {
49    project: Option<String>,
50}
51
52fn store_for(state: &AppState, project: Option<String>) -> Result<Store, ApiError> {
53    let root = project
54        .map(PathBuf::from)
55        .unwrap_or_else(|| state.current.clone());
56    Ok(Store::open(root)?)
57}
58
59fn notify(state: &AppState) {
60    let _ = state.tx.send("changed".to_string());
61}
62
63/// Who to attribute a UI-driven mutation to for the activity timeline: an explicit
64/// `actor` from the request if given, else the repo's configured git identity, else
65/// a generic fallback.
66fn resolve_actor(store: &Store, provided: Option<String>) -> String {
67    provided
68        .filter(|s| !s.trim().is_empty())
69        .or_else(|| git::config_identity(store.root()))
70        .unwrap_or_else(|| "someone".to_string())
71}
72
73fn board_json(board: &Board, view: &[(String, Vec<Ticket>)]) -> Value {
74    let lists: Vec<Value> = view
75        .iter()
76        .map(|(list_id, tickets)| {
77            let name = board
78                .list(list_id)
79                .map(|l| l.name.clone())
80                .unwrap_or_else(|| list_id.clone());
81            json!({ "list": list_id, "name": name, "tickets": tickets })
82        })
83        .collect();
84    json!({ "board": board.name, "lists": lists })
85}
86
87// --- read endpoints --------------------------------------------------------
88
89/// `GET /api/health`
90pub async fn health() -> Json<Value> {
91    Json(json!({ "ok": true, "service": "wipe-daemon", "version": env!("CARGO_PKG_VERSION") }))
92}
93
94/// `GET /api/projects`
95pub async fn projects(State(state): State<AppState>) -> ApiResult {
96    crate::registry::register(&state.current);
97    Ok(Json(json!({ "projects": crate::registry::list() })))
98}
99
100/// `GET /api/board`
101pub async fn board(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
102    let store = store_for(&state, q.project)?;
103    let (board, view) = ops::board_view(&store)?;
104    Ok(Json(board_json(&board, &view)))
105}
106
107/// `GET /api/history` — commits touching `.wipe/`, most recent first.
108pub async fn history(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
109    let store = store_for(&state, q.project)?;
110    let commits = git::log(store.root(), Some(".wipe"), Some(200))?;
111    Ok(Json(json!({ "commits": commits })))
112}
113
114/// Query for a historical board snapshot.
115#[derive(Debug, Deserialize)]
116pub struct AtQuery {
117    project: Option<String>,
118    commit: String,
119}
120
121/// `GET /api/board/at` — reconstruct the board as of a commit (the rewind feature).
122pub async fn board_at(State(state): State<AppState>, Query(q): Query<AtQuery>) -> ApiResult {
123    let store = store_for(&state, q.project)?;
124    let root = store.root();
125    let board_src = git::file_at_commit(root, &q.commit, ".wipe/board.json")?
126        .ok_or_else(|| ApiError(anyhow::anyhow!("no board at commit {}", q.commit)))?;
127    let board: Board = serde_json::from_str(&board_src)?;
128
129    let mut lists = Vec::with_capacity(board.lists.len());
130    for list in &board.lists {
131        let mut tickets = Vec::with_capacity(list.cards.len());
132        for id in &list.cards {
133            let rel = format!(".wipe/tickets/{id}.json");
134            if let Some(src) = git::file_at_commit(root, &q.commit, &rel)? {
135                if let Ok(t) = serde_json::from_str::<Ticket>(&src) {
136                    tickets.push(t);
137                }
138            }
139        }
140        lists.push(json!({ "list": list.id, "name": list.name, "tickets": tickets }));
141    }
142    Ok(Json(
143        json!({ "board": board.name, "commit": q.commit, "lists": lists }),
144    ))
145}
146
147// --- write endpoints -------------------------------------------------------
148
149/// Body for creating a ticket.
150#[derive(Debug, Deserialize)]
151pub struct CreateTicketBody {
152    project: Option<String>,
153    title: String,
154    #[serde(default)]
155    body: Option<String>,
156    #[serde(default)]
157    priority: Option<String>,
158    #[serde(default)]
159    list: Option<String>,
160    #[serde(default)]
161    labels: Vec<String>,
162    #[serde(default)]
163    assignees: Vec<String>,
164    #[serde(default)]
165    actor: Option<String>,
166}
167
168/// `POST /api/tickets`
169pub async fn create_ticket(
170    State(state): State<AppState>,
171    Json(b): Json<CreateTicketBody>,
172) -> ApiResult {
173    let store = store_for(&state, b.project)?;
174    let actor = resolve_actor(&store, b.actor);
175    let spec = NewTicket {
176        title: b.title,
177        body: b.body,
178        priority: b.priority,
179        list: b.list,
180        labels: b.labels,
181        assignees: b.assignees,
182    };
183    let ticket = ops::create_ticket(&store, spec, &actor, Utc::now())?;
184    notify(&state);
185    Ok(Json(serde_json::to_value(ticket)?))
186}
187
188/// Body for moving a ticket.
189#[derive(Debug, Deserialize)]
190pub struct MoveBody {
191    project: Option<String>,
192    to: String,
193    #[serde(default)]
194    pos: Option<usize>,
195    #[serde(default)]
196    actor: Option<String>,
197}
198
199/// `POST /api/tickets/{id}/move`
200pub async fn move_ticket(
201    State(state): State<AppState>,
202    Path(id): Path<String>,
203    Json(b): Json<MoveBody>,
204) -> ApiResult {
205    let store = store_for(&state, b.project)?;
206    let actor = resolve_actor(&store, b.actor);
207    ops::move_ticket(&store, &id, &b.to, b.pos, &actor, Utc::now())?;
208    notify(&state);
209    Ok(Json(json!({ "ok": true, "id": id, "list": b.to })))
210}
211
212/// Body for adding a comment.
213#[derive(Debug, Deserialize)]
214pub struct CommentBody {
215    project: Option<String>,
216    #[serde(default)]
217    author: Option<String>,
218    body: String,
219}
220
221/// `POST /api/tickets/{id}/comments`
222pub async fn add_comment(
223    State(state): State<AppState>,
224    Path(id): Path<String>,
225    Json(b): Json<CommentBody>,
226) -> ApiResult {
227    let store = store_for(&state, b.project)?;
228    let author = b.author.unwrap_or_else(|| "ui".to_string());
229    let cid = ops::add_comment(&store, &id, &author, &b.body, Utc::now())?;
230    notify(&state);
231    Ok(Json(json!({ "ok": true, "ticket": id, "comment": cid })))
232}
233
234/// `GET /api/definitions` — labels + priorities.
235pub async fn definitions(
236    State(state): State<AppState>,
237    Query(q): Query<ProjectQuery>,
238) -> ApiResult {
239    let store = store_for(&state, q.project)?;
240    Ok(Json(serde_json::to_value(store.load_definitions()?)?))
241}
242
243/// Body for creating a label definition. `color` is optional (auto-assigned).
244#[derive(Debug, Deserialize)]
245pub struct LabelBody {
246    project: Option<String>,
247    name: String,
248    #[serde(default)]
249    color: Option<String>,
250    #[serde(default)]
251    description: Option<String>,
252}
253
254/// `POST /api/labels` — define a new label (auto-colored if no color given).
255pub async fn create_label(State(state): State<AppState>, Json(b): Json<LabelBody>) -> ApiResult {
256    let store = store_for(&state, b.project)?;
257    let label = ops::create_label(&store, &b.name, b.color, b.description)?;
258    notify(&state);
259    Ok(Json(serde_json::to_value(label)?))
260}
261
262/// Body for updating a label's color.
263#[derive(Debug, Deserialize)]
264pub struct LabelColorBody {
265    project: Option<String>,
266    color: String,
267}
268
269/// `PATCH /api/labels/{name}` — change a label's color.
270pub async fn recolor_label(
271    State(state): State<AppState>,
272    Path(name): Path<String>,
273    Json(b): Json<LabelColorBody>,
274) -> ApiResult {
275    let store = store_for(&state, b.project)?;
276    let label = ops::set_label_color(&store, &name, &b.color)?;
277    notify(&state);
278    Ok(Json(serde_json::to_value(label)?))
279}
280
281/// `DELETE /api/labels/{name}` — delete a label and strip it from all tickets.
282pub async fn delete_label(
283    State(state): State<AppState>,
284    Path(name): Path<String>,
285    Query(q): Query<ProjectQuery>,
286) -> ApiResult {
287    let store = store_for(&state, q.project)?;
288    ops::delete_label(&store, &name, Utc::now())?;
289    notify(&state);
290    Ok(Json(json!({ "ok": true })))
291}
292
293/// Body for patching a ticket. Absent fields are left unchanged; an explicit
294/// `null` for `priority` clears it.
295#[derive(Debug, Deserialize)]
296pub struct PatchBody {
297    project: Option<String>,
298    #[serde(default)]
299    title: Option<String>,
300    #[serde(default)]
301    body: Option<String>,
302    #[serde(default)]
303    priority: Option<Option<String>>,
304    #[serde(default)]
305    labels: Option<Vec<String>>,
306    #[serde(default)]
307    assignees: Option<Vec<String>>,
308    #[serde(default)]
309    actor: Option<String>,
310}
311
312/// `PATCH /api/tickets/{id}` — update ticket fields.
313pub async fn patch_ticket(
314    State(state): State<AppState>,
315    Path(id): Path<String>,
316    Json(b): Json<PatchBody>,
317) -> ApiResult {
318    let store = store_for(&state, b.project)?;
319    let actor = resolve_actor(&store, b.actor);
320    let patch = TicketPatch {
321        title: b.title,
322        body: b.body,
323        priority: b.priority,
324        labels: b.labels,
325        assignees: b.assignees,
326    };
327    let ticket = ops::update_ticket(&store, &id, patch, &actor, Utc::now())?;
328    notify(&state);
329    Ok(Json(serde_json::to_value(ticket)?))
330}
331
332/// `GET /api/identities` — humans (from git) + agents (registry).
333pub async fn identities(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
334    let store = store_for(&state, q.project)?;
335    Ok(Json(json!({ "identities": ops::list_identities(&store)? })))
336}
337
338/// Body for creating/updating an identity.
339#[derive(Debug, Deserialize)]
340pub struct IdentityBody {
341    project: Option<String>,
342    display_name: String,
343    #[serde(default)]
344    kind: Option<String>,
345}
346
347/// `PUT /api/identities/{id}` — set an identity's display name / kind.
348pub async fn put_identity(
349    State(state): State<AppState>,
350    Path(id): Path<String>,
351    Json(b): Json<IdentityBody>,
352) -> ApiResult {
353    let store = store_for(&state, b.project)?;
354    let kind = match b.kind.as_deref() {
355        Some("agent") => Some(IdentityKind::Agent),
356        Some("human") => Some(IdentityKind::Human),
357        _ => None,
358    };
359    let ident = ops::upsert_identity(&store, &id, &b.display_name, kind)?;
360    notify(&state);
361    Ok(Json(serde_json::to_value(ident)?))
362}
363
364/// `DELETE /api/identities/{id}` — remove an identity from the registry (agents /
365/// manual overrides; git-discovered humans reappear from history).
366pub async fn delete_identity(
367    State(state): State<AppState>,
368    Path(id): Path<String>,
369    Query(q): Query<ProjectQuery>,
370) -> ApiResult {
371    let store = store_for(&state, q.project)?;
372    ops::delete_identity(&store, &id)?;
373    notify(&state);
374    Ok(Json(json!({ "ok": true, "id": id })))
375}
376
377/// `POST /api/tickets/{id}/attachments` — multipart file upload (field `file`).
378pub async fn upload_attachment(
379    State(state): State<AppState>,
380    Path(id): Path<String>,
381    Query(q): Query<ProjectQuery>,
382    mut multipart: Multipart,
383) -> ApiResult {
384    let store = store_for(&state, q.project)?;
385    let actor = resolve_actor(&store, None);
386    let max = store.load_settings()?.max_attachment_mb * 1024 * 1024;
387
388    while let Some(field) = multipart
389        .next_field()
390        .await
391        .map_err(|e| ApiError(anyhow::anyhow!("bad upload: {e}")))?
392    {
393        if field.name() != Some("file") {
394            continue;
395        }
396        let name = field.file_name().unwrap_or("file").to_string();
397        let mime = field
398            .content_type()
399            .map(|s| s.to_string())
400            .unwrap_or_else(|| "application/octet-stream".to_string());
401        let bytes = field
402            .bytes()
403            .await
404            .map_err(|e| ApiError(anyhow::anyhow!("read upload: {e}")))?;
405        if bytes.len() as u64 > max {
406            return Err(ApiError(anyhow::anyhow!(
407                "attachment is {:.1} MB, over the {} MB limit",
408                bytes.len() as f64 / 1_048_576.0,
409                max / 1024 / 1024
410            )));
411        }
412        let att = ops::add_attachment(&store, &id, &name, &bytes, &mime, &actor, Utc::now())?;
413        notify(&state);
414        return Ok(Json(serde_json::to_value(att)?));
415    }
416    Err(ApiError(anyhow::anyhow!("no `file` field in upload")))
417}
418
419/// Body for detaching an attachment.
420#[derive(Debug, Deserialize)]
421pub struct DetachBody {
422    project: Option<String>,
423    path: String,
424    #[serde(default)]
425    actor: Option<String>,
426}
427
428/// `DELETE /api/tickets/{id}/attachments` — detach by repo-relative path.
429pub async fn delete_attachment(
430    State(state): State<AppState>,
431    Path(id): Path<String>,
432    Json(b): Json<DetachBody>,
433) -> ApiResult {
434    let store = store_for(&state, b.project)?;
435    let actor = resolve_actor(&store, b.actor);
436    ops::remove_attachment(&store, &id, &b.path, &actor, Utc::now())?;
437    notify(&state);
438    Ok(Json(json!({ "ok": true })))
439}
440
441/// `GET /api/media/{*path}` — serve an attachment for preview/download.
442pub async fn serve_media(
443    State(state): State<AppState>,
444    Query(q): Query<ProjectQuery>,
445    Path(path): Path<String>,
446) -> Response {
447    let store = match store_for(&state, q.project) {
448        Ok(s) => s,
449        Err(e) => return e.into_response(),
450    };
451    let rel = path.replace('\\', "/");
452    if rel.contains("..") {
453        return (StatusCode::BAD_REQUEST, "invalid path").into_response();
454    }
455    let full = store.root().join(&rel);
456    // Confine reads to within the project root.
457    let within = std::fs::canonicalize(store.root())
458        .ok()
459        .zip(std::fs::canonicalize(&full).ok())
460        .map(|(root, target)| target.starts_with(root))
461        .unwrap_or(false);
462    if !within {
463        return (StatusCode::NOT_FOUND, "not found").into_response();
464    }
465    match std::fs::read(&full) {
466        Ok(bytes) => {
467            let mime = mime_guess::from_path(&full).first_or_octet_stream();
468            ([(header::CONTENT_TYPE, mime.as_ref())], bytes).into_response()
469        }
470        Err(_) => (StatusCode::NOT_FOUND, "not found").into_response(),
471    }
472}
473
474/// `GET /api/graph` — the commit graph (all branches) with board checkpoints.
475pub async fn graph(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
476    let store = store_for(&state, q.project)?;
477    let commits = git::graph(store.root(), Some(300))?;
478    Ok(Json(json!({ "commits": commits })))
479}
480
481/// Body for adding a list.
482#[derive(Debug, Deserialize)]
483pub struct AddListBody {
484    project: Option<String>,
485    name: String,
486}
487
488/// `POST /api/lists` — add a list to the board.
489pub async fn add_list(State(state): State<AppState>, Json(b): Json<AddListBody>) -> ApiResult {
490    let store = store_for(&state, b.project)?;
491    let id = ops::add_list(&store, &b.name, Utc::now())?;
492    notify(&state);
493    Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
494}
495
496/// Body for renaming a list.
497#[derive(Debug, Deserialize)]
498pub struct RenameListBody {
499    project: Option<String>,
500    name: String,
501}
502
503/// `PATCH /api/lists/{id}` — rename a list.
504pub async fn rename_list(
505    State(state): State<AppState>,
506    Path(id): Path<String>,
507    Json(b): Json<RenameListBody>,
508) -> ApiResult {
509    let store = store_for(&state, b.project)?;
510    ops::rename_list(&store, &id, &b.name, Utc::now())?;
511    notify(&state);
512    Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
513}
514
515/// Body for reordering a list.
516#[derive(Debug, Deserialize)]
517pub struct MoveListBody {
518    project: Option<String>,
519    index: usize,
520}
521
522/// `POST /api/lists/{id}/move` — reorder a list to a new index.
523pub async fn move_list(
524    State(state): State<AppState>,
525    Path(id): Path<String>,
526    Json(b): Json<MoveListBody>,
527) -> ApiResult {
528    let store = store_for(&state, b.project)?;
529    ops::move_list(&store, &id, b.index, Utc::now())?;
530    notify(&state);
531    Ok(Json(json!({ "ok": true, "id": id, "index": b.index })))
532}
533
534/// Query for removing a list.
535#[derive(Debug, Deserialize)]
536pub struct RemoveListQuery {
537    project: Option<String>,
538    #[serde(default)]
539    force: bool,
540}
541
542/// `DELETE /api/lists/{id}` — remove a list (use `?force=true` to delete its cards).
543pub async fn remove_list(
544    State(state): State<AppState>,
545    Path(id): Path<String>,
546    Query(q): Query<RemoveListQuery>,
547) -> ApiResult {
548    let store = store_for(&state, q.project)?;
549    ops::remove_list(&store, &id, q.force, Utc::now())?;
550    notify(&state);
551    Ok(Json(json!({ "ok": true, "id": id })))
552}
553
554// --- websocket -------------------------------------------------------------
555
556/// `GET /ws` — upgrade to a WebSocket that streams change notifications.
557pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
558    ws.on_upgrade(move |socket| ws_loop(socket, state))
559}
560
561async fn ws_loop(mut socket: WebSocket, state: AppState) {
562    let mut rx = state.tx.subscribe();
563    let _ = socket.send(Message::Text("connected".into())).await;
564    loop {
565        tokio::select! {
566            msg = rx.recv() => match msg {
567                Ok(m) => {
568                    if socket.send(Message::Text(m.into())).await.is_err() {
569                        break;
570                    }
571                }
572                Err(broadcast::error::RecvError::Closed) => break,
573                Err(broadcast::error::RecvError::Lagged(_)) => {}
574            },
575            incoming = socket.recv() => match incoming {
576                Some(Ok(_)) => {}
577                _ => break,
578            },
579        }
580    }
581}