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