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//! Session-scoped dashboard watchlists (Phase C).
2//!
3//! The dashboard is a personal, editable watchlist with no accounts: a browser
4//! is identified by an opaque `fin_sid` cookie, and its symbols live in the
5//! `watchlist` table keyed on that sid (see migration 0015). A brand-new
6//! browser (no cookie) is minted a sid and seeded with the [`STARTERS`]; an
7//! existing cookie's list is used as-is, even when empty, so a user who removes
8//! everything is not re-seeded. Clearing cookies loses the list, by design.
9
10use axum::http::{header, HeaderMap};
11use sqlx::SqlitePool;
12
13use crate::db::now_ms;
14
15/// The symbols a brand-new browser's watchlist is seeded with. The S&P 500
16/// baseline is *not* here — the dashboard always shows it as the comparison
17/// baseline; these are the user's editable rows.
18pub const STARTERS: &[&str] = &["VTI", "VXUS", "BND", "IAU", "IBIT"];
19
20/// The cookie name that carries the opaque session id.
21pub const COOKIE: &str = "fin_sid";
22
23/// The session cookie's lifetime: one year.
24const COOKIE_MAX_AGE_SECS: i64 = 365 * 24 * 3600;
25
26/// A resolved browser session: its sid, and the `Set-Cookie` value to send when
27/// the session was just minted (a first visit).
28pub struct Session {
29 pub sid: String,
30 pub set_cookie: Option<String>,
31}
32
33/// Parse the `fin_sid` value out of the request's `Cookie` header, if present.
34/// Accepts only a short hex string (what we mint), so a hand-crafted cookie
35/// can't smuggle anything unexpected into the sid.
36pub fn sid_from_headers(headers: &HeaderMap) -> Option<String> {
37 let raw = headers.get(header::COOKIE)?.to_str().ok()?;
38 let prefix = format!("{COOKIE}=");
39 for part in raw.split(';') {
40 let part = part.trim();
41 if let Some(v) = part.strip_prefix(&prefix) {
42 let v = v.trim();
43 if !v.is_empty() && v.len() <= 64 && v.bytes().all(|b| b.is_ascii_hexdigit()) {
44 return Some(v.to_string());
45 }
46 }
47 }
48 None
49}
50
51/// The `Set-Cookie` header value that persists `sid` for a year.
52pub fn set_cookie_value(sid: &str) -> String {
53 format!("{COOKIE}={sid}; Path=/; Max-Age={COOKIE_MAX_AGE_SECS}; HttpOnly; SameSite=Lax")
54}
55
56/// Resolve the browser's session. With a cookie present, use that sid as-is
57/// (its list is whatever the browser arranged, even if empty). With no cookie,
58/// mint a new opaque sid, seed the starter watchlist, and return the cookie to
59/// set.
60pub async fn resolve(pool: &SqlitePool, headers: &HeaderMap) -> Session {
61 if let Some(sid) = sid_from_headers(headers) {
62 return Session { sid, set_cookie: None };
63 }
64 // Mint 16 random bytes as hex via SQLite, so no extra crate is needed; fall
65 // back to a timestamp-derived id only if that ever fails.
66 let sid: String = sqlx::query_scalar("SELECT lower(hex(randomblob(16)))")
67 .fetch_one(pool)
68 .await
69 .unwrap_or_else(|_| format!("{:032x}", now_ms()));
70 seed_starters(pool, &sid).await;
71 let set_cookie = Some(set_cookie_value(&sid));
72 Session { sid, set_cookie }
73}
74
75/// Seed a fresh session with the starter symbols (only those that exist in the
76/// universe — they all do, but the guard keeps a missing one from inserting a
77/// dangling row).
78async fn seed_starters(pool: &SqlitePool, sid: &str) {
79 let now = now_ms();
80 for (i, t) in STARTERS.iter().enumerate() {
81 let _ = sqlx::query(
82 "INSERT INTO watchlist (sid, ticker, position, added_at) \
83 SELECT ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM symbols WHERE ticker = ?) \
84 ON CONFLICT(sid, ticker) DO NOTHING",
85 )
86 .bind(sid)
87 .bind(t)
88 .bind(i as i64)
89 .bind(now)
90 .bind(t)
91 .execute(pool)
92 .await;
93 }
94}
95
96/// The watchlist tickers for `sid`, in display order.
97pub async fn list(pool: &SqlitePool, sid: &str) -> Vec<String> {
98 sqlx::query_scalar("SELECT ticker FROM watchlist WHERE sid = ? ORDER BY position, added_at")
99 .bind(sid)
100 .fetch_all(pool)
101 .await
102 .unwrap_or_default()
103}
104
105/// Append `ticker` to `sid`'s watchlist. Assumes the symbol already exists in
106/// the universe (the route ensures it first). Idempotent on (sid, ticker).
107pub async fn add_ticker(pool: &SqlitePool, sid: &str, ticker: &str) -> sqlx::Result<()> {
108 let now = now_ms();
109 sqlx::query(
110 "INSERT INTO watchlist (sid, ticker, position, added_at) \
111 VALUES (?, ?, COALESCE((SELECT MAX(position) + 1 FROM watchlist WHERE sid = ?), 0), ?) \
112 ON CONFLICT(sid, ticker) DO NOTHING",
113 )
114 .bind(sid)
115 .bind(ticker)
116 .bind(sid)
117 .bind(now)
118 .execute(pool)
119 .await?;
120 Ok(())
121}
122
123/// Remove `ticker` from `sid`'s watchlist.
124pub async fn remove_ticker(pool: &SqlitePool, sid: &str, ticker: &str) -> sqlx::Result<()> {
125 sqlx::query("DELETE FROM watchlist WHERE sid = ? AND ticker = ?")
126 .bind(sid)
127 .bind(ticker)
128 .execute(pool)
129 .await?;
130 Ok(())
131}