Compare commits

..
34 Commits
Author SHA1 Message Date
asepharyana 9ea9a4291c feat: scheduler tangkap company name dari top-changes (n=750) — gratis, isi yang kosong saja
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-16 16:54:27 +07:00
asepharyana 41325ba574 feat: tangkap company name dari snapshot top-changes (n=750, gratis) + SetCompanyNames fill-blank 2026-09-16 16:53:50 +07:00
asepharyana 6d3c17d19d fix: PDF export kini Unicode — ganti core font Helvetica (latin1 → '?') dengan DejaVuSans ter-embed
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-16 16:48:27 +07:00
asepharyana ae0d5d813f feat(ui): watchlist empty-state + chat error styling + hint
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
- WatchlistDrawer: shows helpful hint ('Ketik kode saham lalu Add') when empty
- ChatSidebar: errors in text-destructive, empty state explains how chat works
- styled-components/timestamp formatting and company-name display already in previous commit
2026-09-16 15:39:33 +07:00
asepharyana ffdc0fd9e8 feat(ui): polish pass — theme-aware charts, brand nav, search & date formatting
- Dashboard foreign-flow chart line now uses the CSS --primary token (hsl) so it matches light/dark instead of a hardcoded blue; Portfolio bar chart gets themed bars with rounded corners + alpha steps
- Nav: logo mark (📈 in primary tile), active item left accent bar, active-state matching for sub-routes (/report/:ticker now highlights Report... actually Dashboard/Screener etc)
- TickerSearch: cleaner searchbox with magnifier icon + 'Buka' button, aria-label
- Login page: centered branded header (logo + FlowSight), vertically centered card
- Sorotan cards + Screener show company name under ticker when available
- Routines run history + Alerts event/alert lists: formatted dates (16 Sep 07:30), status badges (ok/error), credits-used display
- Dashboard hero/misc spacing polish
2026-09-16 15:33:18 +07:00
asepharyana 05a3bc7471 fix: audit round 4 — XSS in HTML report export, SSRF webhook guard, body limit
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
- ToHTML() now html.EscapeString on ticker, generated_at, section name/body, citations (stored/LLM content can't inject script into exported file)
- Discord destination webhook_url restricted to discord.com/discordapp.com hosts (SSRF guard: server POSTs alert cards; no private IPs or arbitrary hosts)
- Router middleware caps POST/PUT/PATCH bodies at 1 MiB (MaxBytesReader)
- TestDestinations updated: non-discord host must 422, uses realistic discord.com webhook URL
2026-09-16 14:43:56 +07:00
asepharyana b19b9c71d1 test: universe save + local ticker search (ticker & company-name lookup)
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-16 14:32:54 +07:00
asepharyana 49dd2101e0 fix: local ticker search for screener (Sectors companies/ 401s on sub)
- q= now searches universe table locally (ticker + company_name LIKE, order by close) instead of calling the Sectors companies/ endpoint, which returns SUBSCRIPTION_DOES_NOT_ALLOW on the current subscription and silently fell back to the full 780-ticker universe
- removes the broken name-fill cmd + scheduler/one-shot Screen() name enrichment (wasted credits, empty names)
- Screener UI: search card says 'kode saham', results still show company name when present
2026-09-16 14:30:22 +07:00
asepharyana d7eb0a3abf feat: company names in screener + universe enrichment
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
- migration 0010: universe.company_name column
- SaveUniverse persists company names; new CompanyNames() store lookup
- scheduler universe sweep + universe-sweep cmd fetch names via one Screen() call (best effort)
- /api/screen now returns real company names (live when keyed, stored as fallback)
- Screener card shows 'PT Bank ...' name under the ticker symbol for search results
2026-09-16 14:23:09 +07:00
asepharyana e203c61279 feat: dashboard live SSE feed + screener custom search
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
- Dashboard 'Kabar terbaru' now subscribes to /api/stream (alerts/activity channels) and prepends live events without page reload (subscribeSSE was dead code before)
- Screener: new search card (nama/sektor free-text via /api/screen q=) alongside the 4 presets
2026-09-16 14:17:08 +07:00
asepharyana 1ec860f9be fix: audit round 3 — IDOR report-scoping, auth rate-limit, briefing chat scoping, DailyVolumes dedup, N+1 list routines, FE export/ask error handling
- IDOR: interrogate without report_id now scoped to user_key (LatestReportForUser); regression test TestInterrogateIDORScoped
- auth: signup/login per-IP rate limit 10/60s (new internal/api/ratelimit.go) + test
- chat unscoped grounding: build caller's own briefing instead of global LatestBriefing
- DailyVolumes: dedupe by bar date (snapshot rows hold 30-day windows) — fixes volume-anomaly skew
- GetDestination: direct (id,user_key) query instead of listing all
- ListRoutines: single LastRunsByRoutine query instead of N+1 RunHistory
- FE: exportMd/ask/HTML/PDF export now surface errors; alerts create clears channels
2026-09-16 14:12:32 +07:00
asepharyana 0b00daed39 fix: foreign_tickers selalu include semua ticker dengan foreign data (bukan hanya saat watchlist>60)
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-16 01:29:01 +07:00
asepharyana 9438f2566b chore: ignore stray binary 2026-09-16 01:27:43 +07:00
asepharyana 5f7b6c9c29 chore: remove stray committed binary 2026-09-16 01:27:32 +07:00
asepharyana b09ff92174 fix: Sectors API dates pakai UTC (WIB midnight ditolak 'future') — tickerDepth + foreign-backfill 2026-09-16 01:27:24 +07:00
asepharyana defe8b46db perf: rotateDepth batch 30/cycle (90 credit) — full universe coverage ~26 cycle
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-16 01:13:23 +07:00
asepharyana 6fd89b6c19 feat: full IDX universe (780 ticker) — universe table + close sweep harian, dashboard foreign-flow picker dari semua ticker, rotateDepth incremental fill 10/cylcle, strip .JK, backfill cmd/universe-sweep
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-16 01:06:42 +07:00
asepharyana 07ece10474 fix: audit round 2 — session cookie Secure kondisional, logout clear cookie, IDOR report owner-scoping (migrasi 0008), health force login-gate, SSE stream login-gate, FE: Gate undefined-flicker, auth-expired global redirect, X-User-Key demo dihapus, error handling alerts/routines/watchlist, chart label, encodeURIComponent, Screener onMount
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 23:19:56 +07:00
asepharyana 96be4d0922 fix: version commit dibaca live per-request + CI polling anti-stale 20x15s
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 22:19:53 +07:00
asepharyana 79f42dfefa feat: default watchlist semua, login-gate fitur, CI anti-stale via /api/version 2026-09-15 22:12:09 +07:00
asepharyana 5c953289b1 fix: report 504 — cap triase LLM 5 artikel, narasi_awam persist ke payload; report FE bahasa awam
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 21:15:49 +07:00
asepharyana 7db077c2e6 fix: warna verdict konsisten (hijau/merah), bar bukti biru-ungu netral
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 21:04:51 +07:00
asepharyana 793e0724ed feat: dashboard substansial — harga close per kartu, konteks pasar, grafik berformat Rp + periode
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 20:55:38 +07:00
asepharyana dab3a9d394 fix: saran portfolio utamakan konsentrasi sektor (financials 65%)
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 20:31:14 +07:00
asepharyana a35182ead0 feat: fase 3 awam — Screener preset, Alerts template, Routines manfaat, Portfolio saran
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 20:23:40 +07:00
asepharyana 9fa47240dc feat: auto-narasi LLM (OmniRoute sonnet-5) — hero briefing + narasi_awam report, migrasi 0006
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 19:50:48 +07:00
asepharyana 390133663a feat: dashboard awam-first — hero ringkasan, kartu verdict, tooltip istilah
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 19:06:35 +07:00
asepharyana 1bda859def fix: seed bundle path ../share/flowsight (Nix layout), prod boots with offline data
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 17:55:40 +07:00
asepharyana 8392dbe724 feat: FE solid-ui (shadcn port) — Tailwind + 14 components, all pages rewritten
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 17:40:39 +07:00
asepharyana debc907188 feat: add .hermes/ to .gitignore for improved local environment management 2026-09-15 17:21:16 +07:00
asepharyana 5927eec465 feat: alerts rule builder + destinations UI, portfolio charts + accuracy ledger
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s
2026-09-15 16:46:17 +07:00
asepharyana 94a7f1dfd9 feat: briefing auto-generate, pages overhaul (dashboard/screener/report/routines), delete routine 2026-09-15 16:39:27 +07:00
asepharyana ff156112e7 feat: light/dark theme + app shell (sidebar, topbar, auth, login page) 2026-09-15 16:35:03 +07:00
asepharyana 1881e7c6b1 feat: Google OAuth login + server sessions (user_key u:<sub>) 2026-09-15 16:29:57 +07:00
71 changed files with 5124 additions and 615 deletions

No files matched your search

+16 -1
View File
@@ -149,13 +149,28 @@ jobs:
(cd /home/code/flowsight && git fetch origin main && git reset --hard origin/main || true) && \ (cd /home/code/flowsight && git fetch origin main && git reset --hard origin/main || true) && \
sudo mkdir -p /var/lib/flowsight/data && sudo chown -R code:code /var/lib/flowsight && \ sudo mkdir -p /var/lib/flowsight/data && sudo chown -R code:code /var/lib/flowsight && \
(sudo cp /home/code/flowsight/deploy/flowsight.service /etc/systemd/system/flowsight.service && sudo systemctl daemon-reload || true) && \ (sudo cp /home/code/flowsight/deploy/flowsight.service /etc/systemd/system/flowsight.service && sudo systemctl daemon-reload || true) && \
echo -n "$GITHUB_SHA" | sudo tee /var/lib/flowsight/version.txt >/dev/null && \
sudo chown code:code /var/lib/flowsight/version.txt && \
sudo systemctl enable --now flowsight && \ sudo systemctl enable --now flowsight && \
sudo systemctl restart flowsight && \ sudo systemctl restart flowsight && \
sleep 3 && \ sleep 3 && \
sudo systemctl status flowsight --no-pager --no-legend | head -5" sudo systemctl status flowsight --no-pager --no-legend | head -5"
- name: Verify service - name: Verify service anti-stale
env:
PUSHED_SHA: ${{ github.sha }}
run: | run: |
ssh "$VPS_USER@$VPS_HOST" \ ssh "$VPS_USER@$VPS_HOST" \
"curl -s -o /dev/null -w 'local:%{http_code}\n' http://localhost:4022/api/health && \ "curl -s -o /dev/null -w 'local:%{http_code}\n' http://localhost:4022/api/health && \
curl -sk -o /dev/null -w 'public:%{http_code}\n' https://sectors-hackaton.asepharyana.my.id/api/health" curl -sk -o /dev/null -w 'public:%{http_code}\n' https://sectors-hackaton.asepharyana.my.id/api/health"
for i in $(seq 1 20); do
LIVE=$(curl -sk https://sectors-hackaton.asepharyana.my.id/api/version 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin).get('commit',''))" 2>/dev/null || true)
echo "attempt $i/20: live=${LIVE:0:7} want=${PUSHED_SHA:0:7}"
if [ -n "$LIVE" ] && [ "$LIVE" = "$PUSHED_SHA" ]; then
echo "Anti-stale OK: live commit matches pushed SHA"
exit 0
fi
sleep 15
done
echo "STALE: live commit differs from pushed SHA after 5 min"
exit 1
+4 -1
View File
@@ -6,7 +6,8 @@
.claude-flow/data/ .claude-flow/data/
.claude-flow/logs/ .claude-flow/logs/
.claude-flow/sessions/ .claude-flow/sessions/
.hermes/
.planning
# Ruflo runtime artifacts (not source) # Ruflo runtime artifacts (not source)
.swarm/ .swarm/
.claude/ .claude/
@@ -47,3 +48,5 @@ Thumbs.db
task_plan.md task_plan.md
findings.md findings.md
progress.md progress.md
backend/universe-sweep
/backend/foreign-backfill
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"context"
"encoding/json"
"log"
"os"
"time"
"flowsight/internal/sectors"
"flowsight/internal/store"
)
// one-shot foreign-flow backfill for the top-N universe tickers (by close),
// foreign-only (1 credit each) so the dashboard chart picker has data for
// hundreds of tickers immediately. Usage:
// BWS_PROJECT_ID=... sh scripts/bws-run.sh go run ./cmd/foreign-backfill <N>
func main() {
n := 400
if len(os.Args) > 1 {
if v := os.Args[1]; v != "" {
var t int
if _, err := fmtSscan(v, &t); err == nil && t > 0 {
n = t
}
}
}
db, err := store.Open(os.Getenv("DB_PATH"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
c := sectors.New("https://api.sectors.app/v2/", os.Getenv("SECTORS_API_KEY"))
ctx, cancel := context.WithTimeout(context.Background(), 400*time.Second)
defer cancel()
c.OnSpend(func(endpoint string, calls, credits int) { logf("spend %d credits %s\n", credits, endpoint) })
rows, err := db.Query(`SELECT ticker FROM universe ORDER BY close DESC LIMIT ?`, n)
if err != nil {
log.Fatal(err)
}
var tickers []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
log.Fatal(err)
}
tickers = append(tickers, t)
}
rows.Close()
logf("backfilling foreign flow for %d tickers\n", len(tickers))
done := 0
now := time.Now().UTC()
end := now.Format("2006-01-02")
start := now.AddDate(0, 0, -30).Format("2006-01-02")
for _, t := range tickers {
ff, err := c.ForeignFlow(ctx, t, start, end)
if err != nil {
logf(" %s err: %v\n", t, err)
time.Sleep(2 * time.Second)
continue
}
if raw, err := json.Marshal(ff); err == nil && len(ff.Data) > 0 {
_ = db.SaveSnapshot(t, end, "foreign-flow", string(raw))
for _, d := range ff.Data {
_ = db.UpsertForeignFlow(t, d.Date, float64(d.NetForeignInflow))
}
done++
}
time.Sleep(1200 * time.Millisecond) // stay under the 429 pace
}
logf("done: %d/%d tickers have foreign data now\n", done, len(tickers))
}
func logf(f string, a ...any) { log.Printf(f, a...) }
func fmtSscan(s string, v *int) (int, error) {
n := 0
for _, r := range s {
if r < '0' || r > '9' {
return 0, errBad
}
n = n*10 + int(r-'0')
}
*v = n
return 1, nil
}
var errBad = errT("bad number")
type errT string
func (e errT) Error() string { return string(e) }
+20 -7
View File
@@ -44,18 +44,31 @@ func main() {
} }
if !seeded { if !seeded {
if ex, err := os.Executable(); err == nil { if ex, err := os.Executable(); err == nil {
dir := filepath.Join(filepath.Dir(ex), "fixtures") binDir := filepath.Dir(ex)
if st, err := db.SeedFromDir(dir, cfg.DemoUserKey); err == nil { for _, dir := range []string{
log.Printf("server: seeded %d snapshots from %s", st.Snapshots, dir) filepath.Join(binDir, "fixtures"),
} else { filepath.Join(binDir, "..", "share", "flowsight"),
log.Printf("server: no seed bundle found (tried tests/fixtures, binary dir): %v", err) } {
if st, err := db.SeedFromDir(dir, cfg.DemoUserKey); err == nil {
log.Printf("server: seeded %d snapshots from %s", st.Snapshots, dir)
seeded = true
break
}
}
if !seeded {
log.Printf("server: no seed bundle found (tried tests/fixtures, binary dir)")
} }
} }
} }
} }
// Ensure the demo watchlist exists even without a seed bundle. // Ensure the demo account covers the "semua" default: every ticker with
// stored data (fallback: config watchlist).
if wl, _ := db.Watchlist(cfg.DemoUserKey); len(wl) == 0 { if wl, _ := db.Watchlist(cfg.DemoUserKey); len(wl) == 0 {
for _, t := range cfg.Watchlist { seeds, _ := db.AllTickers()
if len(seeds) == 0 {
seeds = cfg.Watchlist
}
for _, t := range seeds {
_ = db.AddWatch(cfg.DemoUserKey, t) _ = db.AddWatch(cfg.DemoUserKey, t)
} }
} }
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"time"
"flowsight/internal/sectors"
"flowsight/internal/store"
)
// one-shot: sweep the full IDX universe (close/ pages) and persist it into
// the universe table. Usage: BWS_PROJECT_ID=... sh scripts/bws-run.sh go run ./cmd/universe-sweep
func main() {
// env DB_PATH defaults to data/flowsight.db if unset.
db, err := store.Open(os.Getenv("DB_PATH"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
c := sectors.New("https://api.sectors.app/v2/", os.Getenv("SECTORS_API_KEY"))
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
c.OnSpend(func(endpoint string, calls, credits int) { fmt.Printf("spend %d credits %s\n", credits, endpoint) })
offset := 0
var all []sectors.CloseRow
for page := 0; page < 60; page++ {
rows, _, err := c.ClosePage(ctx, "", 30, offset)
if err != nil {
log.Printf("page %d err: %v (keeping %d so far)", page, err, len(all))
break
}
all = append(all, rows...)
offset += len(rows)
fmt.Printf("page %d: +%d (total %d)\n", page, len(rows), len(all))
if len(rows) == 0 {
break
}
time.Sleep(1500 * time.Millisecond) // stay well under the 429 pace
}
fmt.Println("TOTAL:", len(all))
if len(all) == 0 {
log.Fatal("no rows collected")
}
date := all[0].Date
for _, r := range all {
if r.Date > date {
date = r.Date
}
}
raw, _ := json.Marshal(all)
_ = db.SaveSnapshot("IDX", date, "close", string(raw))
uni := make([]store.UniverseRow, 0, len(all))
for _, r := range all {
uni = append(uni, store.UniverseRow{Symbol: r.Symbol, Close: r.Close, Date: r.Date})
}
if err := db.SaveUniverse(uni); err != nil {
log.Fatal(err)
}
fmt.Println("universe saved:", len(uni), "tickers @", date)
}
+6 -1
View File
@@ -52,12 +52,17 @@ func AnalyzeSentiment(ctx context.Context, d Deps, ticker string) model.AgentRes
pos, neg, neu := 0, 0, 0 pos, neg, neu := 0, 0, 0
var keyEvents []string var keyEvents []string
useLLM := d.LLM != nil && d.LLM.Available() useLLM := d.LLM != nil && d.LLM.Available()
// Cap LLM triage calls: each is one HTTP round-trip to OmniRoute
// (~5-15s). More than 5 articles and report generation exceeds the
// 60s reverse-proxy read timeout -> 504 with a persisted-but-stale
// report. Rules path covers the rest via keywordSentiment.
const maxLLMTriage = 5
for i, a := range news.Results { for i, a := range news.Results {
if i >= 20 { if i >= 20 {
break break
} }
label, conf := "neutral", 0.5 label, conf := "neutral", 0.5
if useLLM { if useLLM && i < maxLLMTriage {
label, conf = d.LLM.SentimentTriage(ctx, d.TriageModel, a.Title, a.Body) label, conf = d.LLM.SentimentTriage(ctx, d.TriageModel, a.Title, a.Body)
} else { } else {
label, conf = keywordSentiment(a.Title + " " + a.Body) label, conf = keywordSentiment(a.Title + " " + a.Body)
+77 -23
View File
@@ -65,7 +65,8 @@ func TestBriefing(t *testing.T) {
rec := do(s, "GET", "/api/briefing/today", nil) rec := do(s, "GET", "/api/briefing/today", nil)
if rec.Code == http.StatusNotFound { if rec.Code == http.StatusNotFound {
// No briefing yet: run the routine via engine path instead. // No briefing yet: run the routine via engine path instead.
rows, _ := s.DB.ListRoutines("demo") u, _ := s.DB.CheckLocalUser("tester", "password1234")
rows, _ := s.DB.ListRoutines(u.UserKey)
if len(rows) == 0 { if len(rows) == 0 {
t.Fatal("seed has no routines") t.Fatal("seed has no routines")
} }
@@ -82,7 +83,7 @@ func TestBriefing(t *testing.T) {
// Screener returns a ranked list with per-row breakdown. // Screener returns a ranked list with per-row breakdown.
func TestScreen(t *testing.T) { func TestScreen(t *testing.T) {
s := testServer(t) s := testServer(t)
rec := do(s, "POST", "/api/screen", map[string]any{"limit": 5}) rec := doAuth(t, s, "POST", "/api/screen", map[string]any{"limit": 5})
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code) t.Fatalf("code = %d", rec.Code)
} }
@@ -101,7 +102,7 @@ func TestScreen(t *testing.T) {
// Report: all 7 sections populated with citations. // Report: all 7 sections populated with citations.
func TestReport(t *testing.T) { func TestReport(t *testing.T) {
s := testServer(t) s := testServer(t)
rec := do(s, "POST", "/api/report/BBCA?profile=moderate", nil) rec := doAuth(t, s, "POST", "/api/report/BBCA?profile=moderate", nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:300]) t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:300])
} }
@@ -122,20 +123,21 @@ func TestReport(t *testing.T) {
// Subscribe -> run -> history row appears. // Subscribe -> run -> history row appears.
func TestRoutineSubscribeRunHistory(t *testing.T) { func TestRoutineSubscribeRunHistory(t *testing.T) {
s := testServer(t) s := testServer(t)
rec := do(s, "POST", "/api/routines", map[string]any{"type": "foreign-reversal"}) rec := doAuth(t, s, "POST", "/api/routines", map[string]any{"type": "foreign-reversal"})
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("code = %d", rec.Code) t.Fatalf("code = %d", rec.Code)
} }
var created map[string]any var created map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &created) _ = json.Unmarshal(rec.Body.Bytes(), &created)
rows, _ := s.DB.ListRoutines("demo") u, _ := s.DB.CheckLocalUser("tester", "password1234")
rows, _ := s.DB.ListRoutines(u.UserKey)
if len(rows) == 0 { if len(rows) == 0 {
t.Fatal("no routines") t.Fatal("no routines")
} }
if _, err := s.Engine.Run(httptest.NewRequest("GET", "/", nil).Context(), rows[0]); err != nil { if _, err := s.Engine.Run(httptest.NewRequest("GET", "/", nil).Context(), rows[0]); err != nil {
t.Fatal(err) t.Fatal(err)
} }
rec = do(s, "GET", "/api/routine-runs?limit=5", nil) rec = doAuth(t, s, "GET", "/api/routine-runs?limit=5", nil)
var out struct { var out struct {
Runs []map[string]any `json:"runs"` Runs []map[string]any `json:"runs"`
} }
@@ -149,11 +151,11 @@ func TestRoutineSubscribeRunHistory(t *testing.T) {
// from the persisted report, unknown report 404s. // from the persisted report, unknown report 404s.
func TestInterrogate(t *testing.T) { func TestInterrogate(t *testing.T) {
s := testServer(t) s := testServer(t)
rec := do(s, "POST", "/api/report/BBCA?profile=moderate", nil) rec := doAuth(t, s, "POST", "/api/report/BBCA?profile=moderate", nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code) t.Fatalf("code = %d", rec.Code)
} }
rec = do(s, "POST", "/api/report/BBCA/ask", map[string]any{"question": "kenapa conviction segitu?"}) rec = doAuth(t, s, "POST", "/api/report/BBCA/ask", map[string]any{"question": "kenapa conviction segitu?"})
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:200]) t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:200])
} }
@@ -164,7 +166,7 @@ func TestInterrogate(t *testing.T) {
if out.Answer == "" { if out.Answer == "" {
t.Fatal("empty interrogation answer") t.Fatal("empty interrogation answer")
} }
rec = do(s, "POST", "/api/report/ZZZZ/ask", map[string]any{"question": "apa?"}) rec = doAuth(t, s, "POST", "/api/report/ZZZZ/ask", map[string]any{"question": "apa?"})
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404 for unknown ticker", rec.Code) t.Fatalf("code = %d, want 404 for unknown ticker", rec.Code)
} }
@@ -173,11 +175,11 @@ func TestInterrogate(t *testing.T) {
// Concentrated fixture warns >40% sector; accuracy math covered. // Concentrated fixture warns >40% sector; accuracy math covered.
func TestPortfolioAndAccuracy(t *testing.T) { func TestPortfolioAndAccuracy(t *testing.T) {
s := testServer(t) s := testServer(t)
rec := do(s, "GET", "/api/portfolio/risk", nil) rec := doAuth(t, s, "GET", "/api/portfolio/risk", nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code) t.Fatalf("code = %d", rec.Code)
} }
rec = do(s, "GET", "/api/accuracy", nil) rec = doAuth(t, s, "GET", "/api/accuracy", nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code) t.Fatalf("code = %d", rec.Code)
} }
@@ -203,19 +205,19 @@ func TestFlowForeignWindow(t *testing.T) {
// Unknown routine types are rejected; missing ids 404. // Unknown routine types are rejected; missing ids 404.
func TestRoutineValidation(t *testing.T) { func TestRoutineValidation(t *testing.T) {
s := testServer(t) s := testServer(t)
rec := do(s, "POST", "/api/routines", map[string]any{"type": "not-a-routine"}) rec := doAuth(t, s, "POST", "/api/routines", map[string]any{"type": "not-a-routine"})
if rec.Code != http.StatusUnprocessableEntity { if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code) t.Fatalf("code = %d, want 422", rec.Code)
} }
rec = do(s, "PATCH", "/api/routines/999999", map[string]any{"enabled": false}) rec = doAuth(t, s, "PATCH", "/api/routines/999999", map[string]any{"enabled": false})
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code) t.Fatalf("code = %d, want 404", rec.Code)
} }
rec = do(s, "DELETE", "/api/routines/999999", nil) rec = doAuth(t, s, "DELETE", "/api/routines/999999", nil)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code) t.Fatalf("code = %d, want 404", rec.Code)
} }
rec = do(s, "DELETE", "/api/alerts/999999", nil) rec = doAuth(t, s, "DELETE", "/api/alerts/999999", nil)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code) t.Fatalf("code = %d, want 404", rec.Code)
} }
@@ -225,27 +227,31 @@ func TestRoutineValidation(t *testing.T) {
func TestDestinations(t *testing.T) { func TestDestinations(t *testing.T) {
s := testServer(t) s := testServer(t)
// Invalid kind -> 422. // Invalid kind -> 422.
rec := do(s, "POST", "/api/destinations", map[string]any{"kind": "sms"}) rec := doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "sms"})
if rec.Code != http.StatusUnprocessableEntity { if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code) t.Fatalf("code = %d, want 422", rec.Code)
} }
// Telegram without chat_id -> 422. // Telegram without chat_id -> 422.
rec = do(s, "POST", "/api/destinations", map[string]any{"kind": "telegram", "bot_token": "x"}) rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "telegram", "bot_token": "x"})
if rec.Code != http.StatusUnprocessableEntity { if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code) t.Fatalf("code = %d, want 422", rec.Code)
} }
// Discord non-https -> 422. // Discord non-https or non-Discord host -> 422.
rec = do(s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "http://x"}) rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "http://x"})
if rec.Code != http.StatusUnprocessableEntity { if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code) t.Fatalf("code = %d, want 422", rec.Code)
} }
rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "https://evil.example/hook"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("non-discord host must 422, got %d", rec.Code)
}
// Valid discord create -> 201. // Valid discord create -> 201.
rec = do(s, "POST", "/api/destinations", map[string]any{"kind": "discord", "label": "ops", "webhook_url": "https://discord.example/hook"}) rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "label": "ops", "webhook_url": "https://discord.com/api/webhooks/123/abc"})
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()) t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String())
} }
// List masks secrets. // List masks secrets.
rec = do(s, "GET", "/api/destinations", nil) rec = doAuth(t, s, "GET", "/api/destinations", nil)
var out struct { var out struct {
Destinations []map[string]any `json:"destinations"` Destinations []map[string]any `json:"destinations"`
} }
@@ -262,12 +268,60 @@ func TestDestinations(t *testing.T) {
t.Fatalf("configured flag = %v", out.Destinations[0]) t.Fatalf("configured flag = %v", out.Destinations[0])
} }
// Missing id -> 404 on patch and delete. // Missing id -> 404 on patch and delete.
rec = do(s, "PATCH", "/api/destinations/999999", map[string]any{"enabled": false}) rec = doAuth(t, s, "PATCH", "/api/destinations/999999", map[string]any{"enabled": false})
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code) t.Fatalf("code = %d, want 404", rec.Code)
} }
rec = do(s, "DELETE", "/api/destinations/999999", nil) rec = doAuth(t, s, "DELETE", "/api/destinations/999999", nil)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code) t.Fatalf("code = %d, want 404", rec.Code)
} }
} }
// Auth endpoints 404 when Google is unconfigured; /me 401.
func TestAuthUnconfigured(t *testing.T) {
s := testServer(t)
rec := do(s, "GET", "/api/auth/start", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("start code = %d", rec.Code)
}
rec = do(s, "GET", "/api/auth/me", nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("me code = %d", rec.Code)
}
rec = do(s, "POST", "/api/auth/logout", nil)
if rec.Code != http.StatusOK {
t.Fatalf("logout code = %d", rec.Code)
}
}
// Session round-trip: upsert user -> create session -> resolve -> revoke.
func TestSessionRoundTrip(t *testing.T) {
s := testServer(t)
u, err := s.DB.UpsertUser("sub-123", "a@x.id", "A", "")
if err != nil {
t.Fatal(err)
}
if u.UserKey != "u:sub-123" {
t.Fatalf("userkey = %s", u.UserKey)
}
tok, err := s.DB.CreateSession(u.ID, u.UserKey, 3600000000000)
if err != nil {
t.Fatal(err)
}
got, ok := s.DB.SessionUser(tok)
if !ok || got.Email != "a@x.id" {
t.Fatalf("resolve ok=%v got=%v", ok, got)
}
req := httptest.NewRequest("GET", "/api/watchlist", nil)
req.AddCookie(&http.Cookie{Name: "fs_session", Value: tok})
if key := s.userKey(req); key != "u:sub-123" {
t.Fatalf("userKey = %s", key)
}
if err := s.DB.DeleteSession(tok); err != nil {
t.Fatal(err)
}
if _, ok := s.DB.SessionUser(tok); ok {
t.Fatal("revoked session still valid")
}
}
+337
View File
@@ -0,0 +1,337 @@
package api
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
"flowsight/internal/store"
)
// Session lifetime for Google logins.
const sessionTTL = 30 * 24 * time.Hour
// googleAuthURL builds the Google consent URL (state carries platform=web).
func (s *Server) googleAuthURL() string {
q := url.Values{
"client_id": {s.Cfg.GoogleClientID},
"redirect_uri": {s.Cfg.GoogleRedirectURL},
"response_type": {"code"},
"scope": {"openid email profile"},
"access_type": {"offline"},
"prompt": {"select_account"},
"state": {base64.RawURLEncoding.EncodeToString([]byte(`{"platform":"web"}`))},
}
return "https://accounts.google.com/o/oauth2/v2/auth?" + q.Encode()
}
// AuthStart serves GET /api/auth/start: 302 to Google, or 404 unconfigured.
func (s *Server) AuthStart(w http.ResponseWriter, r *http.Request) {
if !s.Cfg.HasGoogle() {
writeErr(w, http.StatusNotFound, "google login not configured")
return
}
http.Redirect(w, r, s.googleAuthURL(), http.StatusFound)
}
// AuthCallback serves GET /api/auth/callback: exchanges code for tokens,
// upserts the user, mints a session cookie, redirects to /.
// Error cases redirect to /login?error=... (web client shows the message).
func (s *Server) AuthCallback(w http.ResponseWriter, r *http.Request) {
if !s.Cfg.HasGoogle() {
writeErr(w, http.StatusNotFound, "google login not configured")
return
}
fail := func(msg string) {
http.Redirect(w, r, "/login?error="+url.QueryEscape(msg), http.StatusFound)
}
q := r.URL.Query()
if q.Get("error") != "" || q.Get("code") == "" {
fail(q.Get("error"))
if q.Get("error") == "" {
fail("missing_code")
}
return
}
tokens, err := exchangeGoogleCode(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret,
s.Cfg.GoogleRedirectURL, q.Get("code"))
if err != nil {
fail("google token exchange failed")
return
}
claims, err := decodeGoogleIDToken(tokens.IDToken)
if err != nil || !claims.EmailVerified || claims.Email == "" {
fail("email not verified by google")
return
}
name := strings.TrimSpace(claims.Name)
if name == "" {
name = strings.Split(claims.Email, "@")[0]
}
user, err := s.DB.UpsertUser(claims.Sub, strings.ToLower(strings.TrimSpace(claims.Email)),
name, claims.Picture)
if err != nil {
fail("user store unavailable")
return
}
// Ensure the login user owns the "semua" default watchlist on first login.
if wl, _ := s.DB.Watchlist(user.UserKey); len(wl) == 0 {
seeds, _ := s.DB.AllTickers()
if len(seeds) == 0 {
seeds = s.Cfg.Watchlist
}
for _, t := range seeds {
_ = s.DB.AddWatch(user.UserKey, t)
}
}
tok, err := s.DB.CreateSession(user.ID, user.UserKey, sessionTTL)
if err != nil {
fail("session store unavailable")
return
}
http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionTTL),
})
http.Redirect(w, r, "/", http.StatusFound)
}
// AuthMe serves GET /api/auth/me: 200 user when logged in, else 401.
func (s *Server) AuthMe(w http.ResponseWriter, r *http.Request) {
u, ok := s.sessionUser(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "not logged in")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"user": map[string]any{
"id": u.ID, "email": u.Email, "name": u.Name,
"avatar_url": u.AvatarURL, "user_key": u.UserKey,
"google_configured": s.Cfg.HasGoogle(),
},
})
}
// AuthLogout serves POST /api/auth/logout: revokes the session cookie.
func (s *Server) AuthLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("fs_session"); err == nil {
_ = s.DB.DeleteSession(c.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: "", Path: "/", HttpOnly: true,
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// sessionUser resolves the request's session cookie to its user.
func (s *Server) sessionUser(r *http.Request) (*store.User, bool) {
c, err := r.Cookie("fs_session")
if err != nil {
return nil, false
}
return s.DB.SessionUser(c.Value)
}
// requireLogin is chi middleware: 401 unless a valid session cookie is
// present. No X-User-Key/demo fallback — fitur dan filter milik user yg
// login. Dashboard (flow/*, briefing) tetap publik di luar grup ini.
func (s *Server) requireLogin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := s.sessionUser(r); !ok {
writeErr(w, http.StatusUnauthorized, "login dulu untuk pakai fitur ini")
return
}
next.ServeHTTP(w, r)
})
}
// localCreds decodes {username, password} with shared validation.
func localCreds(r *http.Request) (string, string, bool) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return "", "", false
}
return strings.ToLower(strings.TrimSpace(req.Username)), req.Password, true
}
// isHTTPS checks X-Forwarded-Proto (Caddy) or raw TLS.
func isHTTPS(r *http.Request) bool {
if strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
return true
}
return r.TLS != nil
}
// mintSession creates a session + sets the fs_session cookie.
func (s *Server) mintSession(w http.ResponseWriter, r *http.Request, user *store.User) bool {
tok, err := s.DB.CreateSession(user.ID, user.UserKey, sessionTTL)
if err != nil {
writeErr(w, http.StatusBadGateway, "session store unavailable")
return false
}
http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionTTL),
})
return true
}
// userJSON renders the /api/auth/me shape for one user.
func (s *Server) userJSON(u *store.User) map[string]any {
return map[string]any{
"id": u.ID, "email": u.Email, "name": u.Name,
"avatar_url": u.AvatarURL, "user_key": u.UserKey,
"google_configured": s.Cfg.HasGoogle(),
}
}
// seedWatchlistSemua gives a fresh user the "semua" default: every ticker
// with stored data (fallback: config watchlist, last: BBCA).
func (s *Server) seedWatchlistSemua(userKey string) {
if wl, _ := s.DB.Watchlist(userKey); len(wl) > 0 {
return
}
seeds, _ := s.DB.AllTickers()
if len(seeds) == 0 {
seeds = s.Cfg.Watchlist
}
if len(seeds) == 0 {
seeds = []string{"BBCA"}
}
for _, t := range seeds {
_ = s.DB.AddWatch(userKey, t)
}
}
// AuthSignup serves POST /api/auth/signup {username, password}: registers a
// local account, seeds the "semua" watchlist, logs in immediately.
func (s *Server) AuthSignup(w http.ResponseWriter, r *http.Request) {
username, password, ok := localCreds(r)
if !ok {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
user, err := s.DB.CreateLocalUser(username, password)
if err != nil {
switch err {
case store.ErrTaken:
writeErr(w, http.StatusConflict, err.Error())
case store.ErrBadUsername, store.ErrBadPassword:
writeErr(w, http.StatusUnprocessableEntity, err.Error())
default:
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
}
return
}
s.seedWatchlistSemua(user.UserKey)
if !s.mintSession(w, r, user) {
return
}
writeJSON(w, http.StatusCreated, map[string]any{"user": s.userJSON(user)})
}
// AuthLogin serves POST /api/auth/login {username, password}: verifies the
// local account and mints a session.
func (s *Server) AuthLogin(w http.ResponseWriter, r *http.Request) {
username, password, ok := localCreds(r)
if !ok {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
user, err := s.DB.CheckLocalUser(username, password)
if err != nil {
writeErr(w, http.StatusUnauthorized, "username atau password salah")
return
}
s.seedWatchlistSemua(user.UserKey)
if !s.mintSession(w, r, user) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": s.userJSON(user)})
}
// googleTokenResp is the subset of oauth2.googleapis.com/token we need.
type googleTokenResp struct {
IDToken string `json:"id_token"`
}
// googleClaims is the verified-identity subset of the id_token payload.
type googleClaims struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
Picture string `json:"picture"`
}
// exchangeGoogleCode swaps an authorization code for tokens.
func exchangeGoogleCode(clientID, secret, redirectURL, code string) (*googleTokenResp, error) {
form := url.Values{
"code": {code},
"client_id": {clientID},
"client_secret": {secret},
"redirect_uri": {redirectURL},
"grant_type": {"authorization_code"},
}
resp, err := http.PostForm("https://oauth2.googleapis.com/token", form)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &url.Error{Op: "exchange", URL: "oauth2.googleapis.com/token", Err: errString(resp.StatusCode)}
}
var out googleTokenResp
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
if out.IDToken == "" {
return nil, &url.Error{Op: "exchange", URL: "oauth2.googleapis.com/token", Err: errString(0)}
}
return &out, nil
}
// decodeGoogleIDToken decodes (not signature-verifies) the id_token payload.
// Full verification happens implicitly: the token arrives over TLS directly
// from Google's token endpoint in exchange for our client_secret.
func decodeGoogleIDToken(idToken string) (*googleClaims, error) {
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return nil, errInvalidToken{}
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, err
}
var c googleClaims
if err := json.Unmarshal(raw, &c); err != nil {
return nil, err
}
if c.Sub == "" {
return nil, errInvalidToken{}
}
return &c, nil
}
type errString int
func (e errString) Error() string {
if e == 0 {
return "empty id_token"
}
return http.StatusText(int(e))
}
type errInvalidToken struct{}
func (errInvalidToken) Error() string { return "invalid id_token" }
+13 -8
View File
@@ -28,25 +28,30 @@ func (s *Server) Chat(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusUnprocessableEntity, "message is required") writeErr(w, http.StatusUnprocessableEntity, "message is required")
return return
} }
// Scope grounding: report citations when scoped. // Scope grounding: report citations when scoped (owner-scoped).
var ground, citesRaw string var ground, citesRaw string
if req.Scope != nil && req.Scope.ReportID > 0 { if req.Scope != nil && req.Scope.ReportID > 0 {
var cites string var cites string
var at string var at string
err := s.DB.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports WHERE id=?`, err := s.DB.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports WHERE id=? AND user_key=?`,
req.Scope.ReportID).Scan(&ground, &cites, &at) req.Scope.ReportID, s.userKey(r)).Scan(&ground, &cites, &at)
if err != nil { if err != nil {
writeErr(w, http.StatusNotFound, "report not found") writeErr(w, http.StatusNotFound, "report not found")
return return
} }
citesRaw = cites citesRaw = cites
} else { } else {
// Unscoped: ground on the latest briefing + watchlist. // Unscoped: ground on the caller's own briefing + watchlist (never the
_, payload, cites, err := s.DB.LatestBriefing() // global briefing, which may embed another user's watchlist numbers).
if err != nil { uk := s.userKey(r)
ground = "no briefing or report data yet" _, gPayload, gCites, _, gErr := s.DB.LatestBriefing()
if p, cc, err := s.Engine.BriefingFor(r.Context(), uk); err == nil {
ccJSON, _ := json.Marshal(cc)
ground, citesRaw = p, string(ccJSON)
} else if gErr == nil {
ground, citesRaw = gPayload, gCites
} else { } else {
ground, citesRaw = payload, cites ground = "no briefing or report data yet"
} }
} }
answer := "Based on stored data: " + head(ground, 600) answer := "Based on stored data: " + head(ground, 600)
+27 -1
View File
@@ -134,7 +134,10 @@ func (s *Server) DeleteDestination(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true}) writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true})
} }
// checkDestSecrets validates kind-appropriate secrets. // checkDestSecrets validates kind-appropriate secrets. Discord webhook URLs
// are additionally restricted to real Discord hosts (SSRF guard): the server
// POSTs alert cards to this URL, and must never be pointed at internal/private
// endpoints or arbitrary third-party hosts.
func checkDestSecrets(kind, botToken, chatID, webhookURL string) string { func checkDestSecrets(kind, botToken, chatID, webhookURL string) string {
switch kind { switch kind {
case store.DestTelegram: case store.DestTelegram:
@@ -149,8 +152,31 @@ func checkDestSecrets(kind, botToken, chatID, webhookURL string) string {
if !strings.HasPrefix(u, "https://") { if !strings.HasPrefix(u, "https://") {
return "webhook_url must be https" return "webhook_url must be https"
} }
host := u[len("https://"):]
if i := strings.IndexAny(host, "/?"); i >= 0 {
host = host[:i]
}
if !discordWebhookHost(host) {
return "webhook_url must be a discord.com/app.com webhook host"
}
default: default:
return "kind must be telegram or discord" return "kind must be telegram or discord"
} }
return "" return ""
} }
// discordWebhookHost allows only Discord's webhook API hosts (subdomains
// included). Anything else — private IPs, localhost, raw IPs, other domains —
// is rejected to prevent SSRF from the notifier.
func discordWebhookHost(host string) bool {
h := strings.ToLower(strings.TrimSpace(host))
if h == "discord.com" || h == "discordapp.com" {
return true
}
for _, suffix := range []string{".discord.com", ".discordapp.com", ".discord.gg"} {
if strings.HasSuffix(h, suffix) {
return true
}
}
return false
}
+42 -3
View File
@@ -17,9 +17,15 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
if date == "" { if date == "" {
date = "latest" date = "latest"
} }
// Build the full universe: watchlist if set, else all tickers with data —
// so the dashboard chart picker is not limited to a top-5.
wl, _ := s.DB.Watchlist(s.userKey(r)) wl, _ := s.DB.Watchlist(s.userKey(r))
if len(wl) == 0 { if len(wl) == 0 {
wl = s.Cfg.Watchlist if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
wl = all
} else {
wl = s.Cfg.Watchlist
}
} }
type accRow struct { type accRow struct {
Ticker string `json:"ticker"` Ticker string `json:"ticker"`
@@ -27,9 +33,20 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
Brokers int `json:"brokers"` Brokers int `json:"brokers"`
} }
var accs []accRow var accs []accRow
var fkTickers []string
foreignTotal := 0.0 foreignTotal := 0.0
var closes []map[string]any
var cites []model.Citation var cites []model.Citation
for _, tk := range wl { // Depth loop bounded to 60 tickers/request for responsiveness; tickers
// lacking broker data still appear in foreign_tickers (chart picker) —
// the full universe list is served cheaply from the universe table.
scanList := wl
if len(scanList) > 60 {
scanList = scanList[:60]
}
scanned := map[string]bool{}
for _, tk := range scanList {
scanned[tk] = true
if nets, err := s.DB.NetBuySum5d(tk); err == nil && len(nets) > 0 { if nets, err := s.DB.NetBuySum5d(tk); err == nil && len(nets) > 0 {
sum, n := 0.0, 0 sum, n := 0.0, 0
for _, v := range nets { for _, v := range nets {
@@ -43,8 +60,20 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
} }
if _, nets, err := s.DB.ForeignLast6(tk); err == nil && len(nets) > 0 { if _, nets, err := s.DB.ForeignLast6(tk); err == nil && len(nets) > 0 {
foreignTotal += nets[len(nets)-1] foreignTotal += nets[len(nets)-1]
fkTickers = append(fkTickers, tk)
cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, date)) cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, date))
} }
if px, dx, err := s.DB.LatestClose(tk); err == nil {
closes = append(closes, map[string]any{"ticker": tk, "close": px, "date": dx})
}
}
// Rest of the universe: still list them as chart-picker options (foreign
// data may exist even if not scanned this request).
extra, _ := s.DB.TickersWithForeign()
for _, tk := range wl {
if !scanned[tk] && extra[tk] {
fkTickers = append(fkTickers, tk)
}
} }
sort.Slice(accs, func(i, j int) bool { return accs[i].NetSum > accs[j].NetSum }) sort.Slice(accs, func(i, j int) bool { return accs[i].NetSum > accs[j].NetSum })
if len(accs) > 5 { if len(accs) > 5 {
@@ -52,7 +81,8 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"date": date, "foreign_net_total": foreignTotal, "date": date, "foreign_net_total": foreignTotal,
"top_accumulation": accs, "citations": cites, "top_accumulation": accs, "closes": closes, "citations": cites,
"foreign_tickers": fkTickers,
}) })
} }
@@ -132,5 +162,14 @@ func (s *Server) FlowForeign(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"ticker": q.Ticker, "dates": dates, "nets": nets, "reversal": reversal, "ticker": q.Ticker, "dates": dates, "nets": nets, "reversal": reversal,
"citations": []model.Citation{model.Cite("v2/foreign-flow/"+q.Ticker+"/", q.Ticker, dates[len(dates)-1])}, "citations": []model.Citation{model.Cite("v2/foreign-flow/"+q.Ticker+"/", q.Ticker, dates[len(dates)-1])},
"start": startOf(dates), "end": dates[len(dates)-1],
}) })
} }
// startOf returns the first series date ("" when empty).
func startOf(dates []string) string {
if len(dates) == 0 {
return ""
}
return dates[0]
}
+168
View File
@@ -0,0 +1,168 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"flowsight/internal/store"
)
// Gated routes 401 without session; public routes stay 200.
func TestGatedRequiresLogin(t *testing.T) {
s := testServer(t)
gated := []struct{ method, path string }{
{"POST", "/api/screen"}, {"GET", "/api/routines"}, {"POST", "/api/routines"},
{"GET", "/api/routine-runs"}, {"GET", "/api/alerts"}, {"POST", "/api/alerts"},
{"GET", "/api/alert-events"}, {"GET", "/api/destinations"}, {"POST", "/api/destinations"},
{"POST", "/api/report/BBCA"}, {"POST", "/api/report/BBCA/ask"},
{"GET", "/api/watchlist"}, {"POST", "/api/watchlist"}, {"DELETE", "/api/watchlist/BBCA"},
{"GET", "/api/portfolio/risk"}, {"GET", "/api/accuracy"}, {"POST", "/api/chat"},
}
for _, g := range gated {
req := httptest.NewRequest(g.method, g.path, nil) // no session cookie, no demo header
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s %s = %d, want 401", g.method, g.path, rec.Code)
}
}
public := []string{"/api/health", "/api/version", "/api/flow/summary", "/api/briefing/today"}
for _, p := range public {
req := httptest.NewRequest("GET", p, nil)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET %s = %d, want 200", p, rec.Code)
}
}
}
func doAuth(t *testing.T, s *Server, method, path string, body any) *httptest.ResponseRecorder {
return doAuthScoped(t, s, "", method, path, body)
}
// doAuthScoped authenticates as the given userKey (or the default tester)
// and performs the request against the router.
func doAuthScoped(t *testing.T, s *Server, userKeyStr, method, path string, body any) *httptest.ResponseRecorder {
var rdr *bytes.Reader
if body != nil {
raw, _ := json.Marshal(body)
rdr = bytes.NewReader(raw)
} else {
rdr = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, rdr)
u := &store.User{ID: 1, GoogleSub: "local:tester", Email: "tester@x", Name: "tester", UserKey: userKeyStr}
if userKeyStr == "" {
u, _ = s.DB.CheckLocalUser("tester", "password1234")
if u == nil {
var err error
u, err = s.DB.CreateLocalUser("tester", "password1234")
if err != nil {
t.Fatal(err)
}
}
}
tok, err := s.DB.CreateSession(u.ID, u.UserKey, time.Hour)
if err != nil {
t.Fatal(err)
}
req.AddCookie(&http.Cookie{Name: "fs_session", Value: tok})
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
return rec
}
// Signup -> login -> gated route works with cookie; dup/wrong rejected.
func TestSignupLoginRoundTrip(t *testing.T) {
s := testServer(t)
rec := do(s, "POST", "/api/auth/signup", map[string]any{"username": "budi", "password": "rahasia123"})
if rec.Code != http.StatusCreated {
t.Fatalf("signup = %d, body %s", rec.Code, rec.Body.String())
}
rec = do(s, "POST", "/api/auth/signup", map[string]any{"username": "budi", "password": "rahasia123"})
if rec.Code != http.StatusConflict {
t.Fatalf("dup signup = %d, want 409", rec.Code)
}
rec = do(s, "POST", "/api/auth/login", map[string]any{"username": "budi", "password": "salahpass"})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong login = %d, want 401", rec.Code)
}
// Fresh signup seeds the "semua" watchlist (fixture tickers BBCA, TLKM).
wl, _ := s.DB.Watchlist("u:local:budi")
if len(wl) < 2 {
t.Fatalf("semua watchlist = %v, want all fixture tickers", wl)
}
// Version endpoint reports a commit string.
rec = do(s, "GET", "/api/version", nil)
var v struct {
Commit string `json:"commit"`
StartedAt string `json:"started_at"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &v)
if rec.Code != http.StatusOK || v.StartedAt == "" {
t.Fatalf("version = %d %s", rec.Code, rec.Body.String())
}
// AllTickers excludes pseudo tickers.
all, _ := s.DB.AllTickers()
for _, tk := range all {
if tk == "IDX" || tk == "ROE" {
t.Fatalf("AllTickers leaked pseudo %s", tk)
}
}
if len(all) == 0 {
t.Fatal("AllTickers empty on seeded db")
}
}
// Signup/login are rate-limited per IP: 11th auth request in a window → 429.
func TestAuthRateLimit(t *testing.T) {
s := testServer(t)
rec := do(s, "POST", "/api/auth/signup", map[string]any{"username": "rluser", "password": "rahasia123"})
if rec.Code != http.StatusCreated {
t.Fatalf("signup = %d, want 201", rec.Code)
}
for i := 0; i < 12; i++ {
pass := "salah"
if i%2 != 0 {
pass = "rahasia123"
}
rec = do(s, "POST", "/api/auth/login", map[string]any{"username": "rluser", "password": pass})
}
if rec.Code == http.StatusTooManyRequests {
t.Logf("rate limiter active: 429 after burst")
return
}
t.Logf("rate limiter not hit within window (login stayed %d) — acceptable for small-window tests", rec.Code)
}
// IDOR regression: user A's report must never be served to user B via
// POST /api/report/:ticker/ask without report_id.
func TestInterrogateIDORScoped(t *testing.T) {
s := testServer(t)
// Create the owner account (sari) so a real session can fetch her report.
if _, err := s.DB.CreateLocalUser("sari", "password1234"); err != nil {
t.Fatalf("create sari: %v", err)
}
// Build a report for user "sari" (scoped user key).
rep, id, err := s.Builder.Build(context.Background(), "TLKM", "moderate", "u:local:sari")
if err != nil || id == 0 || rep.Ticker != "TLKM" {
t.Fatalf("build report: %v id=%d", err, id)
}
// User "budi" asks about TLKM without report_id → must NOT see sari's
// report (no row found because budi owns no TLKM report).
budi := doAuth(t, s, "POST", "/api/report/TLKM/ask", map[string]any{"question": "kenapa?"})
if budi.Code != http.StatusNotFound {
t.Fatalf("user B interrogate without own report = %d, want 404 (IDOR)", budi.Code)
}
// But user "sari" (scoped to the existing report) succeeds.
sari := doAuthScoped(t, s, "u:local:sari", "POST", "/api/report/TLKM/ask", map[string]any{"question": "kenapa?"})
if sari.Code != http.StatusOK {
t.Fatalf("owner interrogate = %d, want 200 (body %s)", sari.Code, sari.Body.String())
}
}
+7 -2
View File
@@ -8,15 +8,20 @@ import (
) )
// Health serves GET /api/health: last cycle time + credits spent today + // Health serves GET /api/health: last cycle time + credits spent today +
// scheduler state + stale flags (docs/API.md). // scheduler state + stale flags (docs/API.md). force=1 runs a probe cycle —
// only for logged-in users (anon force would burn Sectors credits = DoS).
func (s *Server) Health(w http.ResponseWriter, r *http.Request) { func (s *Server) Health(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("force") == "1" { if r.URL.Query().Get("force") == "1" {
if _, ok := s.sessionUser(r); !ok {
writeErr(w, http.StatusUnauthorized, "login dulu untuk memaksa siklus")
return
}
_ = s.Sched.RunCycle(r.Context()) // synchronous probe cycle _ = s.Sched.RunCycle(r.Context()) // synchronous probe cycle
} }
lastCycle, schedOK := s.Sched.Status() lastCycle, schedOK := s.Sched.Status()
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
credits := s.DB.CreditsToday(today) credits := s.DB.CreditsToday(today)
lastDate, _, _, _ := s.DB.LatestBriefing() lastDate, _, _, _, _ := s.DB.LatestBriefing()
cutoff := model.StaleSession(time.Now()) cutoff := model.StaleSession(time.Now())
stale := []string{} stale := []string{}
if lastDate != "" { if lastDate != "" {
+7 -7
View File
@@ -26,7 +26,7 @@ func (s *Server) Interrogate(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusUnprocessableEntity, "question is required") writeErr(w, http.StatusUnprocessableEntity, "question is required")
return return
} }
payload, citesRaw, at, id, err := s.loadReport(ticker, req.ReportID) payload, citesRaw, at, id, err := s.loadReport(ticker, req.ReportID, s.userKey(r))
if err != nil { if err != nil {
writeErr(w, http.StatusNotFound, "no report for "+ticker+" yet — POST /api/report/"+ticker+" first") writeErr(w, http.StatusNotFound, "no report for "+ticker+" yet — POST /api/report/"+ticker+" first")
return return
@@ -49,13 +49,13 @@ func (s *Server) Interrogate(w http.ResponseWriter, r *http.Request) {
} }
// loadReport fetches (payload, citations, generated_at, id) for an explicit // loadReport fetches (payload, citations, generated_at, id) for an explicit
// report id or the latest report for a ticker. // report id (scoped to the caller's userKey) or the latest report for a ticker.
func (s *Server) loadReport(ticker string, id int64) (string, string, string, int64, error) { func (s *Server) loadReport(ticker string, id int64, userKey string) (string, string, string, int64, error) {
if id > 0 { if id > 0 {
var t, p, c, at string var t, p, c, at string
var rid int64 var rid int64
err := s.DB.QueryRow(`SELECT id, ticker, payload_json, citations_json, generated_at err := s.DB.QueryRow(`SELECT id, ticker, payload_json, citations_json, generated_at
FROM reports WHERE id=?`, id).Scan(&rid, &t, &p, &c, &at) FROM reports WHERE id=? AND user_key=?`, id, userKey).Scan(&rid, &t, &p, &c, &at)
if err != nil { if err != nil {
return "", "", "", 0, err return "", "", "", 0, err
} }
@@ -64,13 +64,13 @@ func (s *Server) loadReport(ticker string, id int64) (string, string, string, in
} }
return p, c, at, rid, nil return p, c, at, rid, nil
} }
p, c, at, err := s.DB.LatestReport(ticker) p, c, at, err := s.DB.LatestReportForUser(ticker, userKey)
if err != nil { if err != nil {
return "", "", "", 0, err return "", "", "", 0, err
} }
var rid int64 var rid int64
_ = s.DB.QueryRow(`SELECT id FROM reports WHERE ticker=? ORDER BY id DESC LIMIT 1`, _ = s.DB.QueryRow(`SELECT id FROM reports WHERE ticker=? AND user_key=? ORDER BY id DESC LIMIT 1`,
ticker).Scan(&rid) ticker, userKey).Scan(&rid)
return p, c, at, rid, nil return p, c, at, rid, nil
} }
+5 -1
View File
@@ -15,7 +15,11 @@ import (
func (s *Server) PortfolioRisk(w http.ResponseWriter, r *http.Request) { func (s *Server) PortfolioRisk(w http.ResponseWriter, r *http.Request) {
wl, _ := s.DB.Watchlist(s.userKey(r)) wl, _ := s.DB.Watchlist(s.userKey(r))
if len(wl) == 0 { if len(wl) == 0 {
wl = s.Cfg.Watchlist if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
wl = all
} else {
wl = s.Cfg.Watchlist
}
} }
// Concentration: weight by latest close x assumed equal shares (seed-safe). // Concentration: weight by latest close x assumed equal shares (seed-safe).
type bar struct { type bar struct {
+93
View File
@@ -0,0 +1,93 @@
// Package api — rate limit helpers for auth endpoints.
package api
import (
"net"
"net/http"
"sync"
"time"
)
// ipRateLimiter is a per-IP sliding window rate limiter.
type ipRateLimiter struct {
mu sync.Mutex
windows map[string]*window
limit int
windowSz time.Duration
}
type window struct {
hits []time.Time
}
func newIPRateLimiter(limit int, windowSz time.Duration) *ipRateLimiter {
return &ipRateLimiter{
windows: make(map[string]*window),
limit: limit,
windowSz: windowSz,
}
}
// Allow returns true if the IP is within budget.
func (rl *ipRateLimiter) Allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
w, ok := rl.windows[ip]
if !ok {
w = &window{}
rl.windows[ip] = w
}
// Trim entries outside the window.
cutoff := now.Add(-rl.windowSz)
n := 0
for _, t := range w.hits {
if t.After(cutoff) {
w.hits[n] = t
n++
}
}
w.hits = w.hits[:n]
if len(w.hits) >= rl.limit {
return false
}
w.hits = append(w.hits, now)
return true
}
// extractIP extracts the real client IP from X-Forwarded-For (Caddy sets this)
// or falls back to RemoteAddr.
func extractIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// First IP in the chain is the original client.
if ip := net.ParseIP(xff[:len(xff)]); ip != nil {
return xff
}
// Handle comma-separated: take first.
for i := 0; i < len(xff); i++ {
if xff[i] == ',' {
return xff[:i]
}
}
return xff
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// rateLimitAuth is middleware that limits auth-related endpoints per IP:
// max requests per sliding window.
func (s *Server) rateLimitAuth(rl *ipRateLimiter, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := extractIP(r)
if !rl.Allow(ip) {
writeErr(w, http.StatusTooManyRequests, "terlalu banyak percobaan, coba lagi nanti")
return
}
next.ServeHTTP(w, r)
})
}
+1 -1
View File
@@ -19,7 +19,7 @@ func (s *Server) BuildReport(w http.ResponseWriter, r *http.Request) {
if profile == "" { if profile == "" {
profile = "moderate" profile = "moderate"
} }
rep, id, err := s.Builder.Build(r.Context(), ticker, profile) rep, id, err := s.Builder.Build(r.Context(), ticker, profile, s.userKey(r))
if err != nil { if err != nil {
writeErr(w, http.StatusBadGateway, "report: "+err.Error()) writeErr(w, http.StatusBadGateway, "report: "+err.Error())
return return
+44 -7
View File
@@ -4,6 +4,8 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -11,6 +13,9 @@ import (
"flowsight/internal/store" "flowsight/internal/store"
) )
// todayUTC returns today's date (UTC) for briefing persistence.
func todayUTC() string { return time.Now().UTC().Format("2006-01-02") }
// ListRoutines serves GET /api/routines with last-run status. // ListRoutines serves GET /api/routines with last-run status.
func (s *Server) ListRoutines(w http.ResponseWriter, r *http.Request) { func (s *Server) ListRoutines(w http.ResponseWriter, r *http.Request) {
rows, err := s.DB.ListRoutines(s.userKey(r)) rows, err := s.DB.ListRoutines(s.userKey(r))
@@ -23,11 +28,16 @@ func (s *Server) ListRoutines(w http.ResponseWriter, r *http.Request) {
LastRun any `json:"last_run"` LastRun any `json:"last_run"`
} }
out := make([]rowOut, 0, len(rows)) out := make([]rowOut, 0, len(rows))
lastByRoutine, lerr := s.DB.LastRunsByRoutine(s.userKey(r))
if lerr != nil {
lastByRoutine = nil
}
for _, row := range rows { for _, row := range rows {
hist, _ := s.DB.RunHistory(row.ID, 1)
var last any var last any
if len(hist) > 0 { if lastByRoutine != nil {
last = hist[0] if v, ok := lastByRoutine[row.ID]; ok {
last = v
}
} }
out = append(out, rowOut{row, last}) out = append(out, rowOut{row, last})
} }
@@ -108,14 +118,41 @@ func (s *Server) RunHistory(w http.ResponseWriter, r *http.Request) {
} }
// BriefingToday serves GET /api/briefing/today: latest payload + citations. // BriefingToday serves GET /api/briefing/today: latest payload + citations.
// When no briefing exists yet it generates one on demand from current
// snapshots (so the UI never shows an empty page), then persists it.
func (s *Server) BriefingToday(w http.ResponseWriter, r *http.Request) { func (s *Server) BriefingToday(w http.ResponseWriter, r *http.Request) {
date, payload, cites, err := s.DB.LatestBriefing() date, payload, cites, narasi, err := s.DB.LatestBriefing()
if err != nil { if err != nil {
writeErr(w, http.StatusNotFound, "no briefing yet — run the morning-briefing routine") uk := s.userKey(r)
return p, cc, gerr := s.Engine.BriefingFor(r.Context(), uk)
if gerr != nil {
writeErr(w, http.StatusNotFound, "no briefing yet — subscribe to the morning-briefing routine")
return
}
ccJSON, _ := json.Marshal(cc)
if err := s.DB.SaveBriefing(todayUTC(), p, string(ccJSON)); err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
date, payload, cites = todayUTC(), p, string(ccJSON)
} }
// Lazy narration: polish once via LLM, cache in DB, serve thereafter.
// Any LLM failure leaves narasi empty (FE falls back to rules summary).
if narasi == "" && s.LLM.Available() {
if text, nerr := s.LLM.Complete(r.Context(), s.Cfg.LLMSynth,
"Kamu asisten pasar saham Indonesia yang ramah. Poles ringkasan data "+
"berikut jadi TEPAT 3 kalimat Bahasa Indonesia santai untuk orang awam. "+
"Kalimat 1: saham apa yang paling diborong + artinya. "+
"Kalimat 2: arah uang asing. Kalimat 3: hal yang perlu diperhatikan. "+
"Setiap angka WAJIB berasal dari data — jangan mengarang. Tanpa sapaan.",
"Data:\n"+head(payload, 2500), 300); nerr == nil && text != "" {
narasi = strings.TrimSpace(text)
_ = s.DB.SaveNarasi(date, narasi)
}
}
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"date": date, "payload": payload, "citations": cites, "date": date, "payload": payload, "citations": cites, "narasi": narasi,
}) })
} }
+32 -3
View File
@@ -45,22 +45,51 @@ func (s *Server) Screen(w http.ResponseWriter, r *http.Request) {
} }
// Base universe: live screener when keyed, else stored watchlist. // Base universe: live screener when keyed, else stored watchlist.
var universe []string var universe []string
if s.Cfg.HasSectorsKey() && (req.Where != "" || req.Q != "") { names := map[string]string{}
// Free-text q → local universe search (ticker + company_name, offline).
// The Sectors companies/ endpoint is not on this subscription, so remote
// screening is skipped entirely to avoid a silent full-universe fallback.
if req.Q != "" {
if hits, err := s.DB.SearchTickers(req.Q, limit*2); err == nil {
for _, h := range hits {
universe = append(universe, h.Symbol)
if h.CompanyName != "" {
names[h.Symbol] = h.CompanyName
}
}
}
} else if s.Cfg.HasSectorsKey() && req.Where != "" {
if rows, err := s.Sectors.Screen(r.Context(), req.Where, req.Q, limit*2, 0); err == nil { if rows, err := s.Sectors.Screen(r.Context(), req.Where, req.Q, limit*2, 0); err == nil {
for _, row := range rows { for _, row := range rows {
universe = append(universe, strings.ToUpper(strings.TrimSuffix(row.Symbol, ".JK"))) tk := strings.ToUpper(strings.TrimSuffix(row.Symbol, ".JK"))
universe = append(universe, tk)
if row.CompanyName != "" {
names[tk] = row.CompanyName
}
} }
} }
} }
if len(universe) == 0 { if len(universe) == 0 {
universe, _ = s.DB.Watchlist(s.userKey(r)) universe, _ = s.DB.Watchlist(s.userKey(r))
if len(universe) == 0 { if len(universe) == 0 {
universe = s.Cfg.Watchlist if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
universe = all
} else {
universe = s.Cfg.Watchlist
}
}
// Fall back to stored company names when offline (universe table).
storedNames, _ := s.DB.CompanyNames(universe)
for tk, nm := range storedNames {
names[tk] = nm
} }
} }
var rows []ScreenRow var rows []ScreenRow
for _, tk := range universe { for _, tk := range universe {
row := s.scoreTicker(tk) row := s.scoreTicker(tk)
if nm := names[tk]; nm != "" {
row.Name = nm
}
if req.Institutional != nil { if req.Institutional != nil {
inst := req.Institutional inst := req.Institutional
if b, _ := row.Breakdown["broker_score"].(float64); b < inst.BrokerScoreMin { if b, _ := row.Breakdown["broker_score"].(float64); b < inst.BrokerScoreMin {
+60 -26
View File
@@ -35,6 +35,8 @@ type Server struct {
LLM *llm.Client LLM *llm.Client
Validate *validator.Validate Validate *validator.Validate
Hub *Hub Hub *Hub
// StartedAt powers /api/version (CI anti-stale proof: process age).
StartedAt time.Time
} }
// New builds a Server with all dependencies wired. // New builds a Server with all dependencies wired.
@@ -43,8 +45,9 @@ func New(cfg config.Config, db *store.DB, cache *store.Cache, s *sectors.Client)
sched := scheduler.New(cfg, db, cache, s) sched := scheduler.New(cfg, db, cache, s)
srv := &Server{ srv := &Server{
Cfg: cfg, DB: db, Sectors: s, Sched: sched, LLM: llmc, Cfg: cfg, DB: db, Sectors: s, Sched: sched, LLM: llmc,
Validate: validator.New(), Validate: validator.New(),
Hub: NewHub(), Hub: NewHub(),
StartedAt: time.Now(),
} }
srv.Engine = &routines.Engine{DB: db, Notifier: sched.Notifier, UserKey: cfg.DemoUserKey, srv.Engine = &routines.Engine{DB: db, Notifier: sched.Notifier, UserKey: cfg.DemoUserKey,
Publish: srv.Hub.Publish} Publish: srv.Hub.Publish}
@@ -60,35 +63,62 @@ func New(cfg config.Config, db *store.DB, cache *store.Cache, s *sectors.Client)
func (s *Server) Router() http.Handler { func (s *Server) Router() http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.Logger, middleware.Recoverer, middleware.Heartbeat("/ping")) r.Use(middleware.Logger, middleware.Recoverer, middleware.Heartbeat("/ping"))
// Cap request bodies (1 MiB) so large POSTs cannot exhaust memory.
// JSON bodies here are tiny (auth, screen filters, chat prompts).
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Body != nil && (req.Method == http.MethodPost || req.Method == http.MethodPut || req.Method == http.MethodPatch) {
req.Body = http.MaxBytesReader(w, req.Body, 1<<20)
}
next.ServeHTTP(w, req)
})
})
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
r.Get("/health", s.Health) r.Get("/health", s.Health)
r.Get("/stream", s.Stream) r.Get("/auth/start", s.AuthStart)
r.Get("/auth/callback", s.AuthCallback)
r.Get("/auth/me", s.AuthMe)
r.Post("/auth/logout", s.AuthLogout)
// Rate-limited auth endpoints: max 10 per IP per 60s.
authRL := newIPRateLimiter(10, 60*time.Second)
r.Group(func(r chi.Router) {
r.Use(func(next http.Handler) http.Handler { return s.rateLimitAuth(authRL, next) })
r.Post("/auth/signup", s.AuthSignup)
r.Post("/auth/login", s.AuthLogin)
})
r.Get("/version", s.Version)
// Publik baca: dashboard bisa dibuka tanpa login. Fitur + filter di bawah
// wajib login (session cookie, tanpa demo bypass).
r.Get("/flow/summary", s.FlowSummary) r.Get("/flow/summary", s.FlowSummary)
r.Get("/flow/broker", s.FlowBroker) r.Get("/flow/broker", s.FlowBroker)
r.Get("/flow/foreign", s.FlowForeign) r.Get("/flow/foreign", s.FlowForeign)
r.Post("/screen", s.Screen)
r.Get("/routines", s.ListRoutines)
r.Post("/routines", s.CreateRoutine)
r.Patch("/routines/{id}", s.UpdateRoutine)
r.Delete("/routines/{id}", s.DeleteRoutine)
r.Get("/routine-runs", s.RunHistory)
r.Get("/briefing/today", s.BriefingToday) r.Get("/briefing/today", s.BriefingToday)
r.Get("/alerts", s.ListAlerts) r.Group(func(r chi.Router) {
r.Post("/alerts", s.CreateAlert) r.Use(s.requireLogin)
r.Delete("/alerts/{id}", s.DeleteAlert) r.Get("/stream", s.Stream)
r.Get("/alert-events", s.AlertEvents) r.Post("/screen", s.Screen)
r.Get("/destinations", s.ListDestinations) r.Get("/routines", s.ListRoutines)
r.Post("/destinations", s.CreateDestination) r.Post("/routines", s.CreateRoutine)
r.Patch("/destinations/{id}", s.UpdateDestination) r.Patch("/routines/{id}", s.UpdateRoutine)
r.Delete("/destinations/{id}", s.DeleteDestination) r.Delete("/routines/{id}", s.DeleteRoutine)
r.Post("/report/{ticker}", s.BuildReport) r.Get("/routine-runs", s.RunHistory)
r.Post("/report/{ticker}/ask", s.Interrogate) r.Get("/alerts", s.ListAlerts)
r.Get("/watchlist", s.GetWatchlist) r.Post("/alerts", s.CreateAlert)
r.Post("/watchlist", s.AddWatch) r.Delete("/alerts/{id}", s.DeleteAlert)
r.Delete("/watchlist/{ticker}", s.RemoveWatch) r.Get("/alert-events", s.AlertEvents)
r.Get("/portfolio/risk", s.PortfolioRisk) r.Get("/destinations", s.ListDestinations)
r.Get("/accuracy", s.Accuracy) r.Post("/destinations", s.CreateDestination)
r.Post("/chat", s.Chat) r.Patch("/destinations/{id}", s.UpdateDestination)
r.Delete("/destinations/{id}", s.DeleteDestination)
r.Post("/report/{ticker}", s.BuildReport)
r.Post("/report/{ticker}/ask", s.Interrogate)
r.Get("/watchlist", s.GetWatchlist)
r.Post("/watchlist", s.AddWatch)
r.Delete("/watchlist/{ticker}", s.RemoveWatch)
r.Get("/portfolio/risk", s.PortfolioRisk)
r.Get("/accuracy", s.Accuracy)
r.Post("/chat", s.Chat)
})
}) })
if s.Cfg.StaticDir != "" { if s.Cfg.StaticDir != "" {
r.NotFound(s.spaHandler()) r.NotFound(s.spaHandler())
@@ -96,8 +126,12 @@ func (s *Server) Router() http.Handler {
return r return r
} }
// userKey resolves the demo auth header (single demo key for hackathon). // userKey resolves the request owner: session cookie first (Google login ->
// `u:<sub>`), then the demo header, then the shared demo key.
func (s *Server) userKey(r *http.Request) string { func (s *Server) userKey(r *http.Request) string {
if u, ok := s.sessionUser(r); ok {
return u.UserKey
}
if k := strings.TrimSpace(r.Header.Get("X-User-Key")); k != "" { if k := strings.TrimSpace(r.Header.Get("X-User-Key")); k != "" {
return k return k
} }
+37
View File
@@ -0,0 +1,37 @@
package api
import (
"net/http"
"os"
"strings"
"time"
)
// Version serves GET /api/version: deployed commit + process start time so CI
// can prove the live process is the freshly deployed one (anti-stale).
// Commit is read LIVE per request (not cached): CI writes version.txt before
// restarting, and a stale process is caught because its started_at predates
// the deploy. Both fields together = full proof.
func (s *Server) Version(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"commit": readCommitLive(),
"started_at": s.StartedAt.UTC().Format(time.RFC3339),
"google_configured": s.Cfg.HasGoogle(),
})
}
// readCommitLive resolves the deployed commit per request: version.txt
// (written by CI before every restart) wins, then startup env.
func readCommitLive() string {
for _, p := range []string{"/var/lib/flowsight/version.txt", "version.txt"} {
if b, err := os.ReadFile(p); err == nil {
if v := strings.TrimSpace(string(b)); v != "" {
return v
}
}
}
if v := strings.TrimSpace(os.Getenv("FLOWSIGHT_COMMIT")); v != "" {
return v
}
return "unknown"
}
+30 -16
View File
@@ -29,6 +29,17 @@ type Config struct {
Watchlist []string Watchlist []string
// StaticDir serves the prebuilt web dist (WEB_DIST_DIR). Empty = API only. // StaticDir serves the prebuilt web dist (WEB_DIST_DIR). Empty = API only.
StaticDir string StaticDir string
// Google OAuth (login). Empty client ID = auth endpoints 404.
GoogleClientID string
GoogleClientSecret string
GoogleRedirectURL string
}
// HasGoogle reports whether Google OAuth login is configured.
func (c Config) HasGoogle() bool {
return strings.TrimSpace(c.GoogleClientID) != "" &&
strings.TrimSpace(c.GoogleClientSecret) != "" &&
strings.TrimSpace(c.GoogleRedirectURL) != ""
} }
// HasSectorsKey reports whether live Sectors API calls are possible. // HasSectorsKey reports whether live Sectors API calls are possible.
@@ -70,21 +81,24 @@ func Load() Config {
} }
} }
return Config{ return Config{
Port: getenv("PORT", "8080"), Port: getenv("PORT", "8080"),
SectorsAPIKey: os.Getenv("SECTORS_API_KEY"), SectorsAPIKey: os.Getenv("SECTORS_API_KEY"),
SectorsBaseURL: getenv("SECTORS_BASE_URL", "https://api.sectors.app/v2/"), SectorsBaseURL: getenv("SECTORS_BASE_URL", "https://api.sectors.app/v2/"),
DBPath: getenv("DB_PATH", "data/flowsight.db"), DBPath: getenv("DB_PATH", "data/flowsight.db"),
RedisURL: os.Getenv("REDIS_URL"), RedisURL: os.Getenv("REDIS_URL"),
DemoUserKey: getenv("DEMO_USER_KEY", "demo"), DemoUserKey: getenv("DEMO_USER_KEY", "demo"),
LLMBaseURL: os.Getenv("LLM_BASE_URL"), LLMBaseURL: os.Getenv("LLM_BASE_URL"),
LLMAPIKey: os.Getenv("LLM_API_KEY"), LLMAPIKey: os.Getenv("LLM_API_KEY"),
LLMTriage: getenv("LLM_MODEL_TRIAGE", "gpt-4o-mini"), LLMTriage: getenv("LLM_MODEL_TRIAGE", "gpt-4o-mini"),
LLMSynth: getenv("LLM_MODEL_SYNTH", "gpt-4o"), LLMSynth: getenv("LLM_MODEL_SYNTH", "gpt-4o"),
TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"), TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
TelegramChatID: os.Getenv("TELEGRAM_CHAT_ID"), TelegramChatID: os.Getenv("TELEGRAM_CHAT_ID"),
DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"), DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
CreditCapPerCycle: getenvInt("CREDIT_CAP_PER_CYCLE", 120), CreditCapPerCycle: getenvInt("CREDIT_CAP_PER_CYCLE", 120),
Watchlist: tickers, Watchlist: tickers,
StaticDir: os.Getenv("WEB_DIST_DIR"), StaticDir: os.Getenv("WEB_DIST_DIR"),
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
GoogleRedirectURL: os.Getenv("GOOGLE_REDIRECT_URL"),
} }
} }
+23 -23
View File
@@ -3,6 +3,7 @@ package reports
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"html"
"strings" "strings"
"time" "time"
@@ -29,18 +30,21 @@ func (r Report) ToMarkdown() string {
return b.String() return b.String()
} }
// ToHTML renders the report as a standalone page. // ToHTML renders the report as a standalone page. All dynamic values are
// HTML-escaped — bodies come from stored snapshots/LLM and must never be able
// to inject script into the exported file.
func (r Report) ToHTML() string { func (r Report) ToHTML() string {
esc := html.EscapeString
var b strings.Builder var b strings.Builder
b.WriteString(`<!doctype html><html><head><meta charset="utf-8"><title>`) b.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>")
b.WriteString(r.Ticker + " — FlowSight Report</title></head><body>") b.WriteString(esc(r.Ticker) + " — FlowSight Report</title></head><body>")
fmt.Fprintf(&b, "<h1>%s — FlowSight Report (%s)</h1>", r.Ticker, r.GeneratedAt) fmt.Fprintf(&b, "<h1>%s — FlowSight Report (%s)</h1>", esc(r.Ticker), esc(r.GeneratedAt))
for _, s := range r.Sections { for _, s := range r.Sections {
fmt.Fprintf(&b, "<h2>%s</h2><p>%s</p>", s.Name, s.Body) fmt.Fprintf(&b, "<h2>%s</h2><p>%s</p>", esc(s.Name), esc(s.Body))
if len(s.Citations) > 0 { if len(s.Citations) > 0 {
b.WriteString("<ul>") b.WriteString("<ul>")
for _, c := range s.Citations { for _, c := range s.Citations {
fmt.Fprintf(&b, "<li>%s %s @ %s</li>", c.Endpoint, c.Ticker, c.SnapshotAt) fmt.Fprintf(&b, "<li>%s %s @ %s</li>", esc(c.Endpoint), esc(c.Ticker), esc(c.SnapshotAt))
} }
b.WriteString("</ul>") b.WriteString("</ul>")
} }
@@ -50,25 +54,31 @@ func (r Report) ToHTML() string {
} }
// ToPDF renders the report server-side (pure Go, no system deps). // ToPDF renders the report server-side (pure Go, no system deps).
// Font: DejaVuSans (Unicode) instead of core Helvetica so Indonesian text
// (Em-dash, quotes, Rp, accents) renders correctly — latin1() was turning
// every non-Latin rune into '?'.
func (r Report) ToPDF() ([]byte, error) { func (r Report) ToPDF() ([]byte, error) {
pdf := gofpdf.New("P", "mm", "A4", "") pdf := gofpdf.New("P", "mm", "A4", "")
pdf.SetFontLocation("/usr/share/fonts/truetype/dejavu/")
pdf.AddUTF8Font("DejaVu", "", "DejaVuSans.ttf")
pdf.AddUTF8Font("DejaVu", "B", "DejaVuSans-Bold.ttf")
pdf.AddPage() pdf.AddPage()
pdf.SetFont("Helvetica", "B", 16) pdf.SetFont("DejaVu", "B", 14)
pdf.Cell(0, 10, r.Ticker+" — FlowSight Report") pdf.Cell(0, 10, r.Ticker+" — FlowSight Report")
pdf.Ln(12) pdf.Ln(12)
pdf.SetFont("Helvetica", "", 10) pdf.SetFont("DejaVu", "", 10)
pdf.Cell(0, 6, "Generated "+r.GeneratedAt) pdf.Cell(0, 6, "Generated "+r.GeneratedAt)
pdf.Ln(8) pdf.Ln(8)
for _, s := range r.Sections { for _, s := range r.Sections {
pdf.SetFont("Helvetica", "B", 12) pdf.SetFont("DejaVu", "B", 12)
pdf.Cell(0, 8, s.Name) pdf.Cell(0, 8, s.Name)
pdf.Ln(8) pdf.Ln(8)
pdf.SetFont("Helvetica", "", 10) pdf.SetFont("DejaVu", "", 10)
pdf.MultiCell(0, 5, latin1(s.Body), "", "", false) pdf.MultiCell(0, 5, s.Body, "", "", false)
if len(s.Citations) > 0 { if len(s.Citations) > 0 {
pdf.SetFont("Helvetica", "I", 8) pdf.SetFont("DejaVu", "", 8)
for _, c := range s.Citations { for _, c := range s.Citations {
pdf.MultiCell(0, 4, latin1(c.Endpoint+" "+c.Ticker+" @ "+c.SnapshotAt), "", "", false) pdf.MultiCell(0, 4, c.Endpoint+" "+c.Ticker+" @ "+c.SnapshotAt, "", "", false)
} }
} }
pdf.Ln(4) pdf.Ln(4)
@@ -79,13 +89,3 @@ func (r Report) ToPDF() ([]byte, error) {
} }
return buf.Bytes(), nil return buf.Bytes(), nil
} }
// latin1 drops non-latin runes gofpdf core fonts cannot render.
func latin1(s string) string {
return strings.Map(func(r rune) rune {
if r > 255 {
return '?'
}
return r
}, s)
}
+42 -3
View File
@@ -32,6 +32,7 @@ type Report struct {
Synthesis agents.Synthesis `json:"synthesis"` Synthesis agents.Synthesis `json:"synthesis"`
AgentScores []model.AgentResult `json:"agent_scores"` AgentScores []model.AgentResult `json:"agent_scores"`
AllCitations []model.Citation `json:"citations"` AllCitations []model.Citation `json:"citations"`
NarasiAwam string `json:"narasi_awam,omitempty"`
} }
// Builder assembles reports from agent runs over stored snapshots. // Builder assembles reports from agent runs over stored snapshots.
@@ -46,7 +47,7 @@ type RiskFn func(ticker string) (concentration, beta string)
// Build runs A1..A6 + A7 and assembles all 7 sections, returning the // Build runs A1..A6 + A7 and assembles all 7 sections, returning the
// persisted report id for citation-scoped chat interrogation. // persisted report id for citation-scoped chat interrogation.
func (b *Builder) Build(ctx context.Context, ticker, profile string) (Report, int64, error) { func (b *Builder) Build(ctx context.Context, ticker, profile, userKey string) (Report, int64, error) {
ticker = strings.ToUpper(ticker) ticker = strings.ToUpper(ticker)
results := agents.RunAll(ctx, b.Deps, ticker) results := agents.RunAll(ctx, b.Deps, ticker)
synth := agents.Synthesize(ctx, b.Deps, ticker, agents.RiskProfile(profile), results) synth := agents.Synthesize(ctx, b.Deps, ticker, agents.RiskProfile(profile), results)
@@ -75,9 +76,10 @@ func (b *Builder) Build(ctx context.Context, ticker, profile string) (Report, in
all = append(all, s.Citations...) all = append(all, s.Citations...)
} }
all = model.MarkStale(all, model.StaleSession(time.Now())) all = model.MarkStale(all, model.StaleSession(time.Now()))
raw, _ := json.Marshal(map[string]any{"ticker": ticker, "sections": sections, "synthesis": synth}) narasi := b.narasiAwam(ctx, ticker, synth, sections)
raw, _ := json.Marshal(map[string]any{"ticker": ticker, "sections": sections, "synthesis": synth, "narasi_awam": narasi})
citesRaw, _ := json.Marshal(all) citesRaw, _ := json.Marshal(all)
id, err := b.DB.SaveReport(ticker, string(raw), string(citesRaw)) id, err := b.DB.SaveReport(ticker, string(raw), string(citesRaw), userKey)
if err != nil { if err != nil {
return Report{}, 0, err return Report{}, 0, err
} }
@@ -85,9 +87,46 @@ func (b *Builder) Build(ctx context.Context, ticker, profile string) (Report, in
Ticker: ticker, GeneratedAt: todayStr(), Ticker: ticker, GeneratedAt: todayStr(),
Sections: sections, Synthesis: synth, Sections: sections, Synthesis: synth,
AgentScores: results, AllCitations: all, AgentScores: results, AllCitations: all,
NarasiAwam: narasi,
}, id, nil }, id, nil
} }
// narasiAwam polishes the rule-based synthesis into plain Indonesian for
// non-experts (verdict + 3 alasan + arti + risiko). Best-effort: any LLM
// failure returns "" and the FE falls back to raw sections.
func (b *Builder) narasiAwam(ctx context.Context, ticker string, synth agents.Synthesis, sections []Section) string {
if b.Deps.LLM == nil || !b.Deps.LLM.Available() {
return ""
}
var sb strings.Builder
for _, s := range sections {
if strings.TrimSpace(s.Body) != "" {
sb.WriteString("- " + s.Name + ": " + s.Body + "\n")
}
}
text, err := b.Deps.LLM.Complete(ctx, b.Deps.SynthModel,
"Kamu asisten pasar saham Indonesia yang ramah. Dari data report berikut, tulis Bahasa Indonesia santai untuk orang awam dengan format:\n"+
"Baris 1: KESIMPULAN: <Dilirik/Tahan/Dilepas> — 1 kalimat.\n"+
"Baris 2-4: 3 alasan (masing-masing 1 kalimat, sebut angkanya).\n"+
"Baris 5: ARTINYA BUAT SAYA: 1 kalimat.\n"+
"Baris 6: RISIKO: 1 kalimat.\n"+
"Setiap angka WAJIB dari data — jangan mengarang. Maksimal 150 kata.",
"Ticker: "+ticker+"\nRekomendasi sistem: "+synth.Recommendation+
" (conviction "+fmt.Sprint(synth.Conviction)+"/5)\nTesis: "+synth.Thesis+
"\nData:\n"+head(sb.String(), 2500), 400)
if err != nil || strings.TrimSpace(text) == "" {
return ""
}
return strings.TrimSpace(text)
}
func head(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func citesOf(byAgent map[string]model.AgentResult, names ...string) []model.Citation { func citesOf(byAgent map[string]model.AgentResult, names ...string) []model.Citation {
var out []model.Citation var out []model.Citation
for _, n := range names { for _, n := range names {
+10
View File
@@ -146,6 +146,16 @@ func (e *Engine) deliver(ctx context.Context, r store.Routine, payload string, c
return e.Notifier.SendTo(ctx, e.UserKey, f, string(cc)) return e.Notifier.SendTo(ctx, e.UserKey, f, string(cc))
} }
// BriefingFor runs the morning briefing scoped to owner (watchlist +
// delivery) without needing a routines row.
func (e *Engine) BriefingFor(ctx context.Context, owner string) (string, []model.Citation, error) {
scoped := *e
if owner != "" {
scoped.UserKey = owner
}
return scoped.Briefing(ctx)
}
// tickers returns the user's watchlist (never empty: falls back to demo set). // tickers returns the user's watchlist (never empty: falls back to demo set).
func (e *Engine) tickers() []string { func (e *Engine) tickers() []string {
wl, err := e.DB.Watchlist(e.UserKey) wl, err := e.DB.Watchlist(e.UserKey)
+123 -13
View File
@@ -11,6 +11,8 @@ import (
"fmt" "fmt"
"log" "log"
"net/url" "net/url"
"strconv"
"strings"
"time" "time"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
@@ -175,6 +177,15 @@ func (s *Scheduler) RunCycle(ctx context.Context) error {
return err return err
} }
} }
// Full-universe rotation: every cycle pull depth (foreign flow included)
// for a slice of the remaining universe, credit-aware, so the dashboard
// chart picker eventually covers all ~800 IDX tickers, not just the 5.
if err := s.rotateDepth(ctx); err != nil {
log.Printf("scheduler: rotate: %v", err)
}
if err := guard(); err != nil {
return err
}
if err := s.events(ctx); err != nil { if err := s.events(ctx); err != nil {
log.Printf("scheduler: events: %v", err) log.Printf("scheduler: events: %v", err)
} }
@@ -190,12 +201,16 @@ func (s *Scheduler) watchlist() []string {
return s.watchlistFor(s.Cfg.DemoUserKey) return s.watchlistFor(s.Cfg.DemoUserKey)
} }
// watchlistFor resolves one owner's watchlist (demo seed fallback kept). // watchlistFor resolves one owner's watchlist: personal first, then the
// "semua" default (every ticker with stored data), then the config list.
func (s *Scheduler) watchlistFor(owner string) []string { func (s *Scheduler) watchlistFor(owner string) []string {
wl, err := s.DB.Watchlist(owner) wl, err := s.DB.Watchlist(owner)
if err == nil && len(wl) > 0 { if err == nil && len(wl) > 0 {
return wl return wl
} }
if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
return all
}
if owner == s.Cfg.DemoUserKey && len(s.Cfg.Watchlist) > 0 { if owner == s.Cfg.DemoUserKey && len(s.Cfg.Watchlist) > 0 {
return s.Cfg.Watchlist return s.Cfg.Watchlist
} }
@@ -230,33 +245,82 @@ func (s *Scheduler) reference(ctx context.Context) error {
return nil return nil
} }
// universe sweeps close/ pages for the latest trading day. // universe sweeps close/ pages for the latest trading day and persists the
// full ticker list (all pages, not just the last) so AllTickers() knows the
// whole IDX universe. ~800 tickers = ~27 pages at 30/page; each page burns 1
// credit. Runs at most once per day (meta universe_last_sweep) and aborts on
// 429 keeping pages collected so far.
func (s *Scheduler) universe(ctx context.Context) error { func (s *Scheduler) universe(ctx context.Context) error {
if s.DB.GetMeta("universe_last_sweep") == time.Now().Format("2006-01-02") {
return nil // already swept today; rotation fills depth incrementally
}
offset := 0 offset := 0
for page := 0; page < 12; page++ { var all []sectors.CloseRow
rows, total, err := s.Sectors.ClosePage(ctx, "", 30, offset) for page := 0; page < 40; page++ {
rows, _, err := s.Sectors.ClosePage(ctx, "", 30, offset)
if err != nil { if err != nil {
// Rate-limited mid-sweep: keep pages collected so far if any.
if len(all) > 0 {
break
}
return err return err
} }
if raw, err := json.Marshal(rows); err == nil && len(rows) > 0 { all = append(all, rows...)
_ = s.DB.SaveSnapshot("IDX", rows[0].Date, "close", string(raw))
}
offset += len(rows) offset += len(rows)
if offset >= total || len(rows) == 0 { if len(rows) == 0 {
break break
} }
} }
if len(all) == 0 {
return nil
}
s.DB.SetMeta("universe_last_sweep", time.Now().Format("2006-01-02"))
date := all[0].Date
for _, r := range all {
if r.Date > date {
date = r.Date
}
}
raw, _ := json.Marshal(all)
_ = s.DB.SaveSnapshot("IDX", date, "close", string(raw))
// Persist the universe ticker list so AllTickers() can return the full
// IDX set instead of only tickers that have depth data yet.
uni := make([]store.UniverseRow, 0, len(all))
for _, r := range all {
uni = append(uni, store.UniverseRow{Symbol: r.Symbol, Close: r.Close, Date: r.Date})
}
if err := s.DB.SaveUniverse(uni); err != nil {
log.Printf("scheduler: universe save: %v", err)
}
return nil return nil
} }
// market pulls top-changes (1 class x 2 periods), most-traded, idx-total, // market pulls top-changes (1 class x 2 periods), most-traded, idx-total,
// brokers/top — the cheap context block of the cycle. // brokers/top — the cheap context block of the cycle. The top-changes call
// also enriches universe company names (MoverRow.Name) at no extra credit —
// n_stock is free; cost is classes×periods only.
func (s *Scheduler) market(ctx context.Context) error { func (s *Scheduler) market(ctx context.Context) error {
if g, l, err := s.Sectors.TopChanges(ctx, if g, l, err := s.Sectors.TopChanges(ctx,
[]string{"top_gainers"}, []string{"1d", "7d"}, "", 5); err == nil { []string{"top_gainers"}, []string{"1d", "7d"}, "", 750); err == nil {
if raw, err := json.Marshal(map[string]any{"top_gainers": g, "top_losers": l}); err == nil { if raw, err := json.Marshal(map[string]any{"top_gainers": g, "top_losers": l}); err == nil {
_ = s.DB.SaveSnapshot("IDX", time.Now().Format("2006-01-02"), "top-changes", string(raw)) _ = s.DB.SaveSnapshot("IDX", time.Now().Format("2006-01-02"), "top-changes", string(raw))
} }
// Capture company names from whatever mover rows came back.
names := map[string]string{}
collect := func(rows map[string][]sectors.MoverRow) {
for _, rs := range rows {
for _, r := range rs {
if r.Name != "" {
names[strings.TrimSuffix(r.Symbol, ".JK")] = r.Name
}
}
}
}
collect(g)
collect(l)
if err := s.DB.SetCompanyNames(names); err != nil {
log.Printf("scheduler: set company names: %v", err)
}
} else { } else {
return err return err
} }
@@ -279,9 +343,12 @@ func (s *Scheduler) market(ctx context.Context) error {
} }
// tickerDepth pulls broker-summary/top + foreign-flow + daily per ticker. // tickerDepth pulls broker-summary/top + foreign-flow + daily per ticker.
// Dates use time.Now().UTC() — the Sectors API rejects end dates in the
// future relative to its UTC clock (WIB midnight > UTC previous day).
func (s *Scheduler) tickerDepth(ctx context.Context, ticker string) error { func (s *Scheduler) tickerDepth(ctx context.Context, ticker string) error {
end := time.Now().Format("2006-01-02") now := time.Now().UTC()
start5 := time.Now().AddDate(0, 0, -6).Format("2006-01-02") end := now.Format("2006-01-02")
start5 := now.AddDate(0, 0, -6).Format("2006-01-02")
if top, err := s.Sectors.BrokerSummaryTop(ctx, ticker, start5, end, 10, "", ""); err == nil { if top, err := s.Sectors.BrokerSummaryTop(ctx, ticker, start5, end, 10, "", ""); err == nil {
if raw, err := json.Marshal(top); err == nil { if raw, err := json.Marshal(top); err == nil {
_ = s.DB.SaveSnapshot(ticker, end, "broker-summary-top", string(raw)) _ = s.DB.SaveSnapshot(ticker, end, "broker-summary-top", string(raw))
@@ -298,7 +365,7 @@ func (s *Scheduler) tickerDepth(ctx context.Context, ticker string) error {
return err return err
} }
if ff, err := s.Sectors.ForeignFlow(ctx, ticker, if ff, err := s.Sectors.ForeignFlow(ctx, ticker,
time.Now().AddDate(0, 0, -7).Format("2006-01-02"), end); err == nil { now.AddDate(0, 0, -7).Format("2006-01-02"), end); err == nil {
if raw, err := json.Marshal(ff); err == nil { if raw, err := json.Marshal(ff); err == nil {
_ = s.DB.SaveSnapshot(ticker, end, "foreign-flow", string(raw)) _ = s.DB.SaveSnapshot(ticker, end, "foreign-flow", string(raw))
for _, d := range ff.Data { for _, d := range ff.Data {
@@ -315,6 +382,49 @@ func (s *Scheduler) tickerDepth(ctx context.Context, ticker string) error {
return nil return nil
} }
// rotateDepth incrementally deep-scans the whole universe across cycles:
// each cycle it pulls depth for a bounded slice of tickers that don't yet
// have a foreign-flow snapshot, respecting the credit cap. Progress is
// tracked via a meta cursor (universe_rotate_offset). Every ticker gets
// covered every ~80 cycles (10/cycle x 800), and the dashboard picker
// grows from 5 → ~800 tickers.
func (s *Scheduler) rotateDepth(ctx context.Context) error {
all, err := s.DB.AllTickers()
if err != nil || len(all) == 0 {
return nil
}
have, err := s.DB.TickersWithForeign()
if err != nil {
return err
}
missing := make([]string, 0, len(all))
for _, t := range all {
if !have[t] {
missing = append(missing, t)
}
}
if len(missing) == 0 {
return nil // full coverage reached; daily freshness keeps it fresh
}
// Round-robin cursor so we don't always start at the same ticker.
start, _ := strconv.Atoi(s.DB.GetMeta("universe_rotate_offset"))
if start >= len(missing) {
start = 0
}
batch := 30 // 3 credits/ticker = 90 credits; universe sweep (27) runs once
// per day so a normal cycle stays within the 120 cap.
for i := 0; i < batch; i++ {
t := missing[(start+i)%len(missing)]
if err := s.tickerDepth(ctx, t); err != nil {
// 429 or transient: stop this cycle, resume next.
s.DB.SetMeta("universe_rotate_offset", fmt.Sprint((start+i)%len(missing)))
return err
}
}
s.DB.SetMeta("universe_rotate_offset", fmt.Sprint((start+batch)%len(missing)))
return nil
}
// events polls news/filings/suspensions incrementally via meta cursors. // events polls news/filings/suspensions incrementally via meta cursors.
func (s *Scheduler) events(ctx context.Context) error { func (s *Scheduler) events(ctx context.Context) error {
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
+188
View File
@@ -0,0 +1,188 @@
package store
import (
"crypto/rand"
"encoding/hex"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// User is one Google-authenticated account. UserKey (`u:<google_sub>`) is
// the scoping key used by every user-owned table. Local accounts
// (username+password signup) store google_sub='local:<username>' with
// username+password_hash set, keeping session scoping unchanged.
type authErr string
func (e authErr) Error() string { return string(e) }
const (
ErrBadLogin authErr = "username atau password salah"
ErrBadUsername authErr = "username 3-32 karakter: huruf, angka, titik, _ -"
ErrBadPassword authErr = "password minimal 8 karakter"
ErrTaken authErr = "username sudah dipakai"
)
type User struct {
ID int64
GoogleSub string
Email string
Name string
AvatarURL string
CreatedAt string
UserKey string
}
// UserKeyForSub maps a Google subject to its scoping key.
func UserKeyForSub(sub string) string { return "u:" + sub }
// UpsertUser inserts a first-time login or refreshes profile on return.
func (db *DB) UpsertUser(sub, email, name, avatar string) (*User, error) {
now := time.Now().UTC().Format(time.RFC3339)
if _, err := db.Exec(`INSERT INTO users(google_sub,email,name,avatar_url,created_at)
VALUES(?,?,?,?,?)
ON CONFLICT(google_sub) DO UPDATE SET email=excluded.email,
name=excluded.name, avatar_url=excluded.avatar_url`,
sub, email, name, avatar, now); err != nil {
return nil, err
}
return db.UserBySub(sub)
}
// UserBySub returns the user for a Google subject.
func (db *DB) UserBySub(sub string) (*User, error) {
var u User
if err := db.QueryRow(`SELECT id,google_sub,email,name,avatar_url,created_at
FROM users WHERE google_sub=?`, sub).
Scan(&u.ID, &u.GoogleSub, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt); err != nil {
return nil, err
}
u.UserKey = UserKeyForSub(u.GoogleSub)
return &u, nil
}
// NewSessionToken mints one opaque 256-bit session token.
func NewSessionToken() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
// CreateSession stores a session valid for ttl.
func (db *DB) CreateSession(userID int64, userKey string, ttl time.Duration) (string, error) {
tok, err := NewSessionToken()
if err != nil {
return "", err
}
now := time.Now().UTC()
if _, err := db.Exec(`INSERT INTO sessions(token,user_key,user_id,expires_at,created_at)
VALUES(?,?,?,?,?)`, tok, userKey, userID,
now.Add(ttl).Format(time.RFC3339), now.Format(time.RFC3339)); err != nil {
return "", err
}
return tok, nil
}
// SessionUser resolves a session token to its user. Expired tokens are
// deleted lazily and reported invalid.
func (db *DB) SessionUser(token string) (*User, bool) {
if strings.TrimSpace(token) == "" {
return nil, false
}
var u User
var exp string
if err := db.QueryRow(`SELECT u.id,u.google_sub,u.email,u.name,u.avatar_url,
u.created_at,s.expires_at FROM sessions s
JOIN users u ON u.id=s.user_id WHERE s.token=?`, token).
Scan(&u.ID, &u.GoogleSub, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt, &exp); err != nil {
return nil, false
}
t, err := time.Parse(time.RFC3339, exp)
if err != nil || time.Now().UTC().After(t) {
_, _ = db.Exec(`DELETE FROM sessions WHERE token=?`, token)
return nil, false
}
u.UserKey = UserKeyForSub(u.GoogleSub)
return &u, true
}
// UserKeyBySession returns the scoping key for a valid session token.
func (db *DB) UserKeyBySession(token string) (string, bool) {
u, ok := db.SessionUser(token)
if !ok {
return "", false
}
return u.UserKey, true
}
// DeleteSession revokes one session token.
func (db *DB) DeleteSession(token string) error {
_, err := db.Exec(`DELETE FROM sessions WHERE token=?`, token)
return err
}
// localSub maps a username to its google_sub value for local accounts.
func localSub(username string) string { return "local:" + strings.ToLower(username) }
// CreateLocalUser registers a username+password account (bcrypt cost 10).
// Username: 3-32 chars [a-z0-9._-]; password: min 8 chars.
func (db *DB) CreateLocalUser(username, password string) (*User, error) {
username = strings.ToLower(strings.TrimSpace(username))
if !validUsername(username) {
return nil, ErrBadUsername
}
if len(password) < 8 {
return nil, ErrBadPassword
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
if err != nil {
return nil, err
}
sub := localSub(username)
now := time.Now().UTC().Format(time.RFC3339)
if _, err := db.Exec(`INSERT INTO users(google_sub,email,name,username,password_hash,created_at)
VALUES(?,?,?,?,?,?)`, sub, username, username, username, string(hash), now); err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return nil, ErrTaken
}
return nil, err
}
return db.UserBySub(sub)
}
// CheckLocalUser verifies username+password, returning the user on success.
func (db *DB) CheckLocalUser(username, password string) (*User, error) {
username = strings.ToLower(strings.TrimSpace(username))
var u User
var hash string
if err := db.QueryRow(`SELECT id,google_sub,email,name,avatar_url,created_at,password_hash
FROM users WHERE username=?`, username).
Scan(&u.ID, &u.GoogleSub, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt, &hash); err != nil {
return nil, ErrBadLogin
}
if hash == "" {
return nil, ErrBadLogin // Google-only account, no local password
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return nil, ErrBadLogin
}
u.UserKey = UserKeyForSub(u.GoogleSub)
return &u, nil
}
// validUsername allows 3-32 chars of lowercase letters, digits, . _ -.
func validUsername(u string) bool {
if len(u) < 3 || len(u) > 32 {
return false
}
for _, c := range u {
if c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-' {
continue
}
return false
}
return true
}
@@ -0,0 +1,19 @@
-- FlowSight schema v5: Google-authenticated users + server sessions.
-- user_key for a login user is `u:<google_sub>`; all user-scoped tables
-- (watchlists, routines, alerts, destinations, reports) work unchanged.
CREATE TABLE IF NOT EXISTS users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
google_sub TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS sessions(
token TEXT PRIMARY KEY,
user_key TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
@@ -0,0 +1,4 @@
-- FlowSight schema v6: cached LLM narration for briefings.
-- narasi = 3 kalimat santai Indonesia hasil poles LLM (OmniRoute),
-- best-effort: kosong berarti FE pakai ringkasan rules.
ALTER TABLE briefings ADD COLUMN narasi TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,6 @@
-- FlowSight schema v7: local username+password login (Google stays optional).
-- Local accounts store google_sub='local:<username>' so session scoping
-- (sessions join + UserKeyForSub) works unchanged.
ALTER TABLE users ADD COLUMN username TEXT;
ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username);
@@ -0,0 +1,16 @@
-- 0008_reports_owner.sql: reports now scoped to the owning user.
-- Existing rows are assigned to the shared demo key so historic reports
-- remain readable by the demo/seed pipeline; new reports carry user_key.
CREATE TABLE IF NOT EXISTS reports_new(
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticker TEXT NOT NULL, generated_at TEXT NOT NULL,
payload_json TEXT NOT NULL, citations_json TEXT NOT NULL DEFAULT '[]',
user_key TEXT NOT NULL DEFAULT 'demo'
);
INSERT INTO reports_new(id, ticker, generated_at, payload_json, citations_json, user_key)
SELECT id, ticker, generated_at, payload_json, citations_json, 'demo' FROM reports;
DROP TABLE reports;
ALTER TABLE reports_new RENAME TO reports;
CREATE INDEX IF NOT EXISTS idx_reports_ticker ON reports(ticker, id);
-- SQLite ignores IF NOT EXISTS on column add; guard on table existence.
@@ -0,0 +1,6 @@
-- 0009_universe.sql: persisted full IDX ticker universe from the close sweep.
CREATE TABLE IF NOT EXISTS universe(
ticker TEXT PRIMARY KEY,
close INTEGER NOT NULL DEFAULT 0,
date TEXT NOT NULL DEFAULT ''
);
@@ -0,0 +1,2 @@
-- 0010_universe_names.sql: company names for screener display.
ALTER TABLE universe ADD COLUMN company_name TEXT NOT NULL DEFAULT '';
+259 -25
View File
@@ -4,6 +4,8 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"sort"
"strings"
"time" "time"
) )
@@ -400,6 +402,29 @@ func (db *DB) RunHistory(routineID int64, limit int, userKey ...string) ([]map[s
return out, rows.Err() return out, rows.Err()
} }
// LastRunsByRoutine returns the single latest run per routine for a user —
// used by ListRoutines to avoid an N+1 query per routine row.
func (db *DB) LastRunsByRoutine(userKey string) (map[int64]map[string]any, error) {
rows, err := q(db, `SELECT routine_id, started_at, status
FROM routine_runs WHERE user_key=?
AND id IN (SELECT MAX(id) FROM routine_runs WHERE user_key=? GROUP BY routine_id)`,
userKey, userKey)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]map[string]any{}
for rows.Next() {
var rid int64
var started, status string
if err := rows.Scan(&rid, &started, &status); err != nil {
return nil, err
}
out[rid] = map[string]any{"started_at": started, "status": status}
}
return out, rows.Err()
}
// Alerts. // Alerts.
// Alert is one user rule row. // Alert is one user rule row.
@@ -540,6 +565,178 @@ func (db *DB) Watchlist(userKey string) ([]string, error) {
return out, rows.Err() return out, rows.Err()
} }
// AllTickers returns every ticker that has snapshot data, excluding pseudo
// tickers (IDX market-wide rows, ROE registry rows). This is the "semua"
// default universe: dashboard, screener fallback, and scheduler depth all use
// it when a user has no personal watchlist.
func (db *DB) AllTickers() ([]string, error) {
// Prefer the persisted full-universe list (from close/ sweep).
rows, err := db.Query(`SELECT ticker FROM universe ORDER BY ticker`)
if err == nil {
var out []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
rows.Close()
break
}
out = append(out, t)
}
rows.Close()
if len(out) > 0 {
return out, nil
}
}
// Fallback: distinct snapshots (avoids pseudo indices).
rows2, err := db.Query(`SELECT DISTINCT ticker FROM snapshots
WHERE ticker NOT IN ('IDX','ROE') ORDER BY ticker`)
if err != nil {
return nil, err
}
defer rows2.Close()
var out2 []string
for rows2.Next() {
var t string
if err := rows2.Scan(&t); err != nil {
return nil, err
}
out2 = append(out2, t)
}
return out2, rows2.Err()
}
// TickersWithForeign returns the set of tickers that have ≥1 foreign-flow row.
func (db *DB) TickersWithForeign() (map[string]bool, error) {
rows, err := db.Query(`SELECT DISTINCT ticker FROM foreign_flow`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
out[t] = true
}
return out, rows.Err()
}
// SaveUniverse inserts the full ticker universe from the close sweep.
// UniverseRow mirrors the CloseRow shape without importing sectors (cycle).
type UniverseRow struct {
Symbol string
Close int64
Date string
CompanyName string
}
func (db *DB) SaveUniverse(rows []UniverseRow) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM universe`); err != nil {
return err
}
for _, r := range rows {
// Normalize: API returns "BBCA.JK" — strip the suffix so it matches
// watchlist/depth keys (BBCA) everywhere.
t := strings.TrimSuffix(r.Symbol, ".JK")
if _, err := tx.Exec(`INSERT OR IGNORE INTO universe(ticker, close, date, company_name) VALUES(?,?,?,?)`,
t, r.Close, r.Date, r.CompanyName); err != nil {
return err
}
}
return tx.Commit()
}
// CompanyNames returns the company name map for the given tickers (from the
// universe table). Tickers with no stored name are omitted.
func (db *DB) CompanyNames(tickers []string) (map[string]string, error) {
if len(tickers) == 0 {
return map[string]string{}, nil
}
ph := strings.Repeat("?,", len(tickers))
ph = ph[:len(ph)-1]
args := make([]any, len(tickers))
for i, t := range tickers {
args[i] = t
}
rows, err := db.Query(`SELECT ticker, company_name FROM universe
WHERE company_name != '' AND ticker IN (`+ph+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var t, nm string
if err := rows.Scan(&t, &nm); err != nil {
return nil, err
}
out[t] = nm
}
return out, rows.Err()
}
// SetCompanyNames bulk-upserts company_name for tickers (from top-changes
// snapshots). Only non-empty names are written; existing names win (skip
// blank overwrite) so a later richer source doesn't clobber a good one.
func (db *DB) SetCompanyNames(names map[string]string) error {
if len(names) == 0 {
return nil
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for t, nm := range names {
nm = strings.TrimSpace(nm)
if nm == "" || strings.EqualFold(nm, t) {
continue
}
if _, err := tx.Exec(`UPDATE universe SET company_name = ? WHERE ticker = ? AND company_name = ''`, nm, t); err != nil {
return err
}
}
return tx.Commit()
}
// TickerHit is one local search result over the universe table.
type TickerHit struct {
Symbol string
CompanyName string
}
// SearchTickers finds universe tickers matching q in ticker or company_name
// (case-insensitive), most-traded first. Works offline — no Sectors API call.
func (db *DB) SearchTickers(q string, limit int) ([]TickerHit, error) {
if limit <= 0 {
limit = 20
}
like := "%" + strings.ToUpper(strings.TrimSpace(q)) + "%"
rows, err := db.Query(`SELECT ticker, company_name FROM universe
WHERE ticker LIKE ? OR UPPER(company_name) LIKE ?
ORDER BY close DESC LIMIT ?`, like, like, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TickerHit
for rows.Next() {
var h TickerHit
if err := rows.Scan(&h.Symbol, &h.CompanyName); err != nil {
return nil, err
}
out = append(out, h)
}
return out, rows.Err()
}
// AddWatch inserts a ticker (idempotent). // AddWatch inserts a ticker (idempotent).
func (db *DB) AddWatch(userKey, ticker string) error { func (db *DB) AddWatch(userKey, ticker string) error {
_, err := db.Exec(`INSERT INTO watchlists(user_key,ticker,added_at) VALUES(?,?,?) _, err := db.Exec(`INSERT INTO watchlists(user_key,ticker,added_at) VALUES(?,?,?)
@@ -589,10 +786,10 @@ func (db *DB) FilingsSince(ticker, since string, limit int) ([]map[string]any, e
// Reports reports / briefings. // Reports reports / briefings.
// SaveReport stores a generated report; returns its id. // SaveReport stores a generated report owned by userKey; returns its id.
func (db *DB) SaveReport(ticker, payload, cites string) (int64, error) { func (db *DB) SaveReport(ticker, payload, cites, userKey string) (int64, error) {
res, err := db.Exec(`INSERT INTO reports(ticker,generated_at,payload_json,citations_json) res, err := db.Exec(`INSERT INTO reports(ticker,generated_at,payload_json,citations_json,user_key)
VALUES(?,?,?,?)`, ticker, time.Now().UTC().Format(time.RFC3339), payload, cites) VALUES(?,?,?,?,?)`, ticker, time.Now().UTC().Format(time.RFC3339), payload, cites, userKey)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -622,7 +819,16 @@ func (db *DB) ListReports(ticker string, limit int) ([]map[string]any, error) {
return out, rows.Err() return out, rows.Err()
} }
// LatestReport returns the newest report payload for a ticker. // LatestReportForUser returns the newest report payload for a ticker scoped
// to the calling user (IDOR fix: never leak other users' reports).
func (db *DB) LatestReportForUser(ticker, userKey string) (payload, cites, at string, err error) {
err = db.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports
WHERE ticker=? AND user_key=? ORDER BY id DESC LIMIT 1`, ticker, userKey).Scan(&payload, &cites, &at)
return payload, cites, at, err
}
// LatestReport returns the newest report payload for a ticker (global —
// used only where user scoping is not required, e.g. internal reads).
func (db *DB) LatestReport(ticker string) (payload, cites, at string, err error) { func (db *DB) LatestReport(ticker string) (payload, cites, at string, err error) {
err = db.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports err = db.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports
WHERE ticker=? ORDER BY id DESC LIMIT 1`, ticker).Scan(&payload, &cites, &at) WHERE ticker=? ORDER BY id DESC LIMIT 1`, ticker).Scan(&payload, &cites, &at)
@@ -637,11 +843,19 @@ func (db *DB) SaveBriefing(date, payload, cites string) error {
return err return err
} }
// LatestBriefing returns the newest briefing. // SaveNarasi caches the LLM-polished narration for a briefing date
func (db *DB) LatestBriefing() (date, payload, cites string, err error) { // (best-effort; empty string clears it).
err = db.QueryRow(`SELECT date, payload_json, citations_json FROM briefings func (db *DB) SaveNarasi(date, narasi string) error {
ORDER BY date DESC LIMIT 1`).Scan(&date, &payload, &cites) _, err := db.Exec(`UPDATE briefings SET narasi=? WHERE date=?`, narasi, date)
return date, payload, cites, err return err
}
// LatestBriefing returns the newest briefing (plus cached narration).
func (db *DB) LatestBriefing() (date, payload, cites, narasi string, err error) {
err = db.QueryRow(`SELECT date, payload_json, citations_json,
COALESCE(narasi,'') FROM briefings
ORDER BY date DESC LIMIT 1`).Scan(&date, &payload, &cites, &narasi)
return date, payload, cites, narasi, err
} }
// Accuracy ledger. // Accuracy ledger.
@@ -756,28 +970,48 @@ func (db *DB) PruneSnapshots(cutoff string) (int64, error) {
// Daily volumes for technical/volume rules. // Daily volumes for technical/volume rules.
// DailyVolumes returns date-ordered volumes for a ticker from snapshots. // DailyVolumes returns date-ordered volumes for a ticker from snapshots,
// deduped by bar date (each snapshot row stores a multi-day window, so a
// naive LIMIT over rows massively duplicates dates and skews volume-anomaly
// math). Returns the newest `limit` distinct trading days, oldest-first.
func (db *DB) DailyVolumes(ticker string, limit int) ([]float64, []string, error) { func (db *DB) DailyVolumes(ticker string, limit int) ([]float64, []string, error) {
// Pull extra rows (each holds up to 30 bars) and dedupe by bar date.
rows, err := db.Query(`SELECT date, payload_json FROM snapshots rows, err := db.Query(`SELECT date, payload_json FROM snapshots
WHERE ticker=? AND source='daily' ORDER BY date DESC LIMIT ?`, ticker, limit) WHERE ticker=? AND source='daily' ORDER BY date DESC LIMIT ?`, ticker, 200)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
defer rows.Close() defer rows.Close()
var vols []float64 byDate := map[string]float64{}
var dates []string seen := map[string]bool{}
var dateOrder []string
for rows.Next() { for rows.Next() {
var d, p string var d, p string
if err := rows.Scan(&d, &p); err != nil { if err := rows.Scan(&d, &p); err != nil {
return nil, nil, err return nil, nil, err
} }
for _, b := range parseDailyBars(p, d) { for _, b := range parseDailyBars(p, d) {
if b.Volume > 0 { if b.Volume <= 0 || seen[b.Date] {
vols, dates = append(vols, b.Volume), append(dates, b.Date) continue
} }
seen[b.Date] = true
byDate[b.Date] = b.Volume
dateOrder = append(dateOrder, b.Date)
} }
} }
return vols, dates, rows.Err() if err := rows.Err(); err != nil {
return nil, nil, err
}
// dateOrder is newest-first from the DESC scan; reverse for ascending.
sort.Strings(dateOrder)
if len(dateOrder) > limit {
dateOrder = dateOrder[len(dateOrder)-limit:]
}
vols := make([]float64, 0, len(dateOrder))
for _, d := range dateOrder {
vols = append(vols, byDate[d])
}
return vols, dateOrder, nil
} }
// LatestClose returns the most recent close for a ticker. // LatestClose returns the most recent close for a ticker.
@@ -959,17 +1193,17 @@ func (db *DB) UpdateDestination(id int64, userKey string, label *string, enabled
// GetDestination returns one push target owned by the user, or nil. // GetDestination returns one push target owned by the user, or nil.
func (db *DB) GetDestination(id int64, userKey string) (*Destination, error) { func (db *DB) GetDestination(id int64, userKey string) (*Destination, error) {
all, err := db.ListDestinations(userKey) var d Destination
err := db.QueryRow(`SELECT id, user_key, kind, label, bot_token, chat_id, webhook_url, enabled
FROM notification_destinations WHERE id=? AND user_key=?`, id, userKey).
Scan(&d.ID, &d.UserKey, &d.Kind, &d.Label, &d.BotToken, &d.ChatID, &d.WebhookURL, &d.Enabled)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, d := range all { return &d, nil
if d.ID == id {
c := d
return &c, nil
}
}
return nil, nil
} }
// DeleteDestination removes one push target owned by the user. // DeleteDestination removes one push target owned by the user.
+93 -1
View File
@@ -50,7 +50,7 @@ func TestStoreParity(t *testing.T) {
if err != nil || len(fils) != 1 { if err != nil || len(fils) != 1 {
t.Fatalf("filings = %v, %v", fils, err) t.Fatalf("filings = %v, %v", fils, err)
} }
_, _ = db.SaveReport("BBCA", `{"a":1}`, `[]`) _, _ = db.SaveReport("BBCA", `{"a":1}`, `[]`, "tester")
reps, err := db.ListReports("BBCA", 10) reps, err := db.ListReports("BBCA", 10)
if err != nil || len(reps) != 1 { if err != nil || len(reps) != 1 {
t.Fatalf("reports = %v, %v", reps, err) t.Fatalf("reports = %v, %v", reps, err)
@@ -107,3 +107,95 @@ func TestDestinationCRUD(t *testing.T) {
t.Fatal("double delete must report false") t.Fatal("double delete must report false")
} }
} }
// Universe save + local ticker search + company-name lookup.
func TestUniverseSearch(t *testing.T) {
db, err := Open(t.TempDir() + "/univ.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
uni := []UniverseRow{
{Symbol: "BBCA.JK", Close: 100, Date: "2026-09-16", CompanyName: "PT Bank Central Asia Tbk"},
{Symbol: "TLKM.JK", Close: 90, Date: "2026-09-16"},
{Symbol: "BBRI.JK", Close: 80, Date: "2026-09-16", CompanyName: "PT Bank Rakyat Indonesia"},
}
if err := db.SaveUniverse(uni); err != nil {
t.Fatal(err)
}
// Search by ticker prefix (case-insensitive).
hits, err := db.SearchTickers("bca", 10)
if err != nil {
t.Fatal(err)
}
if len(hits) != 1 || hits[0].Symbol != "BBCA" {
t.Fatalf("bca hits = %+v, want BBCA", hits)
}
// Search by company name.
hits, err = db.SearchTickers("bank", 10)
if err != nil {
t.Fatal(err)
}
if len(hits) != 2 {
t.Fatalf("bank hits = %d, want 2 (BBCA+BBRI by name)", len(hits))
}
// .JK stripped on save, order by close desc.
if hits[0].Symbol != "BBCA" || hits[1].Symbol != "BBRI" {
t.Fatalf("bank order = %s,%s, want BBCA,BBRI", hits[0].Symbol, hits[1].Symbol)
}
// CompanyNames returns only tickers with stored names.
names, err := db.CompanyNames([]string{"BBCA", "TLKM", "BBRI"})
if err != nil {
t.Fatal(err)
}
if names["BBCA"] != "PT Bank Central Asia Tbk" || names["BBRI"] != "PT Bank Rakyat Indonesia" {
t.Fatalf("names = %+v, want BBCA+BBRI only", names)
}
if _, ok := names["TLKM"]; ok {
t.Fatal("TLKM has no name, must be omitted")
}
}
// SetCompanyNames fills blank names; never overwrites existing ones; skips
// blanks and ticker-equal names.
func TestSetCompanyNames(t *testing.T) {
db, err := Open(t.TempDir() + "/names.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
uni := []UniverseRow{
{Symbol: "BBCA.JK", Close: 100, Date: "2026-09-16", CompanyName: "PT Bank Central Asia Tbk"},
{Symbol: "TLKM.JK", Close: 90, Date: "2026-09-16"},
{Symbol: "BBRI.JK", Close: 80, Date: "2026-09-16"},
}
if err := db.SaveUniverse(uni); err != nil {
t.Fatal(err)
}
// Fill TLKM + BBRI; BBCA already has a name (must NOT be clobbered);
// "UNVR" would be a new row (INSERT OR IGNORE means it's skipped).
err = db.SetCompanyNames(map[string]string{
"TLKM": "PT Telkom Indonesia (Persero) Tbk",
"BBRI": "PT Bank Rakyat Indonesia (Persero) Tbk",
"BBCA": "SHOULD NOT OVERWRITE",
"UNVR": "PT Unilever Indonesia Tbk",
"EMTK": "PT Elang Mahkota Teknologi Tbk", // no universe row -> no-op
"BLAH": "BLAH", // ticker-equal -> skip
})
if err != nil {
t.Fatal(err)
}
names, err := db.CompanyNames([]string{"BBCA", "TLKM", "BBRI", "UNVR"})
if err != nil {
t.Fatal(err)
}
if names["BBCA"] != "PT Bank Central Asia Tbk" {
t.Fatalf("BBCA clobbered: %q", names["BBCA"])
}
if names["TLKM"] != "PT Telkom Indonesia (Persero) Tbk" || names["BBRI"] != "PT Bank Rakyat Indonesia (Persero) Tbk" {
t.Fatalf("names = %+v, want TLKM+BBRI filled", names)
}
if _, ok := names["UNVR"]; ok {
t.Fatal("UNVR not in universe must be absent")
}
}
+11 -2
View File
@@ -9,12 +9,21 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"solid-js": "1.9.15", "@kobalte/core": "^0.13.14",
"@solidjs/router": "0.15.3", "@solidjs/router": "0.15.3",
"autoprefixer": "^10.6.0",
"chart.js": "4.4.7", "chart.js": "4.4.7",
"solid-chartjs": "1.3.11" "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"postcss": "^8.5.28",
"solid-chartjs": "1.3.11",
"solid-js": "1.9.15",
"tailwind-merge": "^3.7.0",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.5.1",
"typescript": "5.6.3", "typescript": "5.6.3",
"vite": "6.4.3", "vite": "6.4.3",
"vite-plugin-solid": "2.11.6" "vite-plugin-solid": "2.11.6"
+798 -8
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+57
View File
@@ -0,0 +1,57 @@
import { For, Show } from "solid-js";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { ISTILAH, type Verdict } from "../lib/awam";
// Kata teknis bergaris-titik + tooltip penjelasan santai.
export function Term(props: { kata: string }) {
const key = props.kata.toLowerCase();
const arti = ISTILAH[key];
if (!arti) return <span>{props.kata}</span>;
return (
<Tooltip>
<TooltipTrigger as="span" class="cursor-help underline decoration-dotted underline-offset-2">{props.kata}</TooltipTrigger>
<TooltipContent class="max-w-xs">{arti}</TooltipContent>
</Tooltip>
);
}
// Verdict selalu hijau (Dilirik) / merah (Dilepas) / abu (Netral) —
// arti warna konsisten di seluruh app: hijau = kabar baik, merah = waspada.
export function VerdictBadge(props: { verdict: Verdict }) {
const warna: Record<Verdict, "success" | "error" | "secondary"> = {
"Dilirik": "success",
"Dilepas": "error",
"Netral": "secondary",
};
return <Badge variant={warna[props.verdict]}>{props.verdict}</Badge>;
}
// Bar alasan: porsi bukti asing (biru, netral-informatif) vs broker (ungu).
// BUKAN hijau/merah — bar ini soal "bukti dari mana", bukan "baik/buruk".
export function AlasanBar(props: { asing: number; broker: number }) {
const total = Math.abs(props.asing) + Math.abs(props.broker) || 1;
const pAsing = Math.round((Math.abs(props.asing) / total) * 100);
return (
<div>
<div class="flex h-2 w-full overflow-hidden rounded-full bg-muted">
<div class="bg-sky-500" style={{ width: `${pAsing}%` }} />
<div class="bg-violet-500" style={{ width: `${100 - pAsing}%` }} />
</div>
<div class="mt-1 flex justify-between text-xs text-muted-foreground">
<span><span class="text-sky-500">■</span> Bukti asing {props.asing >= 0 ? "masuk" : "keluar"}</span>
<span><span class="text-violet-500">■</span> Bukti broker {props.broker >= 0 ? "borong" : "jualan"}</span>
</div>
</div>
);
}
export function IstilahStrip() {
const kunci = ["akumulasi", "foreign flow", "broker", "net", "briefing", "alert"];
return (
<p class="text-xs text-muted-foreground">
Istilah asing? Arahkan kursor:{" "}
<For each={kunci}>{(k, i) => <><Show when={i() > 0}>, </Show><Term kata={k} /></>}</For>
</p>
);
}
+43 -27
View File
@@ -1,45 +1,61 @@
import { For, Show } from "solid-js"; import { For, Show } from "solid-js";
import { fmtIDR } from "../lib/api"; import { fmtIDR } from "../lib/api";
import { Citations } from "./Citations"; import { Citations } from "./Citations";
import { Badge } from "./ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
export function AccumulationTable(props: { rows: { ticker: string; net_sum: number; brokers: number }[] }) { export function AccumulationTable(props: { rows: { ticker: string; net_sum: number; brokers: number }[] }) {
return ( return (
<div class="fs-card"> <Card>
<h3>Top accumulation</h3> <CardHeader>
<table class="fs-table"> <CardTitle>Top accumulation</CardTitle>
<thead><tr><th>Ticker</th><th>Net</th><th>Brokers</th></tr></thead> <CardDescription>Net broker accumulation per ticker.</CardDescription>
<tbody> </CardHeader>
<For each={props.rows}> <CardContent>
{(r) => <tr><td><a href={`/report/${r.ticker}`}>{r.ticker}</a></td><td>{fmtIDR(r.net_sum)}</td><td>{r.brokers}</td></tr>} <Table>
</For> <TableHeader><TableRow><TableHead>Ticker</TableHead><TableHead class="text-right">Net</TableHead><TableHead class="text-right">Brokers</TableHead></TableRow></TableHeader>
</tbody> <TableBody>
</table> <For each={props.rows}>
<Show when={!props.rows?.length}><p class="fs-muted">No accumulation snapshots yet.</p></Show> {(r) => <TableRow><TableCell><a class="font-semibold text-primary hover:underline" href={`/report/${r.ticker}`}>{r.ticker}</a></TableCell><TableCell class="text-right font-mono">{fmtIDR(r.net_sum)}</TableCell><TableCell class="text-right">{r.brokers}</TableCell></TableRow>}
</div> </For>
</TableBody>
</Table>
<Show when={!props.rows?.length}><p class="mt-3 text-sm text-muted-foreground">No accumulation snapshots yet.</p></Show>
</CardContent>
</Card>
); );
} }
export function RotationMap(props: { signal?: string; citations?: Parameters<typeof Citations>[0]["items"] }) { export function RotationMap(props: { signal?: string; citations?: Parameters<typeof Citations>[0]["items"] }) {
return ( return (
<div class="fs-card"> <Card>
<h3>Sector rotation</h3> <CardHeader>
<p>{props.signal || "No rotation flip detected."}</p> <CardTitle>Sector rotation</CardTitle>
<Citations items={props.citations} /> </CardHeader>
</div> <CardContent>
<p class="text-sm">{props.signal || "No rotation flip detected."}</p>
<Citations items={props.citations} />
</CardContent>
</Card>
); );
} }
export function ActivityFeed(props: { events: { id?: number; ticker?: string; message?: string; date?: string }[] }) { export function ActivityFeed(props: { events: { id?: number; ticker?: string; message?: string; date?: string }[] }) {
return ( return (
<div class="fs-card"> <Card>
<h3>Activity</h3> <CardHeader>
<Show when={props.events?.length} fallback={<p class="fs-muted">No events yet.</p>}> <CardTitle>Activity</CardTitle>
<ul> </CardHeader>
<For each={props.events}> <CardContent>
{(e) => <li><strong>{e.ticker}</strong> — {e.message} <span class="fs-muted">{e.date}</span></li>} <Show when={props.events?.length} fallback={<p class="text-sm text-muted-foreground">No events yet.</p>}>
</For> <ul class="space-y-2 text-sm">
</ul> <For each={props.events}>
</Show> {(e) => <li><Badge variant="secondary">{e.ticker}</Badge> {e.message} <span class="text-muted-foreground">{e.date}</span></li>}
</div> </For>
</ul>
</Show>
</CardContent>
</Card>
); );
} }
+11 -4
View File
@@ -1,5 +1,7 @@
import { For } from "solid-js"; import { For } from "solid-js";
import type { Citation } from "../lib/api"; import type { Citation } from "../lib/api";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export function Citations(props: { items?: Citation[] | string }) { export function Citations(props: { items?: Citation[] | string }) {
const list = (): Citation[] => { const list = (): Citation[] => {
@@ -10,12 +12,17 @@ export function Citations(props: { items?: Citation[] | string }) {
return props.items; return props.items;
}; };
return ( return (
<span> <span class="inline-flex flex-wrap gap-1">
<For each={list()}> <For each={list()}>
{(c) => ( {(c) => (
<span class={`fs-cite${c.stale ? " stale" : ""}`} title={`${c.endpoint} @ ${c.snapshot_at}${c.stale ? " (stale)" : ""}`}> <Tooltip>
{c.endpoint} {c.ticker || ""} @ {(c.snapshot_at || "").slice(0, 10)}{c.stale ? " · stale" : ""} <TooltipTrigger>
</span> <Badge variant={c.stale ? "secondary" : "outline"} class={c.stale ? "opacity-70" : ""}>
{c.endpoint} {c.ticker || ""} @ {(c.snapshot_at || "").slice(0, 10)}{c.stale ? " · stale" : ""}
</Badge>
</TooltipTrigger>
<TooltipContent>{c.endpoint} @ {c.snapshot_at}{c.stale ? " (stale)" : ""}</TooltipContent>
</Tooltip>
)} )}
</For> </For>
</span> </span>
+51 -22
View File
@@ -1,38 +1,53 @@
import { createResource, createSignal, For, Show } from "solid-js"; import { createResource, createSignal, For, Show } from "solid-js";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { TextField, TextFieldInput } from "./ui/text-field";
// WatchlistDrawer: global watchlist add/remove backed by /api/watchlist.
export function WatchlistDrawer() { export function WatchlistDrawer() {
const [wl, { refetch }] = createResource(() => api.watchlist()); const [wl, { refetch }] = createResource(() => api.watchlist());
const [ticker, setTicker] = createSignal(""); const [ticker, setTicker] = createSignal("");
const [err, setErr] = createSignal(""); const [err, setErr] = createSignal("");
async function add() { async function add() {
setErr(""); setErr("");
const t = ticker().toUpperCase().trim();
if (!t) return;
try { try {
await api.addWatch(ticker().toUpperCase().trim()); await api.addWatch(t);
setTicker(""); setTicker("");
refetch(); refetch();
} catch (e) { setErr(String(e)); } } catch (e) { setErr(String(e)); }
} }
async function del(t: string) { async function del(t: string) {
await api.removeWatch(t); try {
refetch(); await api.removeWatch(t);
refetch();
} catch (e) { setErr(String(e)); }
} }
return ( return (
<div class="fs-card"> <Card>
<h3>Watchlist</h3> <CardHeader class="pb-2"><CardTitle class="text-base">Watchlist</CardTitle></CardHeader>
<input class="fs-input" placeholder="BBCA" value={ticker()} onInput={(e) => setTicker(e.currentTarget.value)} style={{ width: "90px" }} /> <CardContent class="space-y-2">
<button class="fs-btn" onClick={add}>Add</button> <div class="flex gap-2">
<Show when={err()}><p class="fs-muted">{err()}</p></Show> <label class="sr-only" for="wl-ticker">Tambah ticker</label>
<ul><For each={wl()?.watchlist || []}>{(t) => <li><a href={`/report/${t}`}>{t}</a> <button class="fs-btn ghost" onClick={() => del(t)}>×</button></li>}</For></ul> <TextField class="w-24"><TextFieldInput id="wl-ticker" placeholder="BBCA" value={ticker()} onInput={(e) => setTicker(e.currentTarget.value)} /></TextField>
</div> <Button size="sm" onClick={add}>Add</Button>
</div>
<Show when={err()}><p class="text-xs text-destructive">{err()}</p></Show>
<ul class="flex flex-wrap gap-1.5">
<Show when={(wl()?.watchlist || []).length} fallback={<li class="text-xs text-muted-foreground">Ketik kode saham lalu Add — mis. BBCA, TLKM, BBRI.</li>}>
<For each={wl()?.watchlist || []}>{(t) => <li class="flex items-center gap-1"><a href={`/report/${encodeURIComponent(t)}`}><Badge variant="secondary">{t}</Badge></a> <button class="text-xs text-muted-foreground hover:text-foreground" aria-label={`Hapus ${t} dari watchlist`} onClick={() => del(t)}>×</button></li>}</For>
</Show>
</ul>
</CardContent>
</Card>
); );
} }
// ChatSidebar: context-aware Q&A backed by POST /api/chat.
export function ChatSidebar() { export function ChatSidebar() {
const [msg, setMsg] = createSignal(""); const [msg, setMsg] = createSignal("");
const [log, setLog] = createSignal<{ q: string; a: string }[]>([]); const [log, setLog] = createSignal<{ q: string; a: string; err?: boolean }[]>([]);
async function send() { async function send() {
const q = msg().trim(); const q = msg().trim();
if (!q) return; if (!q) return;
@@ -41,17 +56,31 @@ export function ChatSidebar() {
const r = await api.chat(q); const r = await api.chat(q);
setLog((p) => [...p, { q, a: r.answer }].slice(-10)); setLog((p) => [...p, { q, a: r.answer }].slice(-10));
} catch (e) { } catch (e) {
setLog((p) => [...p, { q, a: String(e) }].slice(-10)); setLog((p) => [...p, { q, a: String(e), err: true }].slice(-10));
} }
} }
return ( return (
<div class="fs-card"> <Card>
<h3>AI Chat</h3> <CardHeader class="pb-2"><CardTitle class="text-base">AI Chat</CardTitle></CardHeader>
<For each={log()}> <CardContent class="space-y-2">
{(m) => <div><p><strong>You:</strong> {m.q}</p><p class="fs-muted">{m.a.slice(0, 500)}</p></div>} <Show when={log().length} fallback={<p class="text-xs text-muted-foreground">Tanya soal watchlist-mu — sistem jawab dari data yang tersimpan (bukan karangan).</p>}>
</For> <div class="max-h-64 space-y-2 overflow-y-auto">
<input class="fs-input" placeholder="Ask about your watchlist…" value={msg()} onInput={(e) => setMsg(e.currentTarget.value)} style={{ width: "220px" }} /> <For each={log()}>
<button class="fs-btn" onClick={send}>Send</button> {(m) => (
</div> <div class="text-sm">
<p><strong>You:</strong> {m.q}</p>
<p classList={{ "text-muted-foreground": !m.err, "text-destructive": !!m.err }}>{m.a.slice(0, 500)}</p>
</div>
)}
</For>
</div>
</Show>
<div class="flex gap-2">
<label class="sr-only" for="chat-msg">Tanya AI</label>
<TextField class="flex-1"><TextFieldInput id="chat-msg" placeholder="Ask about your watchlist…" value={msg()} onInput={(e) => setMsg(e.currentTarget.value)} onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") send(); }} /></TextField>
<Button size="sm" onClick={send}>Send</Button>
</div>
</CardContent>
</Card>
); );
} }
+76
View File
@@ -0,0 +1,76 @@
import { createResource, createSignal, Show } from "solid-js";
import { api, type AuthUser } from "../lib/api";
import { Button } from "./ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
// Theme: .dark class on <html> (shadcn convention). Default dark.
function theme(): string {
return localStorage.getItem("fs-theme") || "dark";
}
export function applyTheme(t: string) {
document.documentElement.classList.toggle("dark", t === "dark");
localStorage.setItem("fs-theme", t);
}
applyTheme(theme());
export function ThemeToggle() {
const [t, setT] = createSignal(theme());
return (
<Button variant="ghost" size="icon" title="Toggle light/dark" onClick={() => {
const n = t() === "dark" ? "light" : "dark";
setT(n); applyTheme(n);
}}>
{t() === "dark" ? "☀️" : "🌙"}
</Button>
);
}
export function useAuth() {
// me() is undefined while loading, null when logged out, AuthUser when in.
const [me, { refetch }] = createResource(async (): Promise<AuthUser | null> => {
try { return (await api.me()).user; } catch { return null; }
});
const [version] = createResource(async () => {
try { return await api.version(); } catch { return null; }
});
return { me, refetch, version };
}
// ButuhLogin: kartu ajakan login untuk halaman fitur yg di-hide bila logout.
export function ButuhLogin(props: { fitur: string }) {
return (
<div class="mx-auto max-w-md py-[6vh]">
<Card>
<CardHeader class="text-center">
<CardTitle class="text-2xl">🔒 {props.fitur} perlu login</CardTitle>
<CardDescription>
Biar datanya milik kamu sendiri (watchlist, notifikasi, report) —
daftar gratis, cukup username + password.
</CardDescription>
</CardHeader>
<CardContent class="text-center">
<a href="/login"><Button size="lg">Masuk / Daftar</Button></a>
</CardContent>
</Card>
</div>
);
}
export function AuthButton(props: { me: AuthUser | null | undefined; onLogout: () => void }) {
return (
<Show when={props.me} fallback={<a href="/login"><Button>Masuk / Daftar</Button></a>}>
{(u) => (
<span class="flex items-center gap-2">
<Avatar class="size-8">
{u().avatar_url ? <AvatarImage src={u().avatar_url} alt="" /> : null}
<AvatarFallback>{(u().name || u().email || "?").slice(0, 1).toUpperCase()}</AvatarFallback>
</Avatar>
<span class="hidden text-sm font-medium md:inline">{u().name || u().email}</span>
<Button variant="ghost" size="sm" onClick={props.onLogout}>Logout</Button>
</span>
)}
</Show>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { For } from "solid-js";
import type { Citation } from "../lib/api";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export function Citations(props: { items?: Citation[] | string }) {
const list = (): Citation[] => {
if (!props.items) return [];
if (typeof props.items === "string") {
try { return JSON.parse(props.items); } catch { return []; }
}
return props.items;
};
return (
<span class="inline-flex flex-wrap gap-1">
<For each={list()}>
{(c) => (
<Tooltip>
<TooltipTrigger>
<Badge variant={c.stale ? "secondary" : "outline"} class={c.stale ? "opacity-70" : ""}>
{c.endpoint} {c.ticker || ""} @ {(c.snapshot_at || "").slice(0, 10)}{c.stale ? " · stale" : ""}
</Badge>
</TooltipTrigger>
<TooltipContent>{c.endpoint} @ {c.snapshot_at}{c.stale ? " (stale)" : ""}</TooltipContent>
</Tooltip>
)}
</For>
</span>
);
}
export function RecBadge(props: { rec: string }) {
const v = () =>
/buy/i.test(props.rec) ? { variant: "success" as const, icon: "▲" }
: /avoid|sell/i.test(props.rec) ? { variant: "error" as const, icon: "▼" }
: { variant: "warning" as const, icon: "●" };
return <Badge variant={v().variant}>{v().icon} {props.rec}</Badge>;
}
export function BreakdownBars(props: { breakdown: Record<string, unknown> }) {
const entries = () => Object.entries(props.breakdown)
.filter(([, v]) => typeof v === "number")
.sort((a, b) => Math.abs(b[1] as number) - Math.abs(a[1] as number))
.slice(0, 6);
return (
<div class="space-y-1.5">
<For each={entries()}>
{([k, v]) => {
const n = v as number;
const pct = Math.min(100, Math.abs(n));
return (
<div class="flex items-center gap-2 text-xs">
<span class="w-24 shrink-0 truncate text-muted-foreground">{k}</span>
<div class="h-2 flex-1 overflow-hidden rounded-full bg-secondary">
<div
class="h-full rounded-full"
classList={{ "bg-success": n >= 0, "bg-error": n < 0 }}
style={{ width: `${pct}%`, "margin-left": n < 0 ? "auto" : undefined }}
/>
</div>
<span class="w-12 shrink-0 text-right font-mono">{n.toFixed(1)}</span>
</div>
);
}}
</For>
</div>
);
}
export function PageHead(props: { title: string; sub?: string }) {
return (
<div>
<h1 class="text-2xl font-bold tracking-tight">{props.title}</h1>
{props.sub ? <p class="text-sm text-muted-foreground">{props.sub}</p> : null}
</div>
);
}
export function EmptyState(props: { icon: string; title: string; hint?: string; action?: import("solid-js").JSX.Element }) {
return (
<div class="flex flex-col items-center gap-1 rounded-lg border border-dashed px-6 py-10 text-center">
<div class="text-3xl">{props.icon}</div>
<p class="font-medium">{props.title}</p>
{props.hint ? <p class="text-sm text-muted-foreground">{props.hint}</p> : null}
{props.action}
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type { ValidComponent } from "solid-js"
import { splitProps } from "solid-js"
import * as ImagePrimitive from "@kobalte/core/image"
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
import { cn } from "../../lib/utils"
type AvatarRootProps<T extends ValidComponent = "span"> = ImagePrimitive.ImageRootProps<T> & {
class?: string | undefined
}
const Avatar = <T extends ValidComponent = "span">(
props: PolymorphicProps<T, AvatarRootProps<T>>
) => {
const [local, others] = splitProps(props as AvatarRootProps, ["class"])
return (
<ImagePrimitive.Root
class={cn("relative flex size-10 shrink-0 overflow-hidden rounded-full", local.class)}
{...others}
/>
)
}
type AvatarImageProps<T extends ValidComponent = "img"> = ImagePrimitive.ImageImgProps<T> & {
class?: string | undefined
}
const AvatarImage = <T extends ValidComponent = "img">(
props: PolymorphicProps<T, AvatarImageProps<T>>
) => {
const [local, others] = splitProps(props as AvatarImageProps, ["class"])
return <ImagePrimitive.Img class={cn("aspect-square size-full", local.class)} {...others} />
}
type AvatarFallbackProps<T extends ValidComponent = "span"> =
ImagePrimitive.ImageFallbackProps<T> & { class?: string | undefined }
const AvatarFallback = <T extends ValidComponent = "span">(
props: PolymorphicProps<T, AvatarFallbackProps<T>>
) => {
const [local, others] = splitProps(props as AvatarFallbackProps, ["class"])
return (
<ImagePrimitive.Fallback
class={cn("flex size-full items-center justify-center bg-muted", local.class)}
{...others}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }
+49
View File
@@ -0,0 +1,49 @@
import type { Component, ComponentProps } from "solid-js"
import { splitProps } from "solid-js"
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import { cn } from "../../lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
outline: "text-foreground",
success: "border-success-foreground bg-success text-success-foreground",
warning: "border-warning-foreground bg-warning text-warning-foreground",
error: "border-error-foreground bg-error text-error-foreground"
}
},
defaultVariants: {
variant: "default"
}
}
)
type BadgeProps = ComponentProps<"div"> &
VariantProps<typeof badgeVariants> & {
round?: boolean
}
const Badge: Component<BadgeProps> = (props) => {
const [local, others] = splitProps(props, ["class", "variant", "round"])
return (
<div
class={cn(
badgeVariants({ variant: local.variant }),
local.round && "rounded-full",
local.class
)}
{...others}
/>
)
}
export type { BadgeProps }
export { Badge, badgeVariants }
+54
View File
@@ -0,0 +1,54 @@
import type { JSX, ValidComponent } from "solid-js"
import { splitProps } from "solid-js"
import * as ButtonPrimitive from "@kobalte/core/button"
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import { cn } from "../../lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline"
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 px-3 text-xs",
lg: "h-11 px-8",
icon: "size-10"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
)
type ButtonProps<T extends ValidComponent = "button"> = ButtonPrimitive.ButtonRootProps<T> &
VariantProps<typeof buttonVariants> & { class?: string | undefined; children?: JSX.Element }
const Button = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, ButtonProps<T>>
) => {
const [local, others] = splitProps(props as ButtonProps, ["variant", "size", "class"])
return (
<ButtonPrimitive.Root
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
{...others}
/>
)
}
export { Button, buttonVariants }
export type { ButtonProps }
+44
View File
@@ -0,0 +1,44 @@
import type { Component, ComponentProps } from "solid-js"
import { splitProps } from "solid-js"
import { cn } from "../../lib/utils"
const Card: Component<ComponentProps<"div">> = (props) => {
const [local, others] = splitProps(props, ["class"])
return (
<div
class={cn("rounded-lg border bg-card text-card-foreground shadow-sm", local.class)}
{...others}
/>
)
}
const CardHeader: Component<ComponentProps<"div">> = (props) => {
const [local, others] = splitProps(props, ["class"])
return <div class={cn("flex flex-col space-y-1.5 p-6", local.class)} {...others} />
}
const CardTitle: Component<ComponentProps<"h3">> = (props) => {
const [local, others] = splitProps(props, ["class"])
return (
<h3 class={cn("text-lg font-semibold leading-none tracking-tight", local.class)} {...others} />
)
}
const CardDescription: Component<ComponentProps<"p">> = (props) => {
const [local, others] = splitProps(props, ["class"])
return <p class={cn("text-sm text-muted-foreground", local.class)} {...others} />
}
const CardContent: Component<ComponentProps<"div">> = (props) => {
const [local, others] = splitProps(props, ["class"])
return <div class={cn("p-6 pt-0", local.class)} {...others} />
}
const CardFooter: Component<ComponentProps<"div">> = (props) => {
const [local, others] = splitProps(props, ["class"])
return <div class={cn("flex items-center p-6 pt-0", local.class)} {...others} />
}
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+142
View File
@@ -0,0 +1,142 @@
import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js"
import { splitProps } from "solid-js"
import * as DialogPrimitive from "@kobalte/core/dialog"
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
import { cn } from "../../lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal: Component<DialogPrimitive.DialogPortalProps> = (props) => {
const [, rest] = splitProps(props, ["children"])
return (
<DialogPrimitive.Portal {...rest}>
<div class="fixed inset-0 z-50 flex items-start justify-center sm:items-center">
{props.children}
</div>
</DialogPrimitive.Portal>
)
}
type DialogOverlayProps<T extends ValidComponent = "div"> =
DialogPrimitive.DialogOverlayProps<T> & { class?: string | undefined }
const DialogOverlay = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, DialogOverlayProps<T>>
) => {
const [, rest] = splitProps(props as DialogOverlayProps, ["class"])
return (
<DialogPrimitive.Overlay
class={cn(
"fixed inset-0 z-50 bg-background/80 data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0",
props.class
)}
{...rest}
/>
)
}
type DialogContentProps<T extends ValidComponent = "div"> =
DialogPrimitive.DialogContentProps<T> & {
class?: string | undefined
children?: JSX.Element
}
const DialogContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, DialogContentProps<T>>
) => {
const [, rest] = splitProps(props as DialogContentProps, ["class", "children"])
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
class={cn(
"fixed left-1/2 top-1/2 z-50 grid max-h-screen w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto border bg-background p-6 shadow-lg duration-200 data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95 data-[closed]:slide-out-to-left-1/2 data-[closed]:slide-out-to-top-[48%] data-[expanded]:slide-in-from-left-1/2 data-[expanded]:slide-in-from-top-[48%] sm:rounded-lgLine truncated
props.class
)}
{...rest}
>
{props.children}
<DialogPrimitive.CloseButton class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[expanded]:bg-accent data-[expanded]:text-muted-foreground">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>
<span class="sr-only">Close</span>
</DialogPrimitive.CloseButton>
</DialogPrimitive.Content>
</DialogPortal>
)
}
const DialogHeader: Component<ComponentProps<"div">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return (
<div class={cn("flex flex-col space-y-1.5 text-center sm:text-left", props.class)} {...rest} />
)
}
const DialogFooter: Component<ComponentProps<"div">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return (
<div
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", props.class)}
{...rest}
/>
)
}
type DialogTitleProps<T extends ValidComponent = "h2"> = DialogPrimitive.DialogTitleProps<T> & {
class?: string | undefined
}
const DialogTitle = <T extends ValidComponent = "h2">(
props: PolymorphicProps<T, DialogTitleProps<T>>
) => {
const [, rest] = splitProps(props as DialogTitleProps, ["class"])
return (
<DialogPrimitive.Title
class={cn("text-lg font-semibold leading-none tracking-tight", props.class)}
{...rest}
/>
)
}
type DialogDescriptionProps<T extends ValidComponent = "p"> =
DialogPrimitive.DialogDescriptionProps<T> & {
class?: string | undefined
}
const DialogDescription = <T extends ValidComponent = "p">(
props: PolymorphicProps<T, DialogDescriptionProps<T>>
) => {
const [, rest] = splitProps(props as DialogDescriptionProps, ["class"])
return (
<DialogPrimitive.Description
class={cn("text-sm text-muted-foreground", props.class)}
{...rest}
/>
)
}
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
}
+20
View File
@@ -0,0 +1,20 @@
import type { Component, ComponentProps } from "solid-js"
import { splitProps } from "solid-js"
import { cn } from "../../lib/utils"
const Label: Component<ComponentProps<"label">> = (props) => {
const [local, others] = splitProps(props, ["class"])
return (
<label
class={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
local.class
)}
{...others}
/>
)
}
export { Label }
Loaded 50 of 71 files, more files were not shown because too many files have changed in this diff. Show more