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