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) && \
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) && \
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 restart flowsight && \
sleep 3 && \
sudo systemctl status flowsight --no-pager --no-legend | head -5"
- name: Verify service
- name: Verify service anti-stale
env:
PUSHED_SHA: ${{ github.sha }}
run: |
ssh "$VPS_USER@$VPS_HOST" \
"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"
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
findings.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 {
for _, t := range cfg.Watchlist {
seeds, _ := db.AllTickers()
if len(seeds) == 0 {
seeds = cfg.Watchlist
}
for _, t := range seeds {
_ = 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)
if rec.Code == http.StatusNotFound {
// 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 {
t.Fatal("seed has no routines")
}
@@ -82,7 +83,7 @@ func TestBriefing(t *testing.T) {
// Screener returns a ranked list with per-row breakdown.
func TestScreen(t *testing.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 {
t.Fatalf("code = %d", rec.Code)
}
@@ -101,7 +102,7 @@ func TestScreen(t *testing.T) {
// Report: all 7 sections populated with citations.
func TestReport(t *testing.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 {
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.
func TestRoutineSubscribeRunHistory(t *testing.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 {
t.Fatalf("code = %d", rec.Code)
}
var created map[string]any
_ = 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 {
t.Fatal("no routines")
}
if _, err := s.Engine.Run(httptest.NewRequest("GET", "/", nil).Context(), rows[0]); err != nil {
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 {
Runs []map[string]any `json:"runs"`
}
@@ -149,11 +151,11 @@ func TestRoutineSubscribeRunHistory(t *testing.T) {
// from the persisted report, unknown report 404s.
func TestInterrogate(t *testing.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 {
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 {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:200])
}
@@ -164,7 +166,7 @@ func TestInterrogate(t *testing.T) {
if out.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 {
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.
func TestPortfolioAndAccuracy(t *testing.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 {
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 {
t.Fatalf("code = %d", rec.Code)
}
@@ -203,19 +205,19 @@ func TestFlowForeignWindow(t *testing.T) {
// Unknown routine types are rejected; missing ids 404.
func TestRoutineValidation(t *testing.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 {
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 {
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 {
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 {
t.Fatalf("code = %d, want 404", rec.Code)
}
@@ -225,27 +227,31 @@ func TestRoutineValidation(t *testing.T) {
func TestDestinations(t *testing.T) {
s := testServer(t)
// 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 {
t.Fatalf("code = %d, want 422", rec.Code)
}
// 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 {
t.Fatalf("code = %d, want 422", rec.Code)
}
// Discord non-https -> 422.
rec = do(s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "http://x"})
// Discord non-https or non-Discord host -> 422.
rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "http://x"})
if rec.Code != http.StatusUnprocessableEntity {
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.
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 {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String())
}
// List masks secrets.
rec = do(s, "GET", "/api/destinations", nil)
rec = doAuth(t, s, "GET", "/api/destinations", nil)
var out struct {
Destinations []map[string]any `json:"destinations"`
}
@@ -262,11 +268,11 @@ func TestDestinations(t *testing.T) {
t.Fatalf("configured flag = %v", out.Destinations[0])
}
// 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 {
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 {
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")
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 {
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)
}
}
@@ -90,7 +94,7 @@ func (s *Server) AuthCallback(w http.ResponseWriter, r *http.Request) {
}
http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
Secure: true, SameSite: http.SameSiteLaxMode,
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionTTL),
})
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{
Name: "fs_session", Value: "", Path: "/", HttpOnly: true,
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
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)
}
// requireLogin is chi middleware: 401 unless a valid session cookie is
// present. No X-User-Key/demo fallback — fitur dan filter milik user yg
// login. Dashboard (flow/*, briefing) tetap publik di luar grup ini.
func (s *Server) requireLogin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := s.sessionUser(r); !ok {
writeErr(w, http.StatusUnauthorized, "login dulu untuk pakai fitur ini")
return
}
next.ServeHTTP(w, r)
})
}
// localCreds decodes {username, password} with shared validation.
func localCreds(r *http.Request) (string, string, bool) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return "", "", false
}
return strings.ToLower(strings.TrimSpace(req.Username)), req.Password, true
}
// isHTTPS checks X-Forwarded-Proto (Caddy) or raw TLS.
func isHTTPS(r *http.Request) bool {
if strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
return true
}
return r.TLS != nil
}
// mintSession creates a session + sets the fs_session cookie.
func (s *Server) mintSession(w http.ResponseWriter, r *http.Request, user *store.User) bool {
tok, err := s.DB.CreateSession(user.ID, user.UserKey, sessionTTL)
if err != nil {
writeErr(w, http.StatusBadGateway, "session store unavailable")
return false
}
http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionTTL),
})
return true
}
// userJSON renders the /api/auth/me shape for one user.
func (s *Server) userJSON(u *store.User) map[string]any {
return map[string]any{
"id": u.ID, "email": u.Email, "name": u.Name,
"avatar_url": u.AvatarURL, "user_key": u.UserKey,
"google_configured": s.Cfg.HasGoogle(),
}
}
// seedWatchlistSemua gives a fresh user the "semua" default: every ticker
// with stored data (fallback: config watchlist, last: BBCA).
func (s *Server) seedWatchlistSemua(userKey string) {
if wl, _ := s.DB.Watchlist(userKey); len(wl) > 0 {
return
}
seeds, _ := s.DB.AllTickers()
if len(seeds) == 0 {
seeds = s.Cfg.Watchlist
}
if len(seeds) == 0 {
seeds = []string{"BBCA"}
}
for _, t := range seeds {
_ = s.DB.AddWatch(userKey, t)
}
}
// AuthSignup serves POST /api/auth/signup {username, password}: registers a
// local account, seeds the "semua" watchlist, logs in immediately.
func (s *Server) AuthSignup(w http.ResponseWriter, r *http.Request) {
username, password, ok := localCreds(r)
if !ok {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
user, err := s.DB.CreateLocalUser(username, password)
if err != nil {
switch err {
case store.ErrTaken:
writeErr(w, http.StatusConflict, err.Error())
case store.ErrBadUsername, store.ErrBadPassword:
writeErr(w, http.StatusUnprocessableEntity, err.Error())
default:
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
}
return
}
s.seedWatchlistSemua(user.UserKey)
if !s.mintSession(w, r, user) {
return
}
writeJSON(w, http.StatusCreated, map[string]any{"user": s.userJSON(user)})
}
// AuthLogin serves POST /api/auth/login {username, password}: verifies the
// local account and mints a session.
func (s *Server) AuthLogin(w http.ResponseWriter, r *http.Request) {
username, password, ok := localCreds(r)
if !ok {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
user, err := s.DB.CheckLocalUser(username, password)
if err != nil {
writeErr(w, http.StatusUnauthorized, "username atau password salah")
return
}
s.seedWatchlistSemua(user.UserKey)
if !s.mintSession(w, r, user) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": s.userJSON(user)})
}
// googleTokenResp is the subset of oauth2.googleapis.com/token we need.
type googleTokenResp struct {
IDToken string `json:"id_token"`
+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")
return
}
// Scope grounding: report citations when scoped.
// Scope grounding: report citations when scoped (owner-scoped).
var ground, citesRaw string
if req.Scope != nil && req.Scope.ReportID > 0 {
var cites string
var at string
err := s.DB.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports WHERE id=?`,
req.Scope.ReportID).Scan(&ground, &cites, &at)
err := s.DB.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports WHERE id=? AND user_key=?`,
req.Scope.ReportID, s.userKey(r)).Scan(&ground, &cites, &at)
if err != nil {
writeErr(w, http.StatusNotFound, "report not found")
return
}
citesRaw = cites
} else {
// Unscoped: ground on the latest briefing + watchlist.
_, payload, cites, _, err := s.DB.LatestBriefing()
if err != nil {
ground = "no briefing or report data yet"
// Unscoped: ground on the caller's own briefing + watchlist (never the
// global briefing, which may embed another user's watchlist numbers).
uk := s.userKey(r)
_, 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 {
ground, citesRaw = payload, cites
ground = "no briefing or report data yet"
}
}
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})
}
// 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 {
switch kind {
case store.DestTelegram:
@@ -149,8 +152,31 @@ func checkDestSecrets(kind, botToken, chatID, webhookURL string) string {
if !strings.HasPrefix(u, "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:
return "kind must be telegram or discord"
}
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 == "" {
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))
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 {
Ticker string `json:"ticker"`
@@ -27,10 +33,20 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
Brokers int `json:"brokers"`
}
var accs []accRow
var fkTickers []string
foreignTotal := 0.0
var closes []map[string]any
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 {
sum, n := 0.0, 0
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 {
foreignTotal += nets[len(nets)-1]
fkTickers = append(fkTickers, tk)
cites = append(cites, model.Cite("v2/foreign-flow/"+tk+"/", tk, date))
}
if px, dx, err := s.DB.LatestClose(tk); err == nil {
closes = append(closes, map[string]any{"ticker": tk, "close": px, "date": dx})
}
}
// Rest of the universe: still list them as chart-picker options (foreign
// data may exist even if not scanned this request).
extra, _ := s.DB.TickersWithForeign()
for _, tk := range wl {
if !scanned[tk] && extra[tk] {
fkTickers = append(fkTickers, tk)
}
}
sort.Slice(accs, func(i, j int) bool { return accs[i].NetSum > accs[j].NetSum })
if len(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{
"date": date, "foreign_net_total": foreignTotal,
"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{
"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])},
"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 +
// 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) {
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
}
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")
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 {
writeErr(w, http.StatusNotFound, "no report for "+ticker+" yet — POST /api/report/"+ticker+" first")
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
// report id or the latest report for a ticker.
func (s *Server) loadReport(ticker string, id int64) (string, string, string, int64, error) {
// report id (scoped to the caller's userKey) or the latest report for a ticker.
func (s *Server) loadReport(ticker string, id int64, userKey string) (string, string, string, int64, error) {
if id > 0 {
var t, p, c, at string
var rid int64
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 {
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
}
p, c, at, err := s.DB.LatestReport(ticker)
p, c, at, err := s.DB.LatestReportForUser(ticker, userKey)
if err != nil {
return "", "", "", 0, err
}
var rid int64
_ = s.DB.QueryRow(`SELECT id FROM reports WHERE ticker=? ORDER BY id DESC LIMIT 1`,
ticker).Scan(&rid)
_ = s.DB.QueryRow(`SELECT id FROM reports WHERE ticker=? AND user_key=? ORDER BY id DESC LIMIT 1`,
ticker, userKey).Scan(&rid)
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) {
wl, _ := s.DB.Watchlist(s.userKey(r))
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).
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 == "" {
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 {
writeErr(w, http.StatusBadGateway, "report: "+err.Error())
return
+8 -3
View File
@@ -28,11 +28,16 @@ func (s *Server) ListRoutines(w http.ResponseWriter, r *http.Request) {
LastRun any `json:"last_run"`
}
out := make([]rowOut, 0, len(rows))
lastByRoutine, lerr := s.DB.LastRunsByRoutine(s.userKey(r))
if lerr != nil {
lastByRoutine = nil
}
for _, row := range rows {
hist, _ := s.DB.RunHistory(row.ID, 1)
var last any
if len(hist) > 0 {
last = hist[0]
if lastByRoutine != nil {
if v, ok := lastByRoutine[row.ID]; ok {
last = v
}
}
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.
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 {
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 {
universe, _ = s.DB.Watchlist(s.userKey(r))
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
for _, tk := range universe {
row := s.scoreTicker(tk)
if nm := names[tk]; nm != "" {
row.Name = nm
}
if req.Institutional != nil {
inst := req.Institutional
if b, _ := row.Breakdown["broker_score"].(float64); b < inst.BrokerScoreMin {
+51 -25
View File
@@ -35,6 +35,8 @@ type Server struct {
LLM *llm.Client
Validate *validator.Validate
Hub *Hub
// StartedAt powers /api/version (CI anti-stale proof: process age).
StartedAt time.Time
}
// 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)
srv := &Server{
Cfg: cfg, DB: db, Sectors: s, Sched: sched, LLM: llmc,
Validate: validator.New(),
Hub: NewHub(),
Validate: validator.New(),
Hub: NewHub(),
StartedAt: time.Now(),
}
srv.Engine = &routines.Engine{DB: db, Notifier: sched.Notifier, UserKey: cfg.DemoUserKey,
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 {
r := chi.NewRouter()
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.Get("/health", s.Health)
r.Get("/auth/start", s.AuthStart)
r.Get("/auth/callback", s.AuthCallback)
r.Get("/auth/me", s.AuthMe)
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/broker", s.FlowBroker)
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("/alerts", s.ListAlerts)
r.Post("/alerts", s.CreateAlert)
r.Delete("/alerts/{id}", s.DeleteAlert)
r.Get("/alert-events", s.AlertEvents)
r.Get("/destinations", s.ListDestinations)
r.Post("/destinations", s.CreateDestination)
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)
r.Group(func(r chi.Router) {
r.Use(s.requireLogin)
r.Get("/stream", s.Stream)
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("/alerts", s.ListAlerts)
r.Post("/alerts", s.CreateAlert)
r.Delete("/alerts/{id}", s.DeleteAlert)
r.Get("/alert-events", s.AlertEvents)
r.Get("/destinations", s.ListDestinations)
r.Post("/destinations", s.CreateDestination)
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 != "" {
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{
Port: getenv("PORT", "8080"),
SectorsAPIKey: os.Getenv("SECTORS_API_KEY"),
SectorsBaseURL: getenv("SECTORS_BASE_URL", "https://api.sectors.app/v2/"),
DBPath: getenv("DB_PATH", "data/flowsight.db"),
RedisURL: os.Getenv("REDIS_URL"),
DemoUserKey: getenv("DEMO_USER_KEY", "demo"),
LLMBaseURL: os.Getenv("LLM_BASE_URL"),
LLMAPIKey: os.Getenv("LLM_API_KEY"),
LLMTriage: getenv("LLM_MODEL_TRIAGE", "gpt-4o-mini"),
LLMSynth: getenv("LLM_MODEL_SYNTH", "gpt-4o"),
TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
TelegramChatID: os.Getenv("TELEGRAM_CHAT_ID"),
DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
CreditCapPerCycle: getenvInt("CREDIT_CAP_PER_CYCLE", 120),
Watchlist: tickers,
StaticDir: os.Getenv("WEB_DIST_DIR"),
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
Port: getenv("PORT", "8080"),
SectorsAPIKey: os.Getenv("SECTORS_API_KEY"),
SectorsBaseURL: getenv("SECTORS_BASE_URL", "https://api.sectors.app/v2/"),
DBPath: getenv("DB_PATH", "data/flowsight.db"),
RedisURL: os.Getenv("REDIS_URL"),
DemoUserKey: getenv("DEMO_USER_KEY", "demo"),
LLMBaseURL: os.Getenv("LLM_BASE_URL"),
LLMAPIKey: os.Getenv("LLM_API_KEY"),
LLMTriage: getenv("LLM_MODEL_TRIAGE", "gpt-4o-mini"),
LLMSynth: getenv("LLM_MODEL_SYNTH", "gpt-4o"),
TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
TelegramChatID: os.Getenv("TELEGRAM_CHAT_ID"),
DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
CreditCapPerCycle: getenvInt("CREDIT_CAP_PER_CYCLE", 120),
Watchlist: tickers,
StaticDir: os.Getenv("WEB_DIST_DIR"),
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
GoogleRedirectURL: os.Getenv("GOOGLE_REDIRECT_URL"),
}
+23 -23
View File
@@ -3,6 +3,7 @@ package reports
import (
"bytes"
"fmt"
"html"
"strings"
"time"
@@ -29,18 +30,21 @@ func (r Report) ToMarkdown() 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 {
esc := html.EscapeString
var b strings.Builder
b.WriteString(`<!doctype html><html><head><meta charset="utf-8"><title>`)
b.WriteString(r.Ticker + " — FlowSight Report</title></head><body>")
fmt.Fprintf(&b, "<h1>%s — FlowSight Report (%s)</h1>", r.Ticker, r.GeneratedAt)
b.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>")
b.WriteString(esc(r.Ticker) + " — FlowSight Report</title></head><body>")
fmt.Fprintf(&b, "<h1>%s — FlowSight Report (%s)</h1>", esc(r.Ticker), esc(r.GeneratedAt))
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 {
b.WriteString("<ul>")
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>")
}
@@ -50,25 +54,31 @@ func (r Report) ToHTML() string {
}
// 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) {
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.SetFont("Helvetica", "B", 16)
pdf.SetFont("DejaVu", "B", 14)
pdf.Cell(0, 10, r.Ticker+" — FlowSight Report")
pdf.Ln(12)
pdf.SetFont("Helvetica", "", 10)
pdf.SetFont("DejaVu", "", 10)
pdf.Cell(0, 6, "Generated "+r.GeneratedAt)
pdf.Ln(8)
for _, s := range r.Sections {
pdf.SetFont("Helvetica", "B", 12)
pdf.SetFont("DejaVu", "B", 12)
pdf.Cell(0, 8, s.Name)
pdf.Ln(8)
pdf.SetFont("Helvetica", "", 10)
pdf.MultiCell(0, 5, latin1(s.Body), "", "", false)
pdf.SetFont("DejaVu", "", 10)
pdf.MultiCell(0, 5, s.Body, "", "", false)
if len(s.Citations) > 0 {
pdf.SetFont("Helvetica", "I", 8)
pdf.SetFont("DejaVu", "", 8)
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)
@@ -79,13 +89,3 @@ func (r Report) ToPDF() ([]byte, error) {
}
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
// 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)
results := agents.RunAll(ctx, b.Deps, ticker)
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)
raw, _ := json.Marshal(map[string]any{"ticker": ticker, "sections": sections, "synthesis": synth, "narasi_awam": narasi})
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 {
return Report{}, 0, err
}
+123 -13
View File
@@ -11,6 +11,8 @@ import (
"fmt"
"log"
"net/url"
"strconv"
"strings"
"time"
"github.com/robfig/cron/v3"
@@ -175,6 +177,15 @@ func (s *Scheduler) RunCycle(ctx context.Context) error {
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 {
log.Printf("scheduler: events: %v", err)
}
@@ -190,12 +201,16 @@ func (s *Scheduler) watchlist() []string {
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 {
wl, err := s.DB.Watchlist(owner)
if err == nil && len(wl) > 0 {
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 {
return s.Cfg.Watchlist
}
@@ -230,33 +245,82 @@ func (s *Scheduler) reference(ctx context.Context) error {
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 {
if s.DB.GetMeta("universe_last_sweep") == time.Now().Format("2006-01-02") {
return nil // already swept today; rotation fills depth incrementally
}
offset := 0
for page := 0; page < 12; page++ {
rows, total, err := s.Sectors.ClosePage(ctx, "", 30, offset)
var all []sectors.CloseRow
for page := 0; page < 40; page++ {
rows, _, err := s.Sectors.ClosePage(ctx, "", 30, offset)
if err != nil {
// Rate-limited mid-sweep: keep pages collected so far if any.
if len(all) > 0 {
break
}
return err
}
if raw, err := json.Marshal(rows); err == nil && len(rows) > 0 {
_ = s.DB.SaveSnapshot("IDX", rows[0].Date, "close", string(raw))
}
all = append(all, rows...)
offset += len(rows)
if offset >= total || len(rows) == 0 {
if len(rows) == 0 {
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
}
// 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 {
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 {
_ = 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 {
return err
}
@@ -279,9 +343,12 @@ func (s *Scheduler) market(ctx context.Context) error {
}
// 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 {
end := time.Now().Format("2006-01-02")
start5 := time.Now().AddDate(0, 0, -6).Format("2006-01-02")
now := time.Now().UTC()
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 raw, err := json.Marshal(top); err == nil {
_ = 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
}
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 {
_ = s.DB.SaveSnapshot(ticker, end, "foreign-flow", string(raw))
for _, d := range ff.Data {
@@ -315,6 +382,49 @@ func (s *Scheduler) tickerDepth(ctx context.Context, ticker string) error {
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.
func (s *Scheduler) events(ctx context.Context) error {
today := time.Now().Format("2006-01-02")
+79 -1
View File
@@ -5,10 +5,25 @@ import (
"encoding/hex"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// User is one Google-authenticated account. UserKey (`u:<google_sub>`) is
// the scoping key used by every user-owned table.
// the scoping key used by every user-owned table. Local accounts
// (username+password signup) store google_sub='local:<username>' with
// username+password_hash set, keeping session scoping unchanged.
type authErr string
func (e authErr) Error() string { return string(e) }
const (
ErrBadLogin authErr = "username atau password salah"
ErrBadUsername authErr = "username 3-32 karakter: huruf, angka, titik, _ -"
ErrBadPassword authErr = "password minimal 8 karakter"
ErrTaken authErr = "username sudah dipakai"
)
type User struct {
ID int64
GoogleSub string
@@ -108,3 +123,66 @@ func (db *DB) DeleteSession(token string) error {
_, err := db.Exec(`DELETE FROM sessions WHERE token=?`, token)
return err
}
// localSub maps a username to its google_sub value for local accounts.
func localSub(username string) string { return "local:" + strings.ToLower(username) }
// CreateLocalUser registers a username+password account (bcrypt cost 10).
// Username: 3-32 chars [a-z0-9._-]; password: min 8 chars.
func (db *DB) CreateLocalUser(username, password string) (*User, error) {
username = strings.ToLower(strings.TrimSpace(username))
if !validUsername(username) {
return nil, ErrBadUsername
}
if len(password) < 8 {
return nil, ErrBadPassword
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
if err != nil {
return nil, err
}
sub := localSub(username)
now := time.Now().UTC().Format(time.RFC3339)
if _, err := db.Exec(`INSERT INTO users(google_sub,email,name,username,password_hash,created_at)
VALUES(?,?,?,?,?,?)`, sub, username, username, username, string(hash), now); err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return nil, ErrTaken
}
return nil, err
}
return db.UserBySub(sub)
}
// CheckLocalUser verifies username+password, returning the user on success.
func (db *DB) CheckLocalUser(username, password string) (*User, error) {
username = strings.ToLower(strings.TrimSpace(username))
var u User
var hash string
if err := db.QueryRow(`SELECT id,google_sub,email,name,avatar_url,created_at,password_hash
FROM users WHERE username=?`, username).
Scan(&u.ID, &u.GoogleSub, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt, &hash); err != nil {
return nil, ErrBadLogin
}
if hash == "" {
return nil, ErrBadLogin // Google-only account, no local password
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return nil, ErrBadLogin
}
u.UserKey = UserKeyForSub(u.GoogleSub)
return &u, nil
}
// validUsername allows 3-32 chars of lowercase letters, digits, . _ -.
func validUsername(u string) bool {
if len(u) < 3 || len(u) > 32 {
return false
}
for _, c := range u {
if c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-' {
continue
}
return false
}
return true
}
@@ -0,0 +1,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"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
@@ -400,6 +402,29 @@ func (db *DB) RunHistory(routineID int64, limit int, userKey ...string) ([]map[s
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.
// Alert is one user rule row.
@@ -540,6 +565,178 @@ func (db *DB) Watchlist(userKey string) ([]string, error) {
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).
func (db *DB) AddWatch(userKey, ticker string) error {
_, 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.
// SaveReport stores a generated report; returns its id.
func (db *DB) SaveReport(ticker, payload, cites string) (int64, error) {
res, err := db.Exec(`INSERT INTO reports(ticker,generated_at,payload_json,citations_json)
VALUES(?,?,?,?)`, ticker, time.Now().UTC().Format(time.RFC3339), payload, cites)
// SaveReport stores a generated report owned by userKey; returns its id.
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,user_key)
VALUES(?,?,?,?,?)`, ticker, time.Now().UTC().Format(time.RFC3339), payload, cites, userKey)
if err != nil {
return 0, err
}
@@ -622,7 +819,16 @@ func (db *DB) ListReports(ticker string, limit int) ([]map[string]any, error) {
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) {
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)
@@ -764,28 +970,48 @@ func (db *DB) PruneSnapshots(cutoff string) (int64, error) {
// 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) {
// Pull extra rows (each holds up to 30 bars) and dedupe by bar date.
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 {
return nil, nil, err
}
defer rows.Close()
var vols []float64
var dates []string
byDate := map[string]float64{}
seen := map[string]bool{}
var dateOrder []string
for rows.Next() {
var d, p string
if err := rows.Scan(&d, &p); err != nil {
return nil, nil, err
}
for _, b := range parseDailyBars(p, d) {
if b.Volume > 0 {
vols, dates = append(vols, b.Volume), append(dates, b.Date)
if b.Volume <= 0 || seen[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.
@@ -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.
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 {
return nil, err
}
for _, d := range all {
if d.ID == id {
c := d
return &c, nil
}
}
return nil, nil
return &d, nil
}
// 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 {
t.Fatalf("filings = %v, %v", fils, err)
}
_, _ = db.SaveReport("BBCA", `{"a":1}`, `[]`)
_, _ = db.SaveReport("BBCA", `{"a":1}`, `[]`, "tester")
reps, err := db.ListReports("BBCA", 10)
if err != nil || len(reps) != 1 {
t.Fatalf("reports = %v, %v", reps, err)
@@ -107,3 +107,95 @@ func TestDestinationCRUD(t *testing.T) {
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("");
async function add() {
setErr("");
const t = ticker().toUpperCase().trim();
if (!t) return;
try {
await api.addWatch(ticker().toUpperCase().trim());
await api.addWatch(t);
setTicker("");
refetch();
} catch (e) { setErr(String(e)); }
}
async function del(t: string) {
await api.removeWatch(t);
refetch();
try {
await api.removeWatch(t);
refetch();
} catch (e) { setErr(String(e)); }
}
return (
<Card>
<CardHeader class="pb-2"><CardTitle class="text-base">Watchlist</CardTitle></CardHeader>
<CardContent class="space-y-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>
</div>
<Show when={err()}><p class="text-xs text-destructive">{err()}</p></Show>
<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>
</CardContent>
</Card>
@@ -40,7 +47,7 @@ export function WatchlistDrawer() {
export function ChatSidebar() {
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() {
const q = msg().trim();
if (!q) return;
@@ -49,20 +56,28 @@ export function ChatSidebar() {
const r = await api.chat(q);
setLog((p) => [...p, { q, a: r.answer }].slice(-10));
} catch (e) {
setLog((p) => [...p, { q, a: String(e) }].slice(-10));
setLog((p) => [...p, { q, a: String(e), err: true }].slice(-10));
}
}
return (
<Card>
<CardHeader class="pb-2"><CardTitle class="text-base">AI Chat</CardTitle></CardHeader>
<CardContent class="space-y-2">
<div class="max-h-64 space-y-2 overflow-y-auto">
<For each={log()}>
{(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>
</div>
<Show when={log().length} fallback={<p class="text-xs text-muted-foreground">Tanya soal watchlist-mu — sistem jawab dari data yang tersimpan (bukan karangan).</p>}>
<div class="max-h-64 space-y-2 overflow-y-auto">
<For each={log()}>
{(m) => (
<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">
<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>
</div>
</CardContent>
+27 -4
View File
@@ -2,6 +2,7 @@ import { createResource, createSignal, Show } from "solid-js";
import { api, type AuthUser } from "../lib/api";
import { Button } from "./ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
// Theme: .dark class on <html> (shadcn convention). Default dark.
function theme(): string {
@@ -27,17 +28,39 @@ export function ThemeToggle() {
}
export function useAuth() {
// me() is undefined while loading, null when logged out, AuthUser when in.
const [me, { refetch }] = createResource(async (): Promise<AuthUser | null> => {
try { return (await api.me()).user; } catch { return null; }
});
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 }) {
return (
<Show when={props.me} fallback={
<a href="/api/auth/start"><Button>Sign in with Google</Button></a>
}>
<Show when={props.me} fallback={<a href="/login"><Button>Masuk / Daftar</Button></a>}>
{(u) => (
<span class="flex items-center gap-2">
<Avatar class="size-8">
+120 -37
View File
@@ -1,12 +1,12 @@
import { render } from "solid-js/web";
import { Router, Route, useLocation, useNavigate, useParams, type RouteSectionProps } from "@solidjs/router";
import { createSignal, Show } from "solid-js";
import { Router, Route, useLocation, useNavigate, type RouteSectionProps } from "@solidjs/router";
import { createResource, createSignal, createEffect, Show } from "solid-js";
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./components/ui/card";
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 Routines from "./pages/Routines";
import Screener from "./pages/Screener";
@@ -15,7 +15,7 @@ import Report from "./pages/Report";
import Portfolio from "./pages/Portfolio";
import "./styles/globals.css";
const NAV = [
const NAV_ALL = [
{ href: "/", icon: "📊", label: "Dashboard" },
{ href: "/routines", icon: "🗓️", label: "Routines" },
{ href: "/screener", icon: "🔍", label: "Screener" },
@@ -23,30 +23,40 @@ const NAV = [
{ href: "/portfolio", icon: "💼", label: "Portfolio" },
];
function Nav() {
function Nav(props: { loggedIn: boolean }) {
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 (
<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">
Flow<span class="text-primary">Sight</span>
<div class="mb-5 px-2">
<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>
{NAV.map((n) => (
{items().map((n) => (
<a
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>
))}
</nav>
);
}
function MobileNav() {
function MobileNav(props: { loggedIn: boolean }) {
const loc = useLocation();
const items = () => props.loggedIn ? NAV_ALL : NAV_ALL.slice(0, 1);
return (
<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
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"}`}
@@ -58,38 +68,82 @@ function MobileNav() {
);
}
function TickerSearch() {
function TickerSearch(props: { loggedIn: boolean }) {
const nav = useNavigate();
const [q, setQ] = createSignal("");
const go = () => {
const t = q().toUpperCase().trim();
if (t) nav(`/report/${t}`);
const go = (t: string) => {
const v = t.toUpperCase().trim();
// Report butuh login: arahkan yg belum login ke /login.
if (v) nav(props.loggedIn ? `/report/${v}` : "/login");
};
return (
<form class="flex max-w-sm flex-1 items-center gap-2" onSubmit={(e) => { e.preventDefault(); go(); }}>
<TextField class="flex-1">
<TextFieldInput placeholder="Ticker → report (e.g. BBCA)" value={q()}
onInput={(e) => setQ(e.currentTarget.value)} />
</TextField>
<Button type="submit" variant="secondary">Open</Button>
<form class="flex max-w-sm flex-1 items-center gap-2" onSubmit={(e) => { e.preventDefault(); go(q()); }}>
<div class="relative flex-1">
<TextFieldInput
placeholder="Cari kode saham…"
value={q()}
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>
);
}
function LoginPage() {
const params = useParams<{ error?: string }>();
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 (
<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>
<CardHeader class="text-center">
<CardTitle class="text-2xl">Welcome to FlowSight</CardTitle>
<CardDescription>Sign in with Google to get your own watchlist, routines, alerts and reports.</CardDescription>
<CardTitle class="text-2xl">Masuk ke FlowSight 👋</CardTitle>
<CardDescription>
Daftar gratis pakai username + password — watchlist, notifikasi,
dan report jadi milik kamu sendiri.
</CardDescription>
</CardHeader>
<CardContent class="text-center">
<Show when={err()}><p class="mb-3 text-sm text-destructive">Login failed: {err()}</p></Show>
<a href="/api/auth/start"><Button size="lg">Sign in with Google</Button></a>
<CardContent class="space-y-3">
<Show when={err()}><p class="text-sm text-destructive">Login gagal: {err()}</p></Show>
<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>
</Card>
</div>
@@ -98,13 +152,24 @@ function LoginPage() {
function Shell(props: RouteSectionProps) {
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 (
<div class="flex min-h-screen bg-background text-foreground">
<Nav />
<Nav loggedIn={!!me()} />
<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">
<TickerSearch />
<TickerSearch loggedIn={!!me()} />
<span class="flex-1" />
<ThemeToggle />
<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>
</div>
<aside class="hidden w-80 shrink-0 flex-col gap-4 border-l bg-card p-4 xl:flex">
<WatchlistDrawer />
<ChatSidebar />
<Show when={me()} fallback={
<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>
<MobileNav />
<MobileNav loggedIn={!!me()} />
</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(
() => (
<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).
const BASE = "";
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> {
const r = await fetch(BASE + path, { ...init, headers: { ...headers(), ...(init?.headers || {}) } });
if (!r.ok) {
if (r.status === 401 && !path.startsWith("/api/auth/")) notifyAuthExpired();
let msg = r.statusText;
try { const e = await r.json(); msg = e?.error?.message || msg; } catch { /* keep status */ }
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 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 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 }
@@ -27,12 +35,13 @@ export interface ForeignSeries { dates: string[]; nets: number[]; reversal: bool
export const api = {
health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`),
flowSummary: () => req<FlowSummary>("/api/flow/summary"),
flowBroker: (ticker: string) => req<{ buyers: unknown[]; sellers: unknown[]; citations: Citation[] }>(`/api/flow/broker?ticker=${ticker}`),
flowForeign: (ticker: string) => req<ForeignSeries>(`/api/flow/foreign?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=${encodeURIComponent(ticker)}`),
screen: (body: Record<string, unknown>) => req<{ rows: ScreenRow[]; count: number }>("/api/screen", { method: "POST", body: JSON.stringify(body) }),
routines: () => req<{ routines: Routine[] }>("/api/routines"),
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) }),
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}` : ""}`),
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"),
@@ -43,17 +52,20 @@ export const api = {
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" }),
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" }),
reportMd: (ticker: string) => req<string>(`/api/report/${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 }) }),
report: (ticker: string, profile = "moderate") => req<ReportPayload>(`/api/report/${encodeURIComponent(ticker)}?profile=${profile}`, { 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/${encodeURIComponent(ticker)}/ask`, { method: "POST", body: JSON.stringify({ question, report_id }) }),
watchlist: () => req<{ watchlist: string[] }>("/api/watchlist"),
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"),
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 }) }),
me: () => req<{ user: AuthUser }>(`/api/auth/me`),
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.
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 }] } },
];
export default function Alerts() {
function AlertsInner() {
const [alerts, { refetch }] = createResource(() => api.alerts());
const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 30)));
const [dests, { refetch: refetchDests }] = createResource(() => api.destinations());
@@ -28,14 +28,17 @@ export default function Alerts() {
const [dchat, setDchat] = createSignal("");
const [dhook, setDhook] = createSignal("");
const [derr, setDerr] = createSignal("");
const [err, setErr] = createSignal("");
async function create() {
setBusy(true);
try {
await api.createAlert({ name: tpl().label, rule: tpl().rule, channels: channels().split(",").map((c) => c.trim()).filter(Boolean) });
setChannels("");
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() {
setDerr("");
try {
@@ -47,11 +50,17 @@ export default function Alerts() {
refetchDests();
} catch (e) { setDerr(String(e)); }
}
async function toggleDest(id: number, enabled: boolean) { await api.updateDestination(id, { enabled: !enabled }); refetchDests(); }
async function delDest(id: number) { await api.deleteDestination(id); refetchDests(); }
const fmtDate = (s: unknown) => {
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 (
<div class="space-y-4">
<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>
<CardHeader><CardTitle>1. Mau dikabari soal apa?</CardTitle><CardDescription>Pilih satu template <Term kata="alert" />.</CardDescription></CardHeader>
<CardContent class="space-y-3">
@@ -107,7 +116,11 @@ export default function Alerts() {
<CardHeader><CardTitle>Notifikasi terpasang</CardTitle></CardHeader>
<CardContent>
<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>
</CardContent>
</Card>
@@ -118,7 +131,7 @@ export default function Alerts() {
</CardHeader>
<CardContent>
<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>
</CardContent>
</Card>
@@ -127,3 +140,9 @@ export default function Alerts() {
</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 { api } from "../lib/api";
import { useAuth } from "../components/auth";
import { verdictFor, heroSummary, konteksPasar, fmtRp, fmtHarga } from "../lib/awam";
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
import { Badge } from "../components/ui/badge";
@@ -13,12 +14,21 @@ Chart.register(...registerables);
export default function Dashboard() {
const navigate = useNavigate();
const { me } = useAuth();
// screen+alertEvents are login-gated; source = me() so login/logout refetches.
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 [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 [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 n = briefing()?.narasi?.trim();
@@ -28,13 +38,20 @@ export default function Dashboard() {
};
const sorotan = () => (screen()?.rows || []).map((r) => ({ row: r, ...verdictFor(r) }));
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 = () => ({
labels: (foreign()?.dates || []) as string[],
datasets: [{
label: `${chartTiker()} — uang asing harian (${fmtRp(0).slice(0, 2)}7700`,
label: `${chartTiker()} — uang asing harian (Rp juta)`,
data: (foreign()?.nets || []) as number[],
borderColor: "#3b82f6",
backgroundColor: "rgba(59,130,246,.15)",
borderColor: primaryColor(),
backgroundColor: "transparent",
fill: true,
tension: 0.25,
}],
@@ -89,10 +106,15 @@ export default function Dashboard() {
return (
<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">
<CardTitle class="text-xl font-bold">
{s.row.symbol}
<Show when={cx?.close}><span class="ml-2 font-mono text-sm font-normal text-muted-foreground">{fmtHarga(cx.close)}</span></Show>
</CardTitle>
<div>
<CardTitle class="text-xl font-bold">
{s.row.symbol}
<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} />
</CardHeader>
<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>
</CardHeader>
<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) =>
<Button size="sm" variant={chartTiker() === t ? "default" : "outline"} onClick={() => setChartTiker(t)}>{t}</Button>
}</For>
+21 -2
View File
@@ -9,7 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../co
import { Skeleton } from "../components/ui/skeleton";
Chart.register(...registerables);
export default function Portfolio() {
function PortfolioInner() {
const [risk] = createResource(() => api.risk());
const conc = () => risk()?.concentration || [];
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.`;
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 = () => ({
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 (
<div class="space-y-4">
@@ -66,3 +79,9 @@ export default function Portfolio() {
</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 { api, type ReportPayload } from "../lib/api";
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 { TextField, TextFieldInput } from "../components/ui/text-field";
export default function Report() {
function ReportInner() {
const params = useParams();
const [rep, setRep] = createSignal<ReportPayload | null>(null);
const [md, setMd] = createSignal("");
@@ -19,26 +19,34 @@ export default function Report() {
const [busy, setBusy] = createSignal(true);
const [asking, setAsking] = createSignal(false);
const [err, setErr] = createSignal("");
const authHeaders = () => ({ "X-User-Key": localStorage.getItem("fs-key") || "demo" });
async function load() {
setErr(""); setBusy(true);
try { setRep(await api.report(params.ticker)); }
catch (e) { setErr(String(e)); }
finally { setBusy(false); }
}
onMount(load);
async function exportMd() { setMd(await api.reportMd(params.ticker)); }
// Refetch when the ticker param changes (ReportInner stays mounted
// 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() {
if (!question().trim()) return;
setAsking(true);
try {
const r = await api.interrogate(params.ticker, question());
setAnswer(r.answer);
} catch (e) {
setErr(String(e));
} finally { setAsking(false); }
}
function dl(url: string, name: string) {
const a = document.createElement("a");
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 (
<div class="space-y-4">
@@ -72,15 +80,21 @@ export default function Report() {
<CardContent class="flex flex-wrap gap-2">
<Button variant="outline" onClick={exportMd}>Markdown</Button>
<Button variant="outline" onClick={async () => {
const r = await fetch(`/api/report/${params.ticker}?format=html`, { method: "POST", headers: authHeaders() });
dl(URL.createObjectURL(new Blob([await r.text()], { type: "text/html" })), `${params.ticker}-report.html`);
try {
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>
<Button variant="outline" onClick={() => {
dl(URL.createObjectURL(new Blob([JSON.stringify(rep(), null, 1)], { type: "application/json" })), `${params.ticker}-report.json`);
}}>JSON</Button>
<Button variant="outline" onClick={async () => {
const r = await fetch(`/api/report/${params.ticker}?format=pdf`, { method: "POST", headers: authHeaders() });
dl(URL.createObjectURL(await r.blob()), `${params.ticker}-report.pdf`);
try {
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>
</CardContent>
</Card>
@@ -103,3 +117,9 @@ export default function Report() {
</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." },
};
export default function Routines() {
function RoutinesInner() {
const [data, { refetch }] = createResource(() => api.routines());
const [runs, { refetch: refetchRuns }] = createResource(() => api.runs().then((r) => r.runs.slice(0, 20)));
const [type_, setType] = createSignal("morning-briefing");
const [busy, setBusy] = createSignal(false);
const [err, setErr] = createSignal("");
const [briefing] = createResource(() => api.briefing().catch(() => null));
async function subscribe() {
setBusy(true);
try { await api.createRoutine({ type: type_() }); refetch(); }
catch (e) { setErr(String(e)); }
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) {
await fetch(`/api/routines/${id}`, { method: "DELETE", headers: { "X-User-Key": localStorage.getItem("fs-key") || "demo" } });
refetch();
try { await api.deleteRoutine(id); refetch(); } catch (e) { setErr(String(e)); }
}
return (
<div class="space-y-4">
<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>
<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">
@@ -89,7 +100,17 @@ export default function Routines() {
<Button variant="ghost" size="sm" onClick={() => refetchRuns()}>Refresh</Button>
</CardHeader>
<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>
</Card>
</div>
@@ -97,3 +118,9 @@ export default function Routines() {
</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 { verdictFor } from "../lib/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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { Skeleton } from "../components/ui/skeleton";
import { TextField, TextFieldInput } from "../components/ui/text-field";
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> }[] = [
{ 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 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 [rows, setRows] = createSignal<ScreenRow[]>([]);
const [ran, setRan] = createSignal(false);
const [busy, setBusy] = createSignal(false);
const [err, setErr] = createSignal("");
const [aktif, setAktif] = createSignal("");
const [q, setQ] = createSignal("");
const [showCustom, setShowCustom] = createSignal(false);
async function runPreset(p: (typeof PRESETS)[number]) {
setErr(""); setBusy(true); setAktif(p.label);
setErr(""); setBusy(true); setAktif(p.label); setShowCustom(false);
try {
const r = await api.screen(p.body);
setRows(r.rows); setRan(true);
} catch (e) { setErr(String(e)); }
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) }));
return (
<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>
</Card>
)}</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>
<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>
@@ -57,7 +80,12 @@ export default function Screener() {
return (
<Card>
<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} />
</CardHeader>
<CardContent class="space-y-3">
@@ -77,3 +105,9 @@ export default function Screener() {
</div>
);
}
import { Gate } from "../index";
export default function Screener() {
return <Gate fitur="Cari saham"><ScreenerInner /></Gate>;
}