Skip to main content

wipe_daemon/
api.rs

1//! HTTP + WebSocket API handlers over `wipe-core`.
2
3use std::path::PathBuf;
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::sync::Arc;
6
7use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
8use axum::extract::{Multipart, Path, Query, State};
9use axum::http::{header, StatusCode};
10use axum::response::{IntoResponse, Response};
11use axum::Json;
12use chrono::Utc;
13use serde::Deserialize;
14use serde_json::{json, Value};
15use tokio::sync::broadcast;
16
17use wipe_core::forum::{self, NewReply, NewThread, SearchQuery};
18use wipe_core::model::{Board, IdentityKind, Ticket};
19use wipe_core::ops::{self, NewTicket, TicketPatch};
20use wipe_core::{git, Store};
21
22/// Shared server state.
23#[derive(Clone)]
24pub struct AppState {
25    /// The project the daemon was started in, if any. Used only as the default
26    /// target when a request omits `?project=`; every UI request names its project
27    /// explicitly, so this is just a convenience for CLI-less callers. `None` when
28    /// `wipe serve` runs outside a board (a purely global viewer).
29    pub current: Option<PathBuf>,
30    /// Broadcast channel for live-update notifications.
31    pub tx: broadcast::Sender<String>,
32    /// Number of live UI WebSocket clients. Drives idle-shutdown: when this is 0
33    /// for long enough, an auto-served daemon exits.
34    pub clients: Arc<AtomicUsize>,
35}
36
37/// An error that renders as a JSON `{ok:false,error}` body.
38pub struct ApiError(anyhow::Error);
39
40impl<E: Into<anyhow::Error>> From<E> for ApiError {
41    fn from(e: E) -> Self {
42        ApiError(e.into())
43    }
44}
45
46impl IntoResponse for ApiError {
47    fn into_response(self) -> Response {
48        let body = Json(json!({ "ok": false, "error": self.0.to_string() }));
49        (StatusCode::BAD_REQUEST, body).into_response()
50    }
51}
52
53type ApiResult = Result<Json<Value>, ApiError>;
54
55/// Query string carrying an optional project root.
56#[derive(Debug, Deserialize)]
57pub struct ProjectQuery {
58    project: Option<String>,
59}
60
61/// Resolve which board a request targets. Prefer the explicit `project` (every UI
62/// request sends one); fall back to the daemon's launch project. Erroring when
63/// neither is available keeps a stray request from silently hitting the wrong board.
64fn store_for(state: &AppState, project: Option<String>) -> Result<Store, ApiError> {
65    let root = project
66        .map(PathBuf::from)
67        .or_else(|| state.current.clone())
68        .ok_or_else(|| ApiError(anyhow::anyhow!("no project selected")))?;
69    Ok(Store::open(root)?)
70}
71
72fn notify(state: &AppState) {
73    let _ = state.tx.send("changed".to_string());
74}
75
76/// Who to attribute a UI-driven mutation to for the activity timeline: an explicit
77/// `actor` from the request if given, else the repo's VCS user (git, Plastic, …),
78/// the board default, or the configured global default - never "unknown".
79fn resolve_actor(store: &Store, provided: Option<String>) -> String {
80    ops::resolve_identity(Some(store), provided.as_deref())
81}
82
83fn board_json(board: &Board, view: &[(String, Vec<Ticket>)]) -> Value {
84    let lists: Vec<Value> = view
85        .iter()
86        .map(|(list_id, tickets)| {
87            let name = board
88                .list(list_id)
89                .map(|l| l.name.clone())
90                .unwrap_or_else(|| list_id.clone());
91            json!({ "list": list_id, "name": name, "tickets": tickets })
92        })
93        .collect();
94    json!({ "board": board.name, "lists": lists })
95}
96
97// --- read endpoints --------------------------------------------------------
98
99/// `GET /api/health`
100pub async fn health() -> Json<Value> {
101    Json(json!({ "ok": true, "service": "wipe-daemon", "version": env!("CARGO_PKG_VERSION") }))
102}
103
104/// `GET /api/config` - user-global preferences (styling + default identity) so the
105/// board UI can honor the choices made via `wipe config --global` / `wipe onboard`.
106pub async fn app_config() -> Json<Value> {
107    let g = wipe_core::GlobalConfig::load();
108    Json(json!({
109        "accent": g.ui_accent,
110        "theme": g.ui_theme,
111        "default_identity": g.default_identity,
112        "prefer_default_identity": g.prefer_default_identity.unwrap_or(false),
113    }))
114}
115
116/// Body for updating user-global preferences. Absent fields are left unchanged.
117#[derive(Debug, Deserialize)]
118pub struct ConfigPatch {
119    #[serde(default)]
120    accent: Option<String>,
121    #[serde(default)]
122    theme: Option<String>,
123    #[serde(default)]
124    default_identity: Option<String>,
125    #[serde(default)]
126    prefer_default_identity: Option<bool>,
127}
128
129/// `PATCH /api/config` - update user-global preferences from the UI.
130pub async fn patch_config(Json(b): Json<ConfigPatch>) -> ApiResult {
131    let mut g = wipe_core::GlobalConfig::load();
132    if let Some(a) = b.accent {
133        g.ui_accent = Some(a);
134    }
135    if let Some(t) = b.theme {
136        g.ui_theme = Some(t);
137    }
138    if let Some(id) = b.default_identity {
139        let id = id.trim().to_string();
140        if !id.is_empty() {
141            g.default_identity = Some(id);
142        }
143    }
144    if let Some(p) = b.prefer_default_identity {
145        g.prefer_default_identity = Some(p);
146    }
147    g.save()
148        .map_err(|e| ApiError(anyhow::anyhow!("saving config: {e}")))?;
149    Ok(Json(json!({
150        "ok": true,
151        "accent": g.ui_accent,
152        "theme": g.ui_theme,
153        "default_identity": g.default_identity,
154        "prefer_default_identity": g.prefer_default_identity.unwrap_or(false),
155    })))
156}
157
158/// `POST /api/scan` - discover boards on disk and refresh the registry, returning
159/// the updated project list (so the UI can offer a "rescan" action).
160pub async fn rescan() -> ApiResult {
161    // A blocking full-disk walk - keep it off the async worker threads.
162    let (found, projects) = tokio::task::spawn_blocking(|| {
163        crate::registry::prune();
164        let roots: Vec<std::path::PathBuf> = {
165            let g = wipe_core::GlobalConfig::load();
166            match g.scan_roots {
167                Some(r) if !r.is_empty() => r.into_iter().map(std::path::PathBuf::from).collect(),
168                _ => crate::registry::default_scan_roots(),
169            }
170        };
171        let found = crate::registry::scan(&roots, 7).len();
172        (found, crate::registry::list())
173    })
174    .await
175    .map_err(|e| ApiError(anyhow::anyhow!("scan task failed: {e}")))?;
176    Ok(Json(json!({ "found": found, "projects": projects })))
177}
178
179/// `GET /api/projects`
180///
181/// Reports every registered board plus `current` - the project the daemon was
182/// launched in - so the UI can default-open that board rather than an arbitrary
183/// one. `current` is null when `wipe serve` was started outside any board.
184pub async fn projects(State(state): State<AppState>) -> ApiResult {
185    let current = state
186        .current
187        .as_ref()
188        .filter(|root| Store::open(root).is_ok())
189        .map(|root| root.display().to_string());
190    if let Some(root) = &state.current {
191        crate::registry::register(root);
192    }
193    Ok(Json(
194        json!({ "projects": crate::registry::list(), "current": current }),
195    ))
196}
197
198/// `GET /api/board`
199pub async fn board(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
200    let store = store_for(&state, q.project)?;
201    let (board, view) = ops::board_view(&store)?;
202    Ok(Json(board_json(&board, &view)))
203}
204
205/// `GET /api/history` - commits touching `.wipe/`, most recent first.
206pub async fn history(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
207    let store = store_for(&state, q.project)?;
208    let commits = git::log(store.root(), Some(".wipe"), Some(200))?;
209    Ok(Json(json!({ "commits": commits })))
210}
211
212/// Query for a historical board snapshot.
213#[derive(Debug, Deserialize)]
214pub struct AtQuery {
215    project: Option<String>,
216    commit: String,
217}
218
219/// `GET /api/board/at` - reconstruct the board as of a commit (the rewind feature).
220pub async fn board_at(State(state): State<AppState>, Query(q): Query<AtQuery>) -> ApiResult {
221    let store = store_for(&state, q.project)?;
222    let root = store.root();
223    let board_src = git::file_at_commit(root, &q.commit, ".wipe/board.json")?
224        .ok_or_else(|| ApiError(anyhow::anyhow!("no board at commit {}", q.commit)))?;
225    let board: Board = serde_json::from_str(&board_src)?;
226
227    let mut lists = Vec::with_capacity(board.lists.len());
228    for list in &board.lists {
229        let mut tickets = Vec::with_capacity(list.cards.len());
230        for id in &list.cards {
231            let rel = format!(".wipe/tickets/{id}.json");
232            if let Some(src) = git::file_at_commit(root, &q.commit, &rel)? {
233                if let Ok(t) = serde_json::from_str::<Ticket>(&src) {
234                    tickets.push(t);
235                }
236            }
237        }
238        lists.push(json!({ "list": list.id, "name": list.name, "tickets": tickets }));
239    }
240    Ok(Json(
241        json!({ "board": board.name, "commit": q.commit, "lists": lists }),
242    ))
243}
244
245// --- write endpoints -------------------------------------------------------
246
247/// Body for creating a ticket.
248#[derive(Debug, Deserialize)]
249pub struct CreateTicketBody {
250    project: Option<String>,
251    title: String,
252    #[serde(default)]
253    body: Option<String>,
254    #[serde(default)]
255    priority: Option<String>,
256    #[serde(default)]
257    list: Option<String>,
258    #[serde(default)]
259    labels: Vec<String>,
260    #[serde(default)]
261    assignees: Vec<String>,
262    #[serde(default)]
263    actor: Option<String>,
264}
265
266/// `POST /api/tickets`
267pub async fn create_ticket(
268    State(state): State<AppState>,
269    Query(pq): Query<ProjectQuery>,
270    Json(b): Json<CreateTicketBody>,
271) -> ApiResult {
272    let store = store_for(&state, pq.project.or(b.project))?;
273    let actor = resolve_actor(&store, b.actor);
274    let spec = NewTicket {
275        title: b.title,
276        body: b.body,
277        priority: b.priority,
278        list: b.list,
279        labels: b.labels,
280        assignees: b.assignees,
281    };
282    let ticket = ops::create_ticket(&store, spec, &actor, Utc::now())?;
283    notify(&state);
284    Ok(Json(serde_json::to_value(ticket)?))
285}
286
287/// Body for moving a ticket.
288#[derive(Debug, Deserialize)]
289pub struct MoveBody {
290    project: Option<String>,
291    to: String,
292    #[serde(default)]
293    pos: Option<usize>,
294    #[serde(default)]
295    actor: Option<String>,
296}
297
298/// `POST /api/tickets/{id}/move`
299pub async fn move_ticket(
300    State(state): State<AppState>,
301    Path(id): Path<String>,
302    Query(pq): Query<ProjectQuery>,
303    Json(b): Json<MoveBody>,
304) -> ApiResult {
305    let store = store_for(&state, pq.project.or(b.project))?;
306    let actor = resolve_actor(&store, b.actor);
307    ops::move_ticket(&store, &id, &b.to, b.pos, &actor, Utc::now())?;
308    notify(&state);
309    Ok(Json(json!({ "ok": true, "id": id, "list": b.to })))
310}
311
312/// Body for adding a comment.
313#[derive(Debug, Deserialize)]
314pub struct CommentBody {
315    project: Option<String>,
316    #[serde(default)]
317    author: Option<String>,
318    body: String,
319}
320
321/// `POST /api/tickets/{id}/comments`
322pub async fn add_comment(
323    State(state): State<AppState>,
324    Path(id): Path<String>,
325    Query(pq): Query<ProjectQuery>,
326    Json(b): Json<CommentBody>,
327) -> ApiResult {
328    let store = store_for(&state, pq.project.or(b.project))?;
329    let author = b.author.unwrap_or_else(|| "ui".to_string());
330    let cid = ops::add_comment(&store, &id, &author, &b.body, Utc::now())?;
331    notify(&state);
332    Ok(Json(json!({ "ok": true, "ticket": id, "comment": cid })))
333}
334
335/// `GET /api/definitions` - labels + priorities.
336pub async fn definitions(
337    State(state): State<AppState>,
338    Query(q): Query<ProjectQuery>,
339) -> ApiResult {
340    let store = store_for(&state, q.project)?;
341    Ok(Json(serde_json::to_value(store.load_definitions()?)?))
342}
343
344/// Body for creating a label definition. `color` is optional (auto-assigned).
345#[derive(Debug, Deserialize)]
346pub struct LabelBody {
347    project: Option<String>,
348    name: String,
349    #[serde(default)]
350    color: Option<String>,
351    #[serde(default)]
352    description: Option<String>,
353}
354
355/// `POST /api/labels` - define a new label (auto-colored if no color given).
356pub async fn create_label(
357    State(state): State<AppState>,
358    Query(pq): Query<ProjectQuery>,
359    Json(b): Json<LabelBody>,
360) -> ApiResult {
361    let store = store_for(&state, pq.project.or(b.project))?;
362    let label = ops::create_label(&store, &b.name, b.color, b.description)?;
363    notify(&state);
364    Ok(Json(serde_json::to_value(label)?))
365}
366
367/// Body for updating a label's color.
368#[derive(Debug, Deserialize)]
369pub struct LabelColorBody {
370    project: Option<String>,
371    color: String,
372}
373
374/// `PATCH /api/labels/{name}` - change a label's color.
375pub async fn recolor_label(
376    State(state): State<AppState>,
377    Path(name): Path<String>,
378    Query(pq): Query<ProjectQuery>,
379    Json(b): Json<LabelColorBody>,
380) -> ApiResult {
381    let store = store_for(&state, pq.project.or(b.project))?;
382    let label = ops::set_label_color(&store, &name, &b.color)?;
383    notify(&state);
384    Ok(Json(serde_json::to_value(label)?))
385}
386
387/// `DELETE /api/labels/{name}` - delete a label and strip it from all tickets.
388pub async fn delete_label(
389    State(state): State<AppState>,
390    Path(name): Path<String>,
391    Query(q): Query<ProjectQuery>,
392) -> ApiResult {
393    let store = store_for(&state, q.project)?;
394    ops::delete_label(&store, &name, Utc::now())?;
395    notify(&state);
396    Ok(Json(json!({ "ok": true })))
397}
398
399/// Body for patching a ticket. Absent fields are left unchanged; an explicit
400/// `null` for `priority` clears it.
401#[derive(Debug, Deserialize)]
402pub struct PatchBody {
403    project: Option<String>,
404    #[serde(default)]
405    title: Option<String>,
406    #[serde(default)]
407    body: Option<String>,
408    #[serde(default)]
409    priority: Option<Option<String>>,
410    #[serde(default)]
411    labels: Option<Vec<String>>,
412    #[serde(default)]
413    assignees: Option<Vec<String>>,
414    #[serde(default)]
415    actor: Option<String>,
416}
417
418/// `PATCH /api/tickets/{id}` - update ticket fields.
419pub async fn patch_ticket(
420    State(state): State<AppState>,
421    Path(id): Path<String>,
422    Query(pq): Query<ProjectQuery>,
423    Json(b): Json<PatchBody>,
424) -> ApiResult {
425    let store = store_for(&state, pq.project.or(b.project))?;
426    let actor = resolve_actor(&store, b.actor);
427    let patch = TicketPatch {
428        title: b.title,
429        body: b.body,
430        priority: b.priority,
431        labels: b.labels,
432        assignees: b.assignees,
433    };
434    let ticket = ops::update_ticket(&store, &id, patch, &actor, Utc::now())?;
435    notify(&state);
436    Ok(Json(serde_json::to_value(ticket)?))
437}
438
439/// `GET /api/identities` - humans (from git) + agents (registry).
440pub async fn identities(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
441    let store = store_for(&state, q.project)?;
442    Ok(Json(json!({ "identities": ops::list_identities(&store)? })))
443}
444
445/// Body for creating/updating an identity.
446#[derive(Debug, Deserialize)]
447pub struct IdentityBody {
448    project: Option<String>,
449    display_name: String,
450    #[serde(default)]
451    kind: Option<String>,
452}
453
454/// `PUT /api/identities/{id}` - set an identity's display name / kind.
455pub async fn put_identity(
456    State(state): State<AppState>,
457    Path(id): Path<String>,
458    Query(pq): Query<ProjectQuery>,
459    Json(b): Json<IdentityBody>,
460) -> ApiResult {
461    let store = store_for(&state, pq.project.or(b.project))?;
462    let kind = match b.kind.as_deref() {
463        Some("agent") => Some(IdentityKind::Agent),
464        Some("human") => Some(IdentityKind::Human),
465        _ => None,
466    };
467    let ident = ops::upsert_identity(&store, &id, &b.display_name, kind)?;
468    notify(&state);
469    Ok(Json(serde_json::to_value(ident)?))
470}
471
472/// `DELETE /api/identities/{id}` - remove an identity from the registry (agents /
473/// manual overrides; git-discovered humans reappear from history).
474pub async fn delete_identity(
475    State(state): State<AppState>,
476    Path(id): Path<String>,
477    Query(q): Query<ProjectQuery>,
478) -> ApiResult {
479    let store = store_for(&state, q.project)?;
480    ops::delete_identity(&store, &id)?;
481    notify(&state);
482    Ok(Json(json!({ "ok": true, "id": id })))
483}
484
485/// `POST /api/tickets/{id}/attachments` - multipart file upload (field `file`).
486pub async fn upload_attachment(
487    State(state): State<AppState>,
488    Path(id): Path<String>,
489    Query(q): Query<ProjectQuery>,
490    mut multipart: Multipart,
491) -> ApiResult {
492    let store = store_for(&state, q.project)?;
493    let actor = resolve_actor(&store, None);
494    let max = store.load_settings()?.max_attachment_mb * 1024 * 1024;
495
496    while let Some(field) = multipart
497        .next_field()
498        .await
499        .map_err(|e| ApiError(anyhow::anyhow!("bad upload: {e}")))?
500    {
501        if field.name() != Some("file") {
502            continue;
503        }
504        let name = field.file_name().unwrap_or("file").to_string();
505        let mime = field
506            .content_type()
507            .map(|s| s.to_string())
508            .unwrap_or_else(|| "application/octet-stream".to_string());
509        let bytes = field
510            .bytes()
511            .await
512            .map_err(|e| ApiError(anyhow::anyhow!("read upload: {e}")))?;
513        if bytes.len() as u64 > max {
514            return Err(ApiError(anyhow::anyhow!(
515                "attachment is {:.1} MB, over the {} MB limit",
516                bytes.len() as f64 / 1_048_576.0,
517                max / 1024 / 1024
518            )));
519        }
520        let att = ops::add_attachment(&store, &id, &name, &bytes, &mime, &actor, Utc::now())?;
521        notify(&state);
522        return Ok(Json(serde_json::to_value(att)?));
523    }
524    Err(ApiError(anyhow::anyhow!("no `file` field in upload")))
525}
526
527/// Body for detaching an attachment.
528#[derive(Debug, Deserialize)]
529pub struct DetachBody {
530    project: Option<String>,
531    path: String,
532    #[serde(default)]
533    actor: Option<String>,
534}
535
536/// `DELETE /api/tickets/{id}/attachments` - detach by repo-relative path.
537pub async fn delete_attachment(
538    State(state): State<AppState>,
539    Path(id): Path<String>,
540    Query(pq): Query<ProjectQuery>,
541    Json(b): Json<DetachBody>,
542) -> ApiResult {
543    let store = store_for(&state, pq.project.or(b.project))?;
544    let actor = resolve_actor(&store, b.actor);
545    ops::remove_attachment(&store, &id, &b.path, &actor, Utc::now())?;
546    notify(&state);
547    Ok(Json(json!({ "ok": true })))
548}
549
550/// `GET /api/media/{*path}` - serve an attachment for preview/download.
551pub async fn serve_media(
552    State(state): State<AppState>,
553    Query(q): Query<ProjectQuery>,
554    Path(path): Path<String>,
555) -> Response {
556    let store = match store_for(&state, q.project) {
557        Ok(s) => s,
558        Err(e) => return e.into_response(),
559    };
560    let rel = path.replace('\\', "/");
561    if rel.contains("..") {
562        return (StatusCode::BAD_REQUEST, "invalid path").into_response();
563    }
564    let full = store.root().join(&rel);
565    // Confine reads to within the project root.
566    let within = std::fs::canonicalize(store.root())
567        .ok()
568        .zip(std::fs::canonicalize(&full).ok())
569        .map(|(root, target)| target.starts_with(root))
570        .unwrap_or(false);
571    if !within {
572        return (StatusCode::NOT_FOUND, "not found").into_response();
573    }
574    match std::fs::read(&full) {
575        Ok(bytes) => {
576            let mime = mime_guess::from_path(&full).first_or_octet_stream();
577            ([(header::CONTENT_TYPE, mime.as_ref())], bytes).into_response()
578        }
579        Err(_) => (StatusCode::NOT_FOUND, "not found").into_response(),
580    }
581}
582
583/// `GET /api/graph` - the commit graph (all branches) with board checkpoints.
584pub async fn graph(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
585    let store = store_for(&state, q.project)?;
586    let commits = git::graph(store.root(), Some(300))?;
587    Ok(Json(json!({ "commits": commits })))
588}
589
590/// Body for adding a list.
591#[derive(Debug, Deserialize)]
592pub struct AddListBody {
593    project: Option<String>,
594    name: String,
595}
596
597/// `POST /api/lists` - add a list to the board.
598pub async fn add_list(
599    State(state): State<AppState>,
600    Query(pq): Query<ProjectQuery>,
601    Json(b): Json<AddListBody>,
602) -> ApiResult {
603    let store = store_for(&state, pq.project.or(b.project))?;
604    let id = ops::add_list(&store, &b.name, Utc::now())?;
605    notify(&state);
606    Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
607}
608
609/// Body for renaming a list.
610#[derive(Debug, Deserialize)]
611pub struct RenameListBody {
612    project: Option<String>,
613    name: String,
614}
615
616/// `PATCH /api/lists/{id}` - rename a list.
617pub async fn rename_list(
618    State(state): State<AppState>,
619    Path(id): Path<String>,
620    Query(pq): Query<ProjectQuery>,
621    Json(b): Json<RenameListBody>,
622) -> ApiResult {
623    let store = store_for(&state, pq.project.or(b.project))?;
624    ops::rename_list(&store, &id, &b.name, Utc::now())?;
625    notify(&state);
626    Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
627}
628
629/// Body for reordering a list.
630#[derive(Debug, Deserialize)]
631pub struct MoveListBody {
632    project: Option<String>,
633    index: usize,
634}
635
636/// `POST /api/lists/{id}/move` - reorder a list to a new index.
637pub async fn move_list(
638    State(state): State<AppState>,
639    Path(id): Path<String>,
640    Query(pq): Query<ProjectQuery>,
641    Json(b): Json<MoveListBody>,
642) -> ApiResult {
643    let store = store_for(&state, pq.project.or(b.project))?;
644    ops::move_list(&store, &id, b.index, Utc::now())?;
645    notify(&state);
646    Ok(Json(json!({ "ok": true, "id": id, "index": b.index })))
647}
648
649/// Query for removing a list.
650#[derive(Debug, Deserialize)]
651pub struct RemoveListQuery {
652    project: Option<String>,
653    #[serde(default)]
654    force: bool,
655}
656
657/// `DELETE /api/lists/{id}` - remove a list (use `?force=true` to delete its cards).
658pub async fn remove_list(
659    State(state): State<AppState>,
660    Path(id): Path<String>,
661    Query(q): Query<RemoveListQuery>,
662) -> ApiResult {
663    let store = store_for(&state, q.project)?;
664    ops::remove_list(&store, &id, q.force, Utc::now())?;
665    notify(&state);
666    Ok(Json(json!({ "ok": true, "id": id })))
667}
668
669// --- forum -----------------------------------------------------------------
670
671/// `GET /api/forum` - thread summaries (root posts + total post counts), newest first.
672pub async fn forum_list(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
673    let store = store_for(&state, q.project)?;
674    let all = forum::index(&store)?;
675    let threads: Vec<Value> = all
676        .iter()
677        .filter(|p| p.depth == 0)
678        .map(|r| {
679            let posts = all.iter().filter(|p| p.thread_id == r.thread_id).count();
680            json!({
681                "id": r.thread_id,
682                "title": r.thread_title,
683                "author": r.author,
684                "labels": r.labels,
685                "posts": posts,
686                "created": r.created,
687            })
688        })
689        .collect();
690    Ok(Json(json!({ "threads": threads })))
691}
692
693/// `GET /api/forum/{id}` - a whole thread (root + nested reply tree).
694pub async fn forum_thread(
695    State(state): State<AppState>,
696    Path(id): Path<String>,
697    Query(q): Query<ProjectQuery>,
698) -> ApiResult {
699    let store = store_for(&state, q.project)?;
700    Ok(Json(serde_json::to_value(forum::get_thread(&store, &id)?)?))
701}
702
703/// Body for creating a thread.
704#[derive(Debug, Deserialize)]
705pub struct ForumPostBody {
706    project: Option<String>,
707    title: String,
708    #[serde(default)]
709    body: String,
710    #[serde(default)]
711    labels: Vec<String>,
712    #[serde(default)]
713    refs: Vec<String>,
714    #[serde(default)]
715    actor: Option<String>,
716}
717
718/// `POST /api/forum` - open a new thread.
719pub async fn forum_create(
720    State(state): State<AppState>,
721    Query(pq): Query<ProjectQuery>,
722    Json(b): Json<ForumPostBody>,
723) -> ApiResult {
724    let store = store_for(&state, pq.project.or(b.project))?;
725    let actor = resolve_actor(&store, b.actor);
726    let t = forum::create_thread(
727        &store,
728        NewThread {
729            title: b.title,
730            body: b.body,
731            labels: b.labels,
732            refs: b.refs,
733            attachments: Vec::new(),
734        },
735        &actor,
736        Utc::now(),
737    )?;
738    notify(&state);
739    Ok(Json(serde_json::to_value(t)?))
740}
741
742/// Body for replying to a post.
743#[derive(Debug, Deserialize)]
744pub struct ForumReplyBody {
745    project: Option<String>,
746    body: String,
747    #[serde(default)]
748    labels: Vec<String>,
749    #[serde(default)]
750    refs: Vec<String>,
751    #[serde(default)]
752    actor: Option<String>,
753}
754
755/// `POST /api/forum/{id}/reply` - reply to a post at any depth.
756pub async fn forum_reply(
757    State(state): State<AppState>,
758    Path(id): Path<String>,
759    Query(pq): Query<ProjectQuery>,
760    Json(b): Json<ForumReplyBody>,
761) -> ApiResult {
762    let store = store_for(&state, pq.project.or(b.project))?;
763    let actor = resolve_actor(&store, b.actor);
764    let child = forum::reply(
765        &store,
766        &id,
767        NewReply {
768            body: b.body,
769            labels: b.labels,
770            refs: b.refs,
771            attachments: Vec::new(),
772        },
773        &actor,
774        Utc::now(),
775    )?;
776    notify(&state);
777    Ok(Json(json!({ "ok": true, "id": child, "parent": id })))
778}
779
780/// Body for editing a post.
781#[derive(Debug, Deserialize)]
782pub struct ForumEditBody {
783    project: Option<String>,
784    body: String,
785}
786
787/// `PATCH /api/forum/{id}` - edit a post's body.
788pub async fn forum_edit(
789    State(state): State<AppState>,
790    Path(id): Path<String>,
791    Query(pq): Query<ProjectQuery>,
792    Json(b): Json<ForumEditBody>,
793) -> ApiResult {
794    let store = store_for(&state, pq.project.or(b.project))?;
795    forum::edit_post(&store, &id, &b.body, Utc::now())?;
796    notify(&state);
797    Ok(Json(json!({ "ok": true, "id": id })))
798}
799
800/// `DELETE /api/forum/{id}` - delete a post and its subtree.
801pub async fn forum_delete(
802    State(state): State<AppState>,
803    Path(id): Path<String>,
804    Query(q): Query<ProjectQuery>,
805) -> ApiResult {
806    let store = store_for(&state, q.project)?;
807    forum::delete_post(&store, &id, Utc::now())?;
808    notify(&state);
809    Ok(Json(json!({ "ok": true, "id": id })))
810}
811
812/// Query for a forum search.
813#[derive(Debug, Deserialize)]
814pub struct ForumSearchParams {
815    project: Option<String>,
816    #[serde(default)]
817    q: Option<String>,
818    #[serde(default)]
819    author: Option<String>,
820    #[serde(default)]
821    label: Option<String>,
822    #[serde(default)]
823    scope: Option<String>,
824    #[serde(default)]
825    titles: Option<bool>,
826    #[serde(default)]
827    depth: Option<usize>,
828    #[serde(default)]
829    limit: Option<usize>,
830}
831
832/// `GET /api/forum/search` - regex + filter search over posts.
833pub async fn forum_search(
834    State(state): State<AppState>,
835    Query(p): Query<ForumSearchParams>,
836) -> ApiResult {
837    let store = store_for(&state, p.project)?;
838    let pattern = match p.q.as_deref() {
839        Some(s) if !s.trim().is_empty() => Some(forum::compile_pattern(s, true)?),
840        _ => None,
841    };
842    let query = SearchQuery {
843        pattern,
844        author: p.author,
845        labels: p.label.into_iter().collect(),
846        scope: p.scope,
847        max_depth: p.depth,
848        titles_only: p.titles.unwrap_or(false),
849        limit: p.limit,
850    };
851    Ok(Json(json!({ "posts": forum::search(&store, &query)? })))
852}
853
854// --- websocket -------------------------------------------------------------
855
856/// `GET /ws` - upgrade to a WebSocket that streams change notifications.
857pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
858    ws.on_upgrade(move |socket| ws_loop(socket, state))
859}
860
861/// Decrements the live-client counter when a WebSocket handler ends.
862struct ClientGuard(Arc<AtomicUsize>);
863impl Drop for ClientGuard {
864    fn drop(&mut self) {
865        self.0.fetch_sub(1, Ordering::SeqCst);
866    }
867}
868
869async fn ws_loop(mut socket: WebSocket, state: AppState) {
870    let mut rx = state.tx.subscribe();
871    // Count this client for the lifetime of the socket so idle-shutdown knows the
872    // board is actively being viewed; `_guard` decrements on drop (any exit path).
873    state.clients.fetch_add(1, Ordering::SeqCst);
874    let _guard = ClientGuard(state.clients.clone());
875    let _ = socket.send(Message::Text("connected".into())).await;
876    loop {
877        tokio::select! {
878            msg = rx.recv() => match msg {
879                Ok(m) => {
880                    if socket.send(Message::Text(m.into())).await.is_err() {
881                        break;
882                    }
883                }
884                Err(broadcast::error::RecvError::Closed) => break,
885                Err(broadcast::error::RecvError::Lagged(_)) => {}
886            },
887            incoming = socket.recv() => match incoming {
888                Some(Ok(_)) => {}
889                _ => break,
890            },
891        }
892    }
893}