Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.
axumdockerfinancerustself-hostedsqlitestocksvite
1//! `POST /api/watchlist` (add) and `POST /api/watchlist/remove` — edit the
2//! current browser session's dashboard watchlist (Phase C).
3//!
4//! Both resolve the session from the `fin_sid` cookie (minting one on a first
5//! visit), so they work even if a script calls them before the dashboard set
6//! the cookie. Add ensures the symbol is in the universe first (validating /
7//! backfilling a brand-new ticker via `ensure_symbol`), then appends it.
8
9use axum::{
10 extract::State,
11 http::{header, HeaderMap, StatusCode},
12 response::{IntoResponse, Response},
13 routing::post,
14 Json, Router,
15};
16use serde::{Deserialize, Serialize};
17
18use crate::routes::symbols::ensure_symbol;
19use crate::{watchlist, AppState};
20
21pub fn router() -> Router<AppState> {
22 Router::new()
23 .route("/api/watchlist", post(add))
24 .route("/api/watchlist/remove", post(remove))
25}
26
27#[derive(Deserialize)]
28struct Body {
29 ticker: String,
30}
31
32#[derive(Serialize)]
33struct Resp {
34 ok: bool,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 ticker: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 name: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 error: Option<String>,
41}
42
43/// Add a symbol to the session's watchlist, validating / backfilling it into
44/// the universe first if it is not tracked yet.
45async fn add(State(state): State<AppState>, headers: HeaderMap, Json(body): Json<Body>) -> Response {
46 let session = watchlist::resolve(&state.pool, &headers).await;
47 match ensure_symbol(&state, &body.ticker).await {
48 Ok(o) => {
49 if let Err(e) = watchlist::add_ticker(&state.pool, &session.sid, &o.ticker).await {
50 tracing::warn!("watchlist add {}: {e:#}", o.ticker);
51 return reply(
52 &session,
53 StatusCode::INTERNAL_SERVER_ERROR,
54 Resp {
55 ok: false,
56 ticker: None,
57 name: None,
58 error: Some("Could not save to your watchlist.".into()),
59 },
60 );
61 }
62 reply(
63 &session,
64 StatusCode::OK,
65 Resp {
66 ok: true,
67 ticker: Some(o.ticker),
68 name: Some(o.name),
69 error: None,
70 },
71 )
72 }
73 Err((status, msg)) => reply(
74 &session,
75 status,
76 Resp {
77 ok: false,
78 ticker: None,
79 name: None,
80 error: Some(msg),
81 },
82 ),
83 }
84}
85
86/// Remove a symbol from the session's watchlist. Idempotent.
87async fn remove(
88 State(state): State<AppState>,
89 headers: HeaderMap,
90 Json(body): Json<Body>,
91) -> Response {
92 let session = watchlist::resolve(&state.pool, &headers).await;
93 let ticker = body.ticker.trim().to_uppercase();
94 let _ = watchlist::remove_ticker(&state.pool, &session.sid, &ticker).await;
95 reply(
96 &session,
97 StatusCode::OK,
98 Resp {
99 ok: true,
100 ticker: Some(ticker),
101 name: None,
102 error: None,
103 },
104 )
105}
106
107/// Build the JSON response, attaching the `Set-Cookie` header when the session
108/// was just minted.
109fn reply(session: &watchlist::Session, status: StatusCode, body: Resp) -> Response {
110 let mut resp = (status, Json(body)).into_response();
111 if let Some(c) = &session.set_cookie {
112 if let Ok(v) = header::HeaderValue::from_str(c) {
113 resp.headers_mut().insert(header::SET_COOKIE, v);
114 }
115 }
116 resp
117}