Compare commits

..
20 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
41 changed files with 1818 additions and 261 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
+2
View File
@@ -48,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) }
+7 -2
View File
@@ -61,9 +61,14 @@ func main() {
} }
} }
} }
// 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)
}
+29 -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,11 +268,11 @@ 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)
} }
+130 -3
View File
@@ -77,9 +77,13 @@ func (s *Server) AuthCallback(w http.ResponseWriter, r *http.Request) {
fail("user store unavailable") fail("user store unavailable")
return return
} }
// Ensure the login user owns the default watchlist on first login. // Ensure the login user owns the "semua" default watchlist on first login.
if wl, _ := s.DB.Watchlist(user.UserKey); len(wl) == 0 { if wl, _ := s.DB.Watchlist(user.UserKey); len(wl) == 0 {
for _, t := range s.Cfg.Watchlist { seeds, _ := s.DB.AllTickers()
if len(seeds) == 0 {
seeds = s.Cfg.Watchlist
}
for _, t := range seeds {
_ = s.DB.AddWatch(user.UserKey, t) _ = s.DB.AddWatch(user.UserKey, t)
} }
} }
@@ -90,7 +94,7 @@ func (s *Server) AuthCallback(w http.ResponseWriter, r *http.Request) {
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true, Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
Secure: true, SameSite: http.SameSiteLaxMode, Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionTTL), Expires: time.Now().Add(sessionTTL),
}) })
http.Redirect(w, r, "/", http.StatusFound) http.Redirect(w, r, "/", http.StatusFound)
@@ -119,6 +123,7 @@ func (s *Server) AuthLogout(w http.ResponseWriter, r *http.Request) {
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: "", Path: "/", HttpOnly: true, Name: "fs_session", Value: "", Path: "/", HttpOnly: true,
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
MaxAge: -1, MaxAge: -1,
}) })
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
@@ -133,6 +138,128 @@ func (s *Server) sessionUser(r *http.Request) (*store.User, bool) {
return s.DB.SessionUser(c.Value) 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. // googleTokenResp is the subset of oauth2.googleapis.com/token we need.
type googleTokenResp struct { type googleTokenResp struct {
IDToken string `json:"id_token"` IDToken string `json:"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
}
+29 -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,10 +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 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 {
@@ -44,12 +60,21 @@ 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 { if px, dx, err := s.DB.LatestClose(tk); err == nil {
closes = append(closes, map[string]any{"ticker": tk, "close": px, "date": dx}) 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 {
accs = accs[:5] accs = accs[:5]
@@ -57,6 +82,7 @@ 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, "closes": closes, "citations": cites, "top_accumulation": accs, "closes": closes, "citations": cites,
"foreign_tickers": fkTickers,
}) })
} }
@@ -136,7 +162,7 @@ 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], "start": startOf(dates), "end": dates[len(dates)-1],
}) })
} }
+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())
}
}
+6 -1
View File
@@ -8,9 +8,14 @@ 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()
+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
+8 -3
View File
@@ -28,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})
} }
+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 {
+51 -25
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,39 +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("/auth/start", s.AuthStart) r.Get("/auth/start", s.AuthStart)
r.Get("/auth/callback", s.AuthCallback) r.Get("/auth/callback", s.AuthCallback)
r.Get("/auth/me", s.AuthMe) r.Get("/auth/me", s.AuthMe)
r.Post("/auth/logout", s.AuthLogout) r.Post("/auth/logout", s.AuthLogout)
r.Get("/stream", s.Stream) // 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())
+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"
}
+17 -17
View File
@@ -81,23 +81,23 @@ 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"), GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
GoogleRedirectURL: os.Getenv("GOOGLE_REDIRECT_URL"), 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)
}
+2 -2
View File
@@ -47,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)
@@ -79,7 +79,7 @@ func (b *Builder) Build(ctx context.Context, ticker, profile string) (Report, in
narasi := b.narasiAwam(ctx, ticker, synth, sections) narasi := b.narasiAwam(ctx, ticker, synth, sections)
raw, _ := json.Marshal(map[string]any{"ticker": ticker, "sections": sections, "synthesis": synth, "narasi_awam": narasi}) 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
} }
+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")
+79 -1
View File
@@ -5,10 +5,25 @@ import (
"encoding/hex" "encoding/hex"
"strings" "strings"
"time" "time"
"golang.org/x/crypto/bcrypt"
) )
// User is one Google-authenticated account. UserKey (`u:<google_sub>`) is // User is one Google-authenticated account. UserKey (`u:<google_sub>`) is
// the scoping key used by every user-owned table. // 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 { type User struct {
ID int64 ID int64
GoogleSub string GoogleSub string
@@ -108,3 +123,66 @@ func (db *DB) DeleteSession(token string) error {
_, err := db.Exec(`DELETE FROM sessions WHERE token=?`, token) _, err := db.Exec(`DELETE FROM sessions WHERE token=?`, token)
return err 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,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 '';
+246 -20
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)
@@ -764,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.
@@ -967,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")
}
}
+28 -13
View File
@@ -11,27 +11,34 @@ export function WatchlistDrawer() {
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 (
<Card> <Card>
<CardHeader class="pb-2"><CardTitle class="text-base">Watchlist</CardTitle></CardHeader> <CardHeader class="pb-2"><CardTitle class="text-base">Watchlist</CardTitle></CardHeader>
<CardContent class="space-y-2"> <CardContent class="space-y-2">
<div class="flex gap-2"> <div class="flex gap-2">
<TextField class="w-24"><TextFieldInput placeholder="BBCA" value={ticker()} onInput={(e) => setTicker(e.currentTarget.value)} /></TextField> <label class="sr-only" for="wl-ticker">Tambah ticker</label>
<TextField class="w-24"><TextFieldInput id="wl-ticker" placeholder="BBCA" value={ticker()} onInput={(e) => setTicker(e.currentTarget.value)} /></TextField>
<Button size="sm" onClick={add}>Add</Button> <Button size="sm" onClick={add}>Add</Button>
</div> </div>
<Show when={err()}><p class="text-xs text-destructive">{err()}</p></Show> <Show when={err()}><p class="text-xs text-destructive">{err()}</p></Show>
<ul class="flex flex-wrap gap-1.5"> <ul class="flex flex-wrap gap-1.5">
<For each={wl()?.watchlist || []}>{(t) => <li><a href={`/report/${t}`}><Badge variant="secondary">{t}</Badge></a> <button class="text-xs text-muted-foreground hover:text-foreground" onClick={() => del(t)}>×</button></li>}</For> <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> </ul>
</CardContent> </CardContent>
</Card> </Card>
@@ -40,7 +47,7 @@ export function WatchlistDrawer() {
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;
@@ -49,20 +56,28 @@ 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 (
<Card> <Card>
<CardHeader class="pb-2"><CardTitle class="text-base">AI Chat</CardTitle></CardHeader> <CardHeader class="pb-2"><CardTitle class="text-base">AI Chat</CardTitle></CardHeader>
<CardContent class="space-y-2"> <CardContent class="space-y-2">
<div class="max-h-64 space-y-2 overflow-y-auto"> <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 each={log()}> <div class="max-h-64 space-y-2 overflow-y-auto">
{(m) => <div class="text-sm"><p><strong>You:</strong> {m.q}</p><p class="text-muted-foreground">{m.a.slice(0, 500)}</p></div>} <For each={log()}>
</For> {(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"> <div class="flex gap-2">
<TextField class="flex-1"><TextFieldInput placeholder="Ask about your watchlist…" value={msg()} onInput={(e) => setMsg(e.currentTarget.value)} onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") send(); }} /></TextField> <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> <Button size="sm" onClick={send}>Send</Button>
</div> </div>
</CardContent> </CardContent>
+27 -4
View File
@@ -2,6 +2,7 @@ import { createResource, createSignal, Show } from "solid-js";
import { api, type AuthUser } from "../lib/api"; import { api, type AuthUser } from "../lib/api";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; 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. // Theme: .dark class on <html> (shadcn convention). Default dark.
function theme(): string { function theme(): string {
@@ -27,17 +28,39 @@ export function ThemeToggle() {
} }
export function useAuth() { export function useAuth() {
// me() is undefined while loading, null when logged out, AuthUser when in.
const [me, { refetch }] = createResource(async (): Promise<AuthUser | null> => { const [me, { refetch }] = createResource(async (): Promise<AuthUser | null> => {
try { return (await api.me()).user; } catch { return null; } try { return (await api.me()).user; } catch { return null; }
}); });
return { me, refetch }; 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 }) { export function AuthButton(props: { me: AuthUser | null | undefined; onLogout: () => void }) {
return ( return (
<Show when={props.me} fallback={ <Show when={props.me} fallback={<a href="/login"><Button>Masuk / Daftar</Button></a>}>
<a href="/api/auth/start"><Button>Sign in with Google</Button></a>
}>
{(u) => ( {(u) => (
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<Avatar class="size-8"> <Avatar class="size-8">
+120 -37
View File
@@ -1,12 +1,12 @@
import { render } from "solid-js/web"; import { render } from "solid-js/web";
import { Router, Route, useLocation, useNavigate, useParams, type RouteSectionProps } from "@solidjs/router"; import { Router, Route, useLocation, useNavigate, type RouteSectionProps } from "@solidjs/router";
import { createSignal, Show } from "solid-js"; import { createResource, createSignal, createEffect, Show } from "solid-js";
import { WatchlistDrawer, ChatSidebar } from "./components/WatchlistChat"; import { WatchlistDrawer, ChatSidebar } from "./components/WatchlistChat";
import { ThemeToggle, useAuth, AuthButton } from "./components/auth"; import { ThemeToggle, useAuth, AuthButton, ButuhLogin } from "./components/auth";
import { Button } from "./components/ui/button"; import { Button } from "./components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./components/ui/card";
import { TextField, TextFieldInput } from "./components/ui/text-field"; import { TextField, TextFieldInput } from "./components/ui/text-field";
import { api } from "./lib/api"; import { api, AUTH_EXPIRED_EVENT } from "./lib/api";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
import Routines from "./pages/Routines"; import Routines from "./pages/Routines";
import Screener from "./pages/Screener"; import Screener from "./pages/Screener";
@@ -15,7 +15,7 @@ import Report from "./pages/Report";
import Portfolio from "./pages/Portfolio"; import Portfolio from "./pages/Portfolio";
import "./styles/globals.css"; import "./styles/globals.css";
const NAV = [ const NAV_ALL = [
{ href: "/", icon: "📊", label: "Dashboard" }, { href: "/", icon: "📊", label: "Dashboard" },
{ href: "/routines", icon: "🗓️", label: "Routines" }, { href: "/routines", icon: "🗓️", label: "Routines" },
{ href: "/screener", icon: "🔍", label: "Screener" }, { href: "/screener", icon: "🔍", label: "Screener" },
@@ -23,30 +23,40 @@ const NAV = [
{ href: "/portfolio", icon: "💼", label: "Portfolio" }, { href: "/portfolio", icon: "💼", label: "Portfolio" },
]; ];
function Nav() { function Nav(props: { loggedIn: boolean }) {
const loc = useLocation(); const loc = useLocation();
// Belum login: hanya Dashboard yg terlihat (fitur lain di-hide).
const items = () => props.loggedIn ? NAV_ALL : NAV_ALL.slice(0, 1);
const isActive = (href: string) => loc.pathname === href || (href !== "/" && loc.pathname.startsWith(href));
return ( return (
<nav class="flex w-56 shrink-0 flex-col gap-1 border-r bg-card p-4 max-lg:hidden"> <nav class="flex w-56 shrink-0 flex-col gap-1 border-r bg-card p-4 max-lg:hidden">
<div class="mb-4 px-2 text-xl font-bold tracking-tight"> <div class="mb-5 px-2">
Flow<span class="text-primary">Sight</span> <a href="/" class="flex items-center gap-2 text-xl font-bold tracking-tight">
<span class="grid size-7 place-items-center rounded-lg bg-primary text-sm text-primary-foreground">📈</span>
Flow<span class="text-primary">Sight</span>
</a>
</div> </div>
{NAV.map((n) => ( {items().map((n) => (
<a <a
href={n.href} href={n.href}
class={`flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground ${loc.pathname === n.href ? "bg-accent text-accent-foreground" : "text-muted-foreground"}`} class={`relative flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground ${isActive(n.href) ? "bg-accent text-accent-foreground" : "text-muted-foreground"}`}
> >
<span>{n.icon}</span> {n.label} <span class="text-base leading-none">{n.icon}</span> {n.label}
<Show when={isActive(n.href)}>
<span class="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-primary" />
</Show>
</a> </a>
))} ))}
</nav> </nav>
); );
} }
function MobileNav() { function MobileNav(props: { loggedIn: boolean }) {
const loc = useLocation(); const loc = useLocation();
const items = () => props.loggedIn ? NAV_ALL : NAV_ALL.slice(0, 1);
return ( return (
<nav class="fixed inset-x-0 bottom-0 z-40 flex border-t bg-card px-2 py-1 lg:hidden"> <nav class="fixed inset-x-0 bottom-0 z-40 flex border-t bg-card px-2 py-1 lg:hidden">
{NAV.map((n) => ( {items().map((n) => (
<a <a
href={n.href} href={n.href}
class={`flex flex-1 flex-col items-center rounded-md py-1.5 text-[11px] font-medium ${loc.pathname === n.href ? "text-primary" : "text-muted-foreground"}`} class={`flex flex-1 flex-col items-center rounded-md py-1.5 text-[11px] font-medium ${loc.pathname === n.href ? "text-primary" : "text-muted-foreground"}`}
@@ -58,38 +68,82 @@ function MobileNav() {
); );
} }
function TickerSearch() { function TickerSearch(props: { loggedIn: boolean }) {
const nav = useNavigate(); const nav = useNavigate();
const [q, setQ] = createSignal(""); const [q, setQ] = createSignal("");
const go = () => { const go = (t: string) => {
const t = q().toUpperCase().trim(); const v = t.toUpperCase().trim();
if (t) nav(`/report/${t}`); // Report butuh login: arahkan yg belum login ke /login.
if (v) nav(props.loggedIn ? `/report/${v}` : "/login");
}; };
return ( return (
<form class="flex max-w-sm flex-1 items-center gap-2" onSubmit={(e) => { e.preventDefault(); go(); }}> <form class="flex max-w-sm flex-1 items-center gap-2" onSubmit={(e) => { e.preventDefault(); go(q()); }}>
<TextField class="flex-1"> <div class="relative flex-1">
<TextFieldInput placeholder="Ticker → report (e.g. BBCA)" value={q()} <TextFieldInput
onInput={(e) => setQ(e.currentTarget.value)} /> placeholder="Cari kode saham…"
</TextField> value={q()}
<Button type="submit" variant="secondary">Open</Button> aria-label="Cari kode saham"
onInput={(e) => setQ(e.currentTarget.value)}
onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); go(q()); } }}
class="h-9 pr-8"
/>
<span class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">🔍</span>
</div>
<Button type="submit" variant="secondary" class="h-9 px-3">Buka</Button>
</form> </form>
); );
} }
function LoginPage() { function LoginPage() {
const params = useParams<{ error?: string }>();
const err = () => new URLSearchParams(window.location.search).get("error"); const err = () => new URLSearchParams(window.location.search).get("error");
void params; const [mode, setMode] = createSignal<"masuk" | "daftar">("masuk");
const [username, setUsername] = createSignal("");
const [password, setPassword] = createSignal("");
const [busy, setBusy] = createSignal(false);
const [formErr, setFormErr] = createSignal("");
const [version] = createResource(() => api.version().catch(() => null));
async function submit(e: Event) {
e.preventDefault();
setFormErr(""); setBusy(true);
try {
if (mode() === "daftar") await api.signup(username().trim(), password());
else await api.login(username().trim(), password());
window.location.href = "/";
} catch (e2) { setFormErr(String(e2)); }
finally { setBusy(false); }
}
return ( return (
<div class="mx-auto max-w-md py-[8vh]"> <div class="mx-auto flex min-h-[70vh] w-full max-w-md flex-col justify-center py-[6vh]">
<div class="mb-6 flex items-center justify-center gap-2 text-2xl font-bold tracking-tight">
<span class="grid size-9 place-items-center rounded-xl bg-primary text-lg text-primary-foreground">📈</span>
Flow<span class="text-primary">Sight</span>
</div>
<Card> <Card>
<CardHeader class="text-center"> <CardHeader class="text-center">
<CardTitle class="text-2xl">Welcome to FlowSight</CardTitle> <CardTitle class="text-2xl">Masuk ke FlowSight 👋</CardTitle>
<CardDescription>Sign in with Google to get your own watchlist, routines, alerts and reports.</CardDescription> <CardDescription>
Daftar gratis pakai username + password — watchlist, notifikasi,
dan report jadi milik kamu sendiri.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="text-center"> <CardContent class="space-y-3">
<Show when={err()}><p class="mb-3 text-sm text-destructive">Login failed: {err()}</p></Show> <Show when={err()}><p class="text-sm text-destructive">Login gagal: {err()}</p></Show>
<a href="/api/auth/start"><Button size="lg">Sign in with Google</Button></a> <div class="flex gap-1 rounded-md bg-muted p-1">
<Button size="sm" variant={mode() === "masuk" ? "default" : "ghost"} class="flex-1" onClick={() => setMode("masuk")}>Masuk</Button>
<Button size="sm" variant={mode() === "daftar" ? "default" : "ghost"} class="flex-1" onClick={() => setMode("daftar")}>Daftar baru</Button>
</div>
<form class="space-y-3" onSubmit={submit}>
<TextField><TextFieldInput placeholder="username (mis. budi)" value={username()} onInput={(e) => setUsername(e.currentTarget.value)} autocomplete="username" /></TextField>
<TextField><TextFieldInput type="password" placeholder="password (min. 8 karakter)" value={password()} onInput={(e) => setPassword(e.currentTarget.value)} autocomplete={mode() === "daftar" ? "new-password" : "current-password"} /></TextField>
<Show when={formErr()}><p class="text-sm text-destructive">{formErr()}</p></Show>
<Button type="submit" size="lg" class="w-full" disabled={busy()}>{busy() ? "…" : mode() === "daftar" ? "Daftar & Masuk" : "Masuk"}</Button>
</form>
<Show when={version()?.google_configured}>
<div class="pt-1 text-center">
<p class="mb-2 text-xs text-muted-foreground">atau</p>
<a href="/api/auth/start"><Button size="lg" variant="outline" class="w-full">Sign in with Google</Button></a>
</div>
</Show>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -98,13 +152,24 @@ function LoginPage() {
function Shell(props: RouteSectionProps) { function Shell(props: RouteSectionProps) {
const { me, refetch } = useAuth(); const { me, refetch } = useAuth();
const logout = async () => { await api.logout(); refetch(); }; const nav = useNavigate();
// Any 401 on a gated API while logged in → session expired; go to login.
createEffect(() => {
const h = () => { if (me()) { refetch(); nav("/login"); } };
window.addEventListener(AUTH_EXPIRED_EVENT, h);
return () => window.removeEventListener(AUTH_EXPIRED_EVENT, h);
});
const logout = async () => {
try { await api.logout(); } catch { /* server gone — clear locally anyway */ }
refetch();
nav("/");
};
return ( return (
<div class="flex min-h-screen bg-background text-foreground"> <div class="flex min-h-screen bg-background text-foreground">
<Nav /> <Nav loggedIn={!!me()} />
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
<header class="sticky top-0 z-30 flex items-center gap-3 border-b bg-background/95 px-4 py-2.5 backdrop-blur"> <header class="sticky top-0 z-30 flex items-center gap-3 border-b bg-background/95 px-4 py-2.5 backdrop-blur">
<TickerSearch /> <TickerSearch loggedIn={!!me()} />
<span class="flex-1" /> <span class="flex-1" />
<ThemeToggle /> <ThemeToggle />
<AuthButton me={me()} onLogout={logout} /> <AuthButton me={me()} onLogout={logout} />
@@ -112,14 +177,32 @@ function Shell(props: RouteSectionProps) {
<main class="mx-auto w-full max-w-6xl flex-1 space-y-6 p-4 pb-20 lg:p-6">{props.children}</main> <main class="mx-auto w-full max-w-6xl flex-1 space-y-6 p-4 pb-20 lg:p-6">{props.children}</main>
</div> </div>
<aside class="hidden w-80 shrink-0 flex-col gap-4 border-l bg-card p-4 xl:flex"> <aside class="hidden w-80 shrink-0 flex-col gap-4 border-l bg-card p-4 xl:flex">
<WatchlistDrawer /> <Show when={me()} fallback={
<ChatSidebar /> <Card><CardHeader class="pb-2"><CardTitle class="text-base">🔒 Login dulu</CardTitle></CardHeader>
<CardContent class="space-y-2"><p class="text-sm text-muted-foreground">Watchlist, filter, dan AI chat milikmu sendiri setelah login.</p>
<a href="/login"><Button size="sm" class="w-full">Masuk / Daftar</Button></a></CardContent></Card>
}>
<WatchlistDrawer />
<ChatSidebar />
</Show>
</aside> </aside>
<MobileNav /> <MobileNav loggedIn={!!me()} />
</div> </div>
); );
} }
// Gate: bungkus halaman fitur — belum login tampilkan ButuhLogin.
export function Gate(props: { fitur: string; children: import("solid-js").JSX.Element }) {
const { me } = useAuth();
return (
<Show when={me() !== undefined} fallback={<p class="text-sm text-muted-foreground">Memeriksa login…</p>}>
<Show when={me()} fallback={<ButuhLogin fitur={props.fitur} />}>
{props.children}
</Show>
</Show>
);
}
render( render(
() => ( () => (
<Router root={Shell}> <Router root={Shell}>
+20 -8
View File
@@ -1,11 +1,19 @@
// Typed backend client (backend is the only source of truth; web never calls Sectors directly). // Typed backend client (backend is the only source of truth; web never calls Sectors directly).
const BASE = ""; const BASE = "";
function headers(): HeadersInit { function headers(): HeadersInit {
return { "Content-Type": "application/json", "X-User-Key": localStorage.getItem("fs-key") || "demo" }; // fs-key is legacy demo identity; cookie auth wins server-side. Keep sending
// session cookie implicitly (same-origin default) — no token in localStorage.
return { "Content-Type": "application/json" };
}
// auth-expired: global signal → Shell refetches /me and redirects to /login.
export const AUTH_EXPIRED_EVENT = "fs:auth-expired";
function notifyAuthExpired() {
try { window.dispatchEvent(new CustomEvent(AUTH_EXPIRED_EVENT)); } catch { /* ignore */ }
} }
async function req<T>(path: string, init?: RequestInit): Promise<T> { async function req<T>(path: string, init?: RequestInit): Promise<T> {
const r = await fetch(BASE + path, { ...init, headers: { ...headers(), ...(init?.headers || {}) } }); const r = await fetch(BASE + path, { ...init, headers: { ...headers(), ...(init?.headers || {}) } });
if (!r.ok) { if (!r.ok) {
if (r.status === 401 && !path.startsWith("/api/auth/")) notifyAuthExpired();
let msg = r.statusText; let msg = r.statusText;
try { const e = await r.json(); msg = e?.error?.message || msg; } catch { /* keep status */ } try { const e = await r.json(); msg = e?.error?.message || msg; } catch { /* keep status */ }
throw new Error(msg); throw new Error(msg);
@@ -17,7 +25,7 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
} }
export interface Citation { endpoint: string; snapshot_at: string; ticker?: string; stale?: boolean } export interface Citation { endpoint: string; snapshot_at: string; ticker?: string; stale?: boolean }
export interface Health { last_cycle_at: string; credits_today: number; scheduler_ok: boolean; stale_flags: string[] } export interface Health { last_cycle_at: string; credits_today: number; scheduler_ok: boolean; stale_flags: string[] }
export interface FlowSummary { date: string; foreign_net_total: number; top_accumulation: { ticker: string; net_sum: number; brokers: number }[]; closes: { ticker: string; close: number; date: string }[]; citations: Citation[] } export interface FlowSummary { date: string; foreign_net_total: number; top_accumulation: { ticker: string; net_sum: number; brokers: number }[]; closes: { ticker: string; close: number; date: string }[]; citations: Citation[]; foreign_tickers?: string[] }
export interface ScreenRow { symbol: string; name: string; composite: number; breakdown: Record<string, unknown>; citations: Citation[] } export interface ScreenRow { symbol: string; name: string; composite: number; breakdown: Record<string, unknown>; citations: Citation[] }
export interface Routine { id: number; user_key: string; type: string; schedule_cron: string; channels_json?: string; channels?: string[]; enabled: boolean; last_run?: unknown } export interface Routine { id: number; user_key: string; type: string; schedule_cron: string; channels_json?: string; channels?: string[]; enabled: boolean; last_run?: unknown }
export interface AlertItem { id: number; user_key: string; name: string; rule_json: string; channels_json: string; last_fired: string } export interface AlertItem { id: number; user_key: string; name: string; rule_json: string; channels_json: string; last_fired: string }
@@ -27,12 +35,13 @@ export interface ForeignSeries { dates: string[]; nets: number[]; reversal: bool
export const api = { export const api = {
health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`), health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`),
flowSummary: () => req<FlowSummary>("/api/flow/summary"), flowSummary: () => req<FlowSummary>("/api/flow/summary"),
flowBroker: (ticker: string) => req<{ buyers: unknown[]; sellers: unknown[]; citations: Citation[] }>(`/api/flow/broker?ticker=${ticker}`), flowBroker: (ticker: string) => req<{ buyers: unknown[]; sellers: unknown[]; citations: Citation[] }>(`/api/flow/broker?ticker=${encodeURIComponent(ticker)}`),
flowForeign: (ticker: string) => req<ForeignSeries>(`/api/flow/foreign?ticker=${ticker}`), flowForeign: (ticker: string) => req<ForeignSeries>(`/api/flow/foreign?ticker=${encodeURIComponent(ticker)}`),
screen: (body: Record<string, unknown>) => req<{ rows: ScreenRow[]; count: number }>("/api/screen", { method: "POST", body: JSON.stringify(body) }), screen: (body: Record<string, unknown>) => req<{ rows: ScreenRow[]; count: number }>("/api/screen", { method: "POST", body: JSON.stringify(body) }),
routines: () => req<{ routines: Routine[] }>("/api/routines"), routines: () => req<{ routines: Routine[] }>("/api/routines"),
createRoutine: (body: Record<string, unknown>) => req<{ id: number }>("/api/routines", { method: "POST", body: JSON.stringify(body) }), createRoutine: (body: Record<string, unknown>) => req<{ id: number }>("/api/routines", { method: "POST", body: JSON.stringify(body) }),
updateRoutine: (id: number, body: Record<string, unknown>) => req<unknown>(`/api/routines/${id}`, { method: "PATCH", body: JSON.stringify(body) }), updateRoutine: (id: number, body: Record<string, unknown>) => req<unknown>(`/api/routines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteRoutine: (id: number) => req<unknown>(`/api/routines/${id}`, { method: "DELETE" }),
runs: (routine_id?: number) => req<{ runs: Record<string, unknown>[] }>(`/api/routine-runs${routine_id ? `?routine_id=${routine_id}` : ""}`), runs: (routine_id?: number) => req<{ runs: Record<string, unknown>[] }>(`/api/routine-runs${routine_id ? `?routine_id=${routine_id}` : ""}`),
briefing: () => req<{ date: string; payload: string; citations: string; narasi?: string }>("/api/briefing/today"), briefing: () => req<{ date: string; payload: string; citations: string; narasi?: string }>("/api/briefing/today"),
destinations: () => req<{ destinations: { id: number; kind: string; label: string; enabled: boolean; configured: boolean }[] }>("/api/destinations"), destinations: () => req<{ destinations: { id: number; kind: string; label: string; enabled: boolean; configured: boolean }[] }>("/api/destinations"),
@@ -43,17 +52,20 @@ export const api = {
createAlert: (body: Record<string, unknown>) => req<{ id: number }>("/api/alerts", { method: "POST", body: JSON.stringify(body) }), createAlert: (body: Record<string, unknown>) => req<{ id: number }>("/api/alerts", { method: "POST", body: JSON.stringify(body) }),
deleteAlert: (id: number) => req<unknown>(`/api/alerts/${id}`, { method: "DELETE" }), deleteAlert: (id: number) => req<unknown>(`/api/alerts/${id}`, { method: "DELETE" }),
alertEvents: (since = "2000-01-01", ticker = "") => req<{ events: Record<string, unknown>[] }>(`/api/alert-events?since=${since}${ticker ? `&ticker=${ticker}` : ""}`), alertEvents: (since = "2000-01-01", ticker = "") => req<{ events: Record<string, unknown>[] }>(`/api/alert-events?since=${since}${ticker ? `&ticker=${ticker}` : ""}`),
report: (ticker: string, profile = "moderate") => req<ReportPayload>(`/api/report/${ticker}?profile=${profile}`, { method: "POST" }), report: (ticker: string, profile = "moderate") => req<ReportPayload>(`/api/report/${encodeURIComponent(ticker)}?profile=${profile}`, { method: "POST" }),
reportMd: (ticker: string) => req<string>(`/api/report/${ticker}?format=md`, { method: "POST" }), reportMd: (ticker: string) => req<string>(`/api/report/${encodeURIComponent(ticker)}?format=md`, { method: "POST" }),
interrogate: (ticker: string, question: string, report_id?: number) => req<{ answer: string; report_id: number; citations: unknown }>(`/api/report/${ticker}/ask`, { method: "POST", body: JSON.stringify({ question, report_id }) }), interrogate: (ticker: string, question: string, report_id?: number) => req<{ answer: string; report_id: number; citations: unknown }>(`/api/report/${encodeURIComponent(ticker)}/ask`, { method: "POST", body: JSON.stringify({ question, report_id }) }),
watchlist: () => req<{ watchlist: string[] }>("/api/watchlist"), watchlist: () => req<{ watchlist: string[] }>("/api/watchlist"),
addWatch: (ticker: string) => req<unknown>("/api/watchlist", { method: "POST", body: JSON.stringify({ ticker }) }), addWatch: (ticker: string) => req<unknown>("/api/watchlist", { method: "POST", body: JSON.stringify({ ticker }) }),
removeWatch: (ticker: string) => req<unknown>(`/api/watchlist/${ticker}`, { method: "DELETE" }), removeWatch: (ticker: string) => req<unknown>(`/api/watchlist/${encodeURIComponent(ticker)}`, { method: "DELETE" }),
risk: () => req<{ concentration: { ticker: string; sector: string; weight: number }[]; correlation: Record<string, Record<string, number>>; beta: number; warnings: string[] }>("/api/portfolio/risk"), risk: () => req<{ concentration: { ticker: string; sector: string; weight: number }[]; correlation: Record<string, Record<string, number>>; beta: number; warnings: string[] }>("/api/portfolio/risk"),
accuracy: () => req<{ agents: { agent: string; calls: number; resolved: number; hits: number; hit_rate: number }[] }>("/api/accuracy"), accuracy: () => req<{ agents: { agent: string; calls: number; resolved: number; hits: number; hit_rate: number }[] }>("/api/accuracy"),
chat: (message: string) => req<{ answer: string }>(`/api/chat`, { method: "POST", body: JSON.stringify({ message }) }), chat: (message: string) => req<{ answer: string }>(`/api/chat`, { method: "POST", body: JSON.stringify({ message }) }),
me: () => req<{ user: AuthUser }>(`/api/auth/me`), me: () => req<{ user: AuthUser }>(`/api/auth/me`),
logout: () => req<{ ok: boolean }>(`/api/auth/logout`, { method: "POST" }), logout: () => req<{ ok: boolean }>(`/api/auth/logout`, { method: "POST" }),
signup: (username: string, password: string) => req<{ user: AuthUser }>(`/api/auth/signup`, { method: "POST", body: JSON.stringify({ username, password }) }),
login: (username: string, password: string) => req<{ user: AuthUser }>(`/api/auth/login`, { method: "POST", body: JSON.stringify({ username, password }) }),
version: () => req<{ commit: string; started_at: string; google_configured: boolean }>(`/api/version`),
}; };
// SSE hook helper: subscribe to channels agents|alerts|activity. // SSE hook helper: subscribe to channels agents|alerts|activity.
export function subscribeSSE(onEvent: (channel: string, data: string) => void): () => void { export function subscribeSSE(onEvent: (channel: string, data: string) => void): () => void {
+26 -7
View File
@@ -15,7 +15,7 @@ const TEMPLATES: { label: string; desc: string; rule: Record<string, unknown> }[
{ label: "📢 Semua gerakan mencolok", desc: "Skor gabungan di atas ambang", rule: { all: [{ field: "score", op: ">=", value: 60 }] } }, { label: "📢 Semua gerakan mencolok", desc: "Skor gabungan di atas ambang", rule: { all: [{ field: "score", op: ">=", value: 60 }] } },
]; ];
export default function Alerts() { function AlertsInner() {
const [alerts, { refetch }] = createResource(() => api.alerts()); const [alerts, { refetch }] = createResource(() => api.alerts());
const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 30))); const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 30)));
const [dests, { refetch: refetchDests }] = createResource(() => api.destinations()); const [dests, { refetch: refetchDests }] = createResource(() => api.destinations());
@@ -28,14 +28,17 @@ export default function Alerts() {
const [dchat, setDchat] = createSignal(""); const [dchat, setDchat] = createSignal("");
const [dhook, setDhook] = createSignal(""); const [dhook, setDhook] = createSignal("");
const [derr, setDerr] = createSignal(""); const [derr, setDerr] = createSignal("");
const [err, setErr] = createSignal("");
async function create() { async function create() {
setBusy(true); setBusy(true);
try { try {
await api.createAlert({ name: tpl().label, rule: tpl().rule, channels: channels().split(",").map((c) => c.trim()).filter(Boolean) }); await api.createAlert({ name: tpl().label, rule: tpl().rule, channels: channels().split(",").map((c) => c.trim()).filter(Boolean) });
setChannels("");
refetch(); refetch();
} finally { setBusy(false); } } catch (e) { setErr(String(e)); }
finally { setBusy(false); }
} }
async function del(id: number) { await api.deleteAlert(id); refetch(); } async function del(id: number) { try { await api.deleteAlert(id); refetch(); } catch (e) { setErr(String(e)); } }
async function addDest() { async function addDest() {
setDerr(""); setDerr("");
try { try {
@@ -47,11 +50,17 @@ export default function Alerts() {
refetchDests(); refetchDests();
} catch (e) { setDerr(String(e)); } } catch (e) { setDerr(String(e)); }
} }
async function toggleDest(id: number, enabled: boolean) { await api.updateDestination(id, { enabled: !enabled }); refetchDests(); } const fmtDate = (s: unknown) => {
async function delDest(id: number) { await api.deleteDestination(id); refetchDests(); } const t = String(s || "");
const d = new Date(t);
return Number.isNaN(d.getTime()) ? t : d.toLocaleString("id-ID", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
};
async function toggleDest(id: number, enabled: boolean) { try { await api.updateDestination(id, { enabled: !enabled }); refetchDests(); } catch (e) { setDerr(String(e)); } }
async function delDest(id: number) { try { await api.deleteDestination(id); refetchDests(); } catch (e) { setDerr(String(e)); } }
return ( return (
<div class="space-y-4"> <div class="space-y-4">
<PageHead title="Notifikasi otomatis 🔔" sub="Pilih kejadian yang mau kamu dikabari — tanpa perlu paham angka." /> <PageHead title="Notifikasi otomatis 🔔" sub="Pilih kejadian yang mau kamu dikabari — tanpa perlu paham angka." />
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
<Card> <Card>
<CardHeader><CardTitle>1. Mau dikabari soal apa?</CardTitle><CardDescription>Pilih satu template <Term kata="alert" />.</CardDescription></CardHeader> <CardHeader><CardTitle>1. Mau dikabari soal apa?</CardTitle><CardDescription>Pilih satu template <Term kata="alert" />.</CardDescription></CardHeader>
<CardContent class="space-y-3"> <CardContent class="space-y-3">
@@ -107,7 +116,11 @@ export default function Alerts() {
<CardHeader><CardTitle>Notifikasi terpasang</CardTitle></CardHeader> <CardHeader><CardTitle>Notifikasi terpasang</CardTitle></CardHeader>
<CardContent> <CardContent>
<Show when={(alerts()?.alerts || []).length} fallback={<EmptyState icon="🔔" title="Belum ada" hint="Pilih template di atas, sekali klik." />}> <Show when={(alerts()?.alerts || []).length} fallback={<EmptyState icon="🔔" title="Belum ada" hint="Pilih template di atas, sekali klik." />}>
<ul class="space-y-1.5 text-sm"><For each={alerts()?.alerts || []}>{(a) => <li class="flex items-center gap-2">{a.name} <span class="text-muted-foreground">terakhir: {a.last_fired || "belum pernah"}</span><span class="flex-1" /><Button variant="destructive" size="sm" onClick={() => del(a.id)}>Hapus</Button></li>}</For></ul> <ul class="space-y-1.5 text-sm"><For each={alerts()?.alerts || []}>{(a) => <li class="flex flex-wrap items-center gap-2">
{a.name}
<span class="text-muted-foreground">terakhir: {a.last_fired ? fmtDate(a.last_fired) : "belum pernah"}</span>
<span class="flex-1" /><Button variant="destructive" size="sm" onClick={() => del(a.id)}>Hapus</Button>
</li>}</For></ul>
</Show> </Show>
</CardContent> </CardContent>
</Card> </Card>
@@ -118,7 +131,7 @@ export default function Alerts() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Show when={(events() || []).length} fallback={<p class="text-sm text-muted-foreground">Belum ada kejadian.</p>}> <Show when={(events() || []).length} fallback={<p class="text-sm text-muted-foreground">Belum ada kejadian.</p>}>
<ul class="space-y-1.5 text-sm"><For each={events() || []}>{(e) => <li><Badge variant="secondary">{String(e.ticker)}</Badge> {String(e.message)}</li>}</For></ul> <ul class="space-y-1.5 text-sm"><For each={events() || []}>{(e) => <li class="flex items-start gap-2"><Badge variant="secondary">{String(e.ticker)}</Badge><span class="min-w-0 flex-1">{String(e.message)}</span><span class="shrink-0 text-xs text-muted-foreground">{fmtDate(e.date)}</span></li>}</For></ul>
</Show> </Show>
</CardContent> </CardContent>
</Card> </Card>
@@ -127,3 +140,9 @@ export default function Alerts() {
</div> </div>
); );
} }
import { Gate } from "../index";
export default function Alerts() {
return <Gate fitur="Notifikasi">{AlertsInner()}</Gate>;
}
+42 -11
View File
@@ -1,6 +1,7 @@
import { createResource, createSignal, For, Show } from "solid-js"; import { createResource, createSignal, createEffect, For, Show } from "solid-js";
import { A, useNavigate } from "@solidjs/router"; import { A, useNavigate } from "@solidjs/router";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { useAuth } from "../components/auth";
import { verdictFor, heroSummary, konteksPasar, fmtRp, fmtHarga } from "../lib/awam"; import { verdictFor, heroSummary, konteksPasar, fmtRp, fmtHarga } from "../lib/awam";
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam"; import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
import { Badge } from "../components/ui/badge"; import { Badge } from "../components/ui/badge";
@@ -13,12 +14,21 @@ Chart.register(...registerables);
export default function Dashboard() { export default function Dashboard() {
const navigate = useNavigate(); const navigate = useNavigate();
const { me } = useAuth();
// screen+alertEvents are login-gated; source = me() so login/logout refetches.
const [flow] = createResource(() => api.flowSummary()); const [flow] = createResource(() => api.flowSummary());
const [screen] = createResource(() => api.screen({ limit: 5 }).catch(() => null)); const [screen] = createResource(me, (user) => (user ? api.screen({ limit: 5 }).catch(() => null) : null));
const [briefing] = createResource(() => api.briefing().catch(() => null)); const [briefing] = createResource(() => api.briefing().catch(() => null));
const [events] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 5)).catch(() => [])); const [events] = createResource(me, (user) => (user ? api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 5)).catch(() => []) : []));
const [chartTiker, setChartTiker] = createSignal("BBCA"); const [chartTiker, setChartTiker] = createSignal("BBCA");
const [foreign] = createResource(chartTiker, (t) => api.flowForeign(t).catch(() => null)); const [foreign] = createResource(chartTiker, (t) => api.flowForeign(t).catch(() => null));
// Full picker list = every ticker that has foreign-flow rows.
const fkTickers = () => flow()?.foreign_tickers || [];
// Keep the current chart ticker valid even when the list changes.
createEffect(() => {
const list = fkTickers();
if (list.length && !list.includes(chartTiker())) setChartTiker(list[0]);
});
const ringkasan = () => { const ringkasan = () => {
const n = briefing()?.narasi?.trim(); const n = briefing()?.narasi?.trim();
@@ -28,13 +38,20 @@ export default function Dashboard() {
}; };
const sorotan = () => (screen()?.rows || []).map((r) => ({ row: r, ...verdictFor(r) })); const sorotan = () => (screen()?.rows || []).map((r) => ({ row: r, ...verdictFor(r) }));
const closeMap = () => Object.fromEntries((flow()?.closes || []).map((c) => [c.ticker, c])); const closeMap = () => Object.fromEntries((flow()?.closes || []).map((c) => [c.ticker, c]));
// Resolve theme-aware primary color (falls back to blue) so the chart
// matches light/dark instead of a hardcoded hex.
const primaryColor = () => {
const c = getComputedStyle(document.documentElement).getPropertyValue("--primary");
const [h, s, l] = c.trim().split(/[\s,]+/).map(Number);
return Number.isFinite(h) && Number.isFinite(s) && Number.isFinite(l) ? `hsl(${h} ${s}% ${l}%)` : "#3b82f6";
};
const chartData = () => ({ const chartData = () => ({
labels: (foreign()?.dates || []) as string[], labels: (foreign()?.dates || []) as string[],
datasets: [{ datasets: [{
label: `${chartTiker()} — uang asing harian (${fmtRp(0).slice(0, 2)}7700`, label: `${chartTiker()} — uang asing harian (Rp juta)`,
data: (foreign()?.nets || []) as number[], data: (foreign()?.nets || []) as number[],
borderColor: "#3b82f6", borderColor: primaryColor(),
backgroundColor: "rgba(59,130,246,.15)", backgroundColor: "transparent",
fill: true, fill: true,
tension: 0.25, tension: 0.25,
}], }],
@@ -89,10 +106,15 @@ export default function Dashboard() {
return ( return (
<Card class={s.verdict === "Dilirik" ? "border-emerald-500/50" : s.verdict === "Dilepas" ? "border-red-500/50" : ""}> <Card class={s.verdict === "Dilirik" ? "border-emerald-500/50" : s.verdict === "Dilepas" ? "border-red-500/50" : ""}>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-xl font-bold"> <div>
{s.row.symbol} <CardTitle class="text-xl font-bold">
<Show when={cx?.close}><span class="ml-2 font-mono text-sm font-normal text-muted-foreground">{fmtHarga(cx.close)}</span></Show> {s.row.symbol}
</CardTitle> <Show when={cx?.close}><span class="ml-2 font-mono text-sm font-normal text-muted-foreground">{fmtHarga(cx.close)}</span></Show>
</CardTitle>
<Show when={s.row.name && s.row.name !== s.row.symbol}>
<p class="text-xs text-muted-foreground">{s.row.name}</p>
</Show>
</div>
<VerdictBadge verdict={s.verdict} /> <VerdictBadge verdict={s.verdict} />
</CardHeader> </CardHeader>
<CardContent class="space-y-3"> <CardContent class="space-y-3">
@@ -121,7 +143,16 @@ export default function Dashboard() {
<CardDescription><Term kata="foreign flow" /> harian — naik = asing beli, turun = asing jual. Pilih saham:</CardDescription> <CardDescription><Term kata="foreign flow" /> harian — naik = asing beli, turun = asing jual. Pilih saham:</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div class="mb-3 flex flex-wrap gap-2"> <div class="mb-3 flex flex-wrap items-center gap-2">
<label class="sr-only" for="fk-ticker">Pilih saham</label>
<select id="fk-ticker" class="h-9 rounded-md border bg-background px-2 text-sm"
value={chartTiker()} onChange={(e) => setChartTiker((e.currentTarget as HTMLSelectElement).value)}>
<For each={fkTickers()}>
{(t) => <option value={t}>{t}</option>}
</For>
</select>
<span class="text-xs text-muted-foreground">{fkTickers().length} saham tersedia</span>
<span class="flex-1" />
<For each={(flow()?.top_accumulation || []).map((t) => t.ticker)}>{(t) => <For each={(flow()?.top_accumulation || []).map((t) => t.ticker)}>{(t) =>
<Button size="sm" variant={chartTiker() === t ? "default" : "outline"} onClick={() => setChartTiker(t)}>{t}</Button> <Button size="sm" variant={chartTiker() === t ? "default" : "outline"} onClick={() => setChartTiker(t)}>{t}</Button>
}</For> }</For>
+21 -2
View File
@@ -9,7 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../co
import { Skeleton } from "../components/ui/skeleton"; import { Skeleton } from "../components/ui/skeleton";
Chart.register(...registerables); Chart.register(...registerables);
export default function Portfolio() { function PortfolioInner() {
const [risk] = createResource(() => api.risk()); const [risk] = createResource(() => api.risk());
const conc = () => risk()?.concentration || []; const conc = () => risk()?.concentration || [];
const betaN = () => Number(risk()?.beta ?? 1); const betaN = () => Number(risk()?.beta ?? 1);
@@ -31,9 +31,22 @@ export default function Portfolio() {
if (top.weight > 0.4) return `${top.ticker} porsinya ${(top.weight * 100).toFixed(0)}% — kebanyakan telur di satu keranjang. Pertimbangkan tambah saham beda sektor.`; if (top.weight > 0.4) return `${top.ticker} porsinya ${(top.weight * 100).toFixed(0)}% — kebanyakan telur di satu keranjang. Pertimbangkan tambah saham beda sektor.`;
return "Sebarannya sudah lumayan — tidak numpuk di satu tempat. Pertahankan. 👍"; return "Sebarannya sudah lumayan — tidak numpuk di satu tempat. Pertahankan. 👍";
}; };
const primaryColor = () => {
const c = getComputedStyle(document.documentElement).getPropertyValue("--primary");
const [h, s, l] = c.trim().split(/[\s,]+/).map(Number);
return Number.isFinite(h) && Number.isFinite(s) && Number.isFinite(l) ? `hsl(${h} ${s}% ${l}%)` : "#3b82f6";
};
const chartData = () => ({ const chartData = () => ({
labels: conc().map((c) => c.ticker), labels: conc().map((c) => c.ticker),
datasets: [{ label: "Porsi %", data: conc().map((c) => +(c.weight * 100).toFixed(1)) }], datasets: [{
label: "Porsi %",
data: conc().map((c) => +(c.weight * 100).toFixed(1)),
backgroundColor: conc().map((_, i) => {
const alphas = ["88", "66", "44", "33", "22"];
return `${primaryColor()}${alphas[i % alphas.length]}`;
}),
borderRadius: 4,
}],
}); });
return ( return (
<div class="space-y-4"> <div class="space-y-4">
@@ -66,3 +79,9 @@ export default function Portfolio() {
</div> </div>
); );
} }
import { Gate } from "../index";
export default function Portfolio() {
return <Gate fitur="Portofolio">{PortfolioInner()}</Gate>;
}
+29 -9
View File
@@ -1,4 +1,4 @@
import { createSignal, For, Show, onMount } from "solid-js"; import { createSignal, createEffect, For, Show, on } from "solid-js";
import { useParams } from "@solidjs/router"; import { useParams } from "@solidjs/router";
import { api, type ReportPayload } from "../lib/api"; import { api, type ReportPayload } from "../lib/api";
import { Citations } from "../components/Citations"; import { Citations } from "../components/Citations";
@@ -10,7 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
import { Skeleton } from "../components/ui/skeleton"; import { Skeleton } from "../components/ui/skeleton";
import { TextField, TextFieldInput } from "../components/ui/text-field"; import { TextField, TextFieldInput } from "../components/ui/text-field";
export default function Report() { function ReportInner() {
const params = useParams(); const params = useParams();
const [rep, setRep] = createSignal<ReportPayload | null>(null); const [rep, setRep] = createSignal<ReportPayload | null>(null);
const [md, setMd] = createSignal(""); const [md, setMd] = createSignal("");
@@ -19,26 +19,34 @@ export default function Report() {
const [busy, setBusy] = createSignal(true); const [busy, setBusy] = createSignal(true);
const [asking, setAsking] = createSignal(false); const [asking, setAsking] = createSignal(false);
const [err, setErr] = createSignal(""); const [err, setErr] = createSignal("");
const authHeaders = () => ({ "X-User-Key": localStorage.getItem("fs-key") || "demo" });
async function load() { async function load() {
setErr(""); setBusy(true); setErr(""); setBusy(true);
try { setRep(await api.report(params.ticker)); } try { setRep(await api.report(params.ticker)); }
catch (e) { setErr(String(e)); } catch (e) { setErr(String(e)); }
finally { setBusy(false); } finally { setBusy(false); }
} }
onMount(load); // Refetch when the ticker param changes (ReportInner stays mounted
async function exportMd() { setMd(await api.reportMd(params.ticker)); } // because Gate wraps it, but params.ticker is reactive).
createEffect(on(() => params.ticker, () => load()));
async function exportMd() {
try { setMd(await api.reportMd(params.ticker)); }
catch (e) { setErr(String(e)); }
}
async function ask() { async function ask() {
if (!question().trim()) return; if (!question().trim()) return;
setAsking(true); setAsking(true);
try { try {
const r = await api.interrogate(params.ticker, question()); const r = await api.interrogate(params.ticker, question());
setAnswer(r.answer); setAnswer(r.answer);
} catch (e) {
setErr(String(e));
} finally { setAsking(false); } } finally { setAsking(false); }
} }
function dl(url: string, name: string) { function dl(url: string, name: string) {
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.download = name; a.click(); a.href = url; a.download = name; a.click();
// Revoke after a short delay to avoid blob URL leak.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} }
return ( return (
<div class="space-y-4"> <div class="space-y-4">
@@ -72,15 +80,21 @@ export default function Report() {
<CardContent class="flex flex-wrap gap-2"> <CardContent class="flex flex-wrap gap-2">
<Button variant="outline" onClick={exportMd}>Markdown</Button> <Button variant="outline" onClick={exportMd}>Markdown</Button>
<Button variant="outline" onClick={async () => { <Button variant="outline" onClick={async () => {
const r = await fetch(`/api/report/${params.ticker}?format=html`, { method: "POST", headers: authHeaders() }); try {
dl(URL.createObjectURL(new Blob([await r.text()], { type: "text/html" })), `${params.ticker}-report.html`); const r = await fetch(`/api/report/${encodeURIComponent(params.ticker)}?format=html`, { method: "POST", credentials: "same-origin" });
if (!r.ok) { setErr(`HTML export gagal: HTTP ${r.status}`); return; }
dl(URL.createObjectURL(new Blob([await r.text()], { type: "text/html" })), `${params.ticker}-report.html`);
} catch (e) { setErr(String(e)); }
}}>HTML</Button> }}>HTML</Button>
<Button variant="outline" onClick={() => { <Button variant="outline" onClick={() => {
dl(URL.createObjectURL(new Blob([JSON.stringify(rep(), null, 1)], { type: "application/json" })), `${params.ticker}-report.json`); dl(URL.createObjectURL(new Blob([JSON.stringify(rep(), null, 1)], { type: "application/json" })), `${params.ticker}-report.json`);
}}>JSON</Button> }}>JSON</Button>
<Button variant="outline" onClick={async () => { <Button variant="outline" onClick={async () => {
const r = await fetch(`/api/report/${params.ticker}?format=pdf`, { method: "POST", headers: authHeaders() }); try {
dl(URL.createObjectURL(await r.blob()), `${params.ticker}-report.pdf`); const r = await fetch(`/api/report/${encodeURIComponent(params.ticker)}?format=pdf`, { method: "POST", credentials: "same-origin" });
if (!r.ok) { setErr(`PDF export gagal: HTTP ${r.status}`); return; }
dl(URL.createObjectURL(await r.blob()), `${params.ticker}-report.pdf`);
} catch (e) { setErr(String(e)); }
}}>PDF</Button> }}>PDF</Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -103,3 +117,9 @@ export default function Report() {
</div> </div>
); );
} }
import { Gate } from "../index";
export default function Report() {
return <Gate fitur="Report saham"><ReportInner /></Gate>;
}
+32 -5
View File
@@ -17,25 +17,36 @@ const MANFAAT: Record<string, { judul: string; desc: string }> = {
"weekend-review": { judul: "📝 Review mingguan", desc: "Ringkasan seminggu: apa yang terjadi dan pelajaran." }, "weekend-review": { judul: "📝 Review mingguan", desc: "Ringkasan seminggu: apa yang terjadi dan pelajaran." },
}; };
export default function Routines() { function RoutinesInner() {
const [data, { refetch }] = createResource(() => api.routines()); const [data, { refetch }] = createResource(() => api.routines());
const [runs, { refetch: refetchRuns }] = createResource(() => api.runs().then((r) => r.runs.slice(0, 20))); const [runs, { refetch: refetchRuns }] = createResource(() => api.runs().then((r) => r.runs.slice(0, 20)));
const [type_, setType] = createSignal("morning-briefing"); const [type_, setType] = createSignal("morning-briefing");
const [busy, setBusy] = createSignal(false); const [busy, setBusy] = createSignal(false);
const [err, setErr] = createSignal("");
const [briefing] = createResource(() => api.briefing().catch(() => null)); const [briefing] = createResource(() => api.briefing().catch(() => null));
async function subscribe() { async function subscribe() {
setBusy(true); setBusy(true);
try { await api.createRoutine({ type: type_() }); refetch(); } try { await api.createRoutine({ type: type_() }); refetch(); }
catch (e) { setErr(String(e)); }
finally { setBusy(false); } finally { setBusy(false); }
} }
async function toggle(id: number, enabled: boolean) { await api.updateRoutine(id, { enabled: !enabled }); refetch(); } const fmtTime = (s: unknown) => {
const t = String(s || "");
if (!t) return "—";
// ISO timestamp → "16 Sep 07:30" (local time).
const d = new Date(t);
return Number.isNaN(d.getTime()) ? t : d.toLocaleString("id-ID", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
};
async function toggle(id: number, enabled: boolean) {
try { await api.updateRoutine(id, { enabled: !enabled }); refetch(); } catch (e) { setErr(String(e)); }
}
async function del(id: number) { async function del(id: number) {
await fetch(`/api/routines/${id}`, { method: "DELETE", headers: { "X-User-Key": localStorage.getItem("fs-key") || "demo" } }); try { await api.deleteRoutine(id); refetch(); } catch (e) { setErr(String(e)); }
refetch();
} }
return ( return (
<div class="space-y-4"> <div class="space-y-4">
<PageHead title="Jadwal otomatis 🗓️" sub="Pilih sekali — sistem yang kerja tiap hari. Ini namanya routine." /> <PageHead title="Jadwal otomatis 🗓️" sub="Pilih sekali — sistem yang kerja tiap hari. Ini namanya routine." />
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
<Card> <Card>
<CardHeader><CardTitle>Mau dilayani apa?</CardTitle><CardDescription>Jadwal standar sudah diatur (mis. ringkasan jam 07:30) — tidak perlu isi cron.</CardDescription></CardHeader> <CardHeader><CardTitle>Mau dilayani apa?</CardTitle><CardDescription>Jadwal standar sudah diatur (mis. ringkasan jam 07:30) — tidak perlu isi cron.</CardDescription></CardHeader>
<CardContent class="space-y-3"> <CardContent class="space-y-3">
@@ -89,7 +100,17 @@ export default function Routines() {
<Button variant="ghost" size="sm" onClick={() => refetchRuns()}>Refresh</Button> <Button variant="ghost" size="sm" onClick={() => refetchRuns()}>Refresh</Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ul class="space-y-1 text-sm text-muted-foreground"><For each={runs() || []}>{(r) => <li>{String(r.started_at)} — {String(r.status)}</li>}</For></ul> <ul class="space-y-1 text-sm text-muted-foreground">
<For each={runs() || []}>{(r) => (
<li class="flex items-center justify-between gap-2">
<span class="flex items-center gap-2">
<Badge variant={r.status === "ok" ? "success" : r.status === "error" ? "error" : "secondary"} class="capitalize">{String(r.status)}</Badge>
<span>{fmtTime(r.started_at)}</span>
</span>
<span class="font-mono text-xs">{r.credits_used ? `${String(r.credits_used)} kredit` : `#${String(r.routine_id)}`}</span>
</li>
)}</For>
</ul>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -97,3 +118,9 @@ export default function Routines() {
</div> </div>
); );
} }
import { Gate } from "../index";
export default function Routines() {
return <Gate fitur="Jadwal otomatis">{RoutinesInner()}</Gate>;
}
+40 -6
View File
@@ -1,4 +1,4 @@
import { createSignal, For, Show } from "solid-js"; import { createSignal, For, Show, onMount } from "solid-js";
import { api, type ScreenRow } from "../lib/api"; import { api, type ScreenRow } from "../lib/api";
import { verdictFor } from "../lib/awam"; import { verdictFor } from "../lib/awam";
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam"; import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
@@ -6,31 +6,47 @@ import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { Skeleton } from "../components/ui/skeleton"; import { Skeleton } from "../components/ui/skeleton";
import { TextField, TextFieldInput } from "../components/ui/text-field";
import { useNavigate } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
// Preset awam -> body /api/screen. // Preset awam -> body /api/screen. PRESET_SEMUA = default auto-jalan.
export const PRESET_SEMUA = { label: "📦 Semua — urut paling menarik", desc: "Tanpa filter, ranking gabungan", body: { limit: 20 } };
const PRESETS: { label: string; desc: string; body: Record<string, unknown> }[] = [ const PRESETS: { label: string; desc: string; body: Record<string, unknown> }[] = [
{ label: "🔥 Yang lagi diborong bandar", desc: "Broker besar net-beli besar", body: { institutional: { broker_score_min: 5 }, limit: 20 } }, { label: "🔥 Yang lagi diborong bandar", desc: "Broker besar net-beli besar", body: { institutional: { broker_score_min: 5 }, limit: 20 } },
{ label: "🌍 Yang asing lagi beli", desc: "Uang luar negeri masuk", body: { institutional: { foreign_inflow: true }, limit: 20 } }, { label: "🌍 Yang asing lagi beli", desc: "Uang luar negeri masuk", body: { institutional: { foreign_inflow: true }, limit: 20 } },
{ label: "🕵️ Yang orang dalamnya ikut beli", desc: "Insider buying terdeteksi", body: { institutional: { insider_buying: true }, limit: 20 } }, { label: "🕵️ Yang orang dalamnya ikut beli", desc: "Insider buying terdeteksi", body: { institutional: { insider_buying: true }, limit: 20 } },
{ label: "📦 Semua — urut paling menarik", desc: "Tanpa filter, ranking gabungan", body: { limit: 20 } }, PRESET_SEMUA,
]; ];
export default function Screener() { function ScreenerInner() {
const navigate = useNavigate(); const navigate = useNavigate();
const [rows, setRows] = createSignal<ScreenRow[]>([]); const [rows, setRows] = createSignal<ScreenRow[]>([]);
const [ran, setRan] = createSignal(false); const [ran, setRan] = createSignal(false);
const [busy, setBusy] = createSignal(false); const [busy, setBusy] = createSignal(false);
const [err, setErr] = createSignal(""); const [err, setErr] = createSignal("");
const [aktif, setAktif] = createSignal(""); const [aktif, setAktif] = createSignal("");
const [q, setQ] = createSignal("");
const [showCustom, setShowCustom] = createSignal(false);
async function runPreset(p: (typeof PRESETS)[number]) { async function runPreset(p: (typeof PRESETS)[number]) {
setErr(""); setBusy(true); setAktif(p.label); setErr(""); setBusy(true); setAktif(p.label); setShowCustom(false);
try { try {
const r = await api.screen(p.body); const r = await api.screen(p.body);
setRows(r.rows); setRan(true); setRows(r.rows); setRan(true);
} catch (e) { setErr(String(e)); } } catch (e) { setErr(String(e)); }
finally { setBusy(false); } finally { setBusy(false); }
} }
async function runCustom() {
const query = q().trim();
if (!query) return;
setErr(""); setBusy(true); setAktif("custom");
try {
const r = await api.screen({ q: query, limit: 20 });
setRows(r.rows); setRan(true);
} catch (e) { setErr(String(e)); }
finally { setBusy(false); }
}
// Default = semua: auto-jalan preset "Semua" sekali saat halaman dibuka.
onMount(() => { if (!ran()) void runPreset(PRESET_SEMUA); });
const hasil = () => rows().map((r) => ({ row: r, ...verdictFor(r) })); const hasil = () => rows().map((r) => ({ row: r, ...verdictFor(r) }));
return ( return (
<div class="space-y-4"> <div class="space-y-4">
@@ -45,6 +61,13 @@ export default function Screener() {
<CardContent><Button size="sm" onClick={() => runPreset(p)} disabled={busy()}>{busy() && aktif() === p.label ? "Menyaring…" : "Tampilkan"}</Button></CardContent> <CardContent><Button size="sm" onClick={() => runPreset(p)} disabled={busy()}>{busy() && aktif() === p.label ? "Menyaring…" : "Tampilkan"}</Button></CardContent>
</Card> </Card>
)}</For> )}</For>
<Card class={showCustom() ? "border-primary" : ""}>
<CardHeader class="pb-2"><CardTitle class="text-base">🔎 Cari kode saham</CardTitle><CardDescription>Ketik kode saham atau nama (mis. "bca", "tlkm", "bank").</CardDescription></CardHeader>
<CardContent class="flex gap-2">
<TextField class="flex-1"><TextFieldInput placeholder="mis. bca, tlkm, bbri…" value={q()} onInput={(e) => { setQ(e.currentTarget.value); setShowCustom(true); }} onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") runCustom(); }} /></TextField>
<Button size="sm" onClick={runCustom} disabled={busy() || !q().trim()}>{busy() && aktif() === "custom" ? "Menyaring…" : "Cari"}</Button>
</CardContent>
</Card>
</div> </div>
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show> <Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
<Show when={busy()}><div class="space-y-2"><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /></div></Show> <Show when={busy()}><div class="space-y-2"><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /></div></Show>
@@ -57,7 +80,12 @@ export default function Screener() {
return ( return (
<Card> <Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-xl font-bold">{s.row.symbol}</CardTitle> <div>
<CardTitle class="text-xl font-bold">{s.row.symbol}</CardTitle>
<Show when={s.row.name && s.row.name !== s.row.symbol}>
<p class="text-sm text-muted-foreground">{s.row.name}</p>
</Show>
</div>
<VerdictBadge verdict={s.verdict} /> <VerdictBadge verdict={s.verdict} />
</CardHeader> </CardHeader>
<CardContent class="space-y-3"> <CardContent class="space-y-3">
@@ -77,3 +105,9 @@ export default function Screener() {
</div> </div>
); );
} }
import { Gate } from "../index";
export default function Screener() {
return <Gate fitur="Cari saham"><ScreenerInner /></Gate>;
}