feat: default watchlist semua, login-gate fitur, CI anti-stale via /api/version
This commit is contained in:
@@ -154,7 +154,7 @@ jobs:
|
||||
sleep 3 && \
|
||||
sudo systemctl status flowsight --no-pager --no-legend | head -5"
|
||||
|
||||
- name: Verify service
|
||||
- name: Verify service anti-stale
|
||||
run: |
|
||||
ssh "$VPS_USER@$VPS_HOST" \
|
||||
"curl -s -o /dev/null -w 'local:%{http_code}\n' http://localhost:4022/api/health && \
|
||||
|
||||
@@ -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,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,27 @@ 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"})
|
||||
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)
|
||||
}
|
||||
// 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.example/hook"})
|
||||
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 +264,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +137,120 @@ 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
|
||||
}
|
||||
|
||||
// mintSession creates a session + sets the fs_session cookie.
|
||||
func (s *Server) mintSession(w http.ResponseWriter, 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: true, 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, 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, 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"`
|
||||
|
||||
@@ -19,7 +19,11 @@ func (s *Server) FlowSummary(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
|
||||
}
|
||||
}
|
||||
type accRow struct {
|
||||
Ticker string `json:"ticker"`
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"time"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
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, _ := 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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -55,7 +55,11 @@ func (s *Server) Screen(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
var rows []ScreenRow
|
||||
|
||||
@@ -35,6 +35,9 @@ type Server struct {
|
||||
LLM *llm.Client
|
||||
Validate *validator.Validate
|
||||
Hub *Hub
|
||||
// Commit + StartedAt power /api/version (CI anti-stale proof).
|
||||
Commit string
|
||||
StartedAt time.Time
|
||||
}
|
||||
|
||||
// New builds a Server with all dependencies wired.
|
||||
@@ -45,6 +48,8 @@ func New(cfg config.Config, db *store.DB, cache *store.Cache, s *sectors.Client)
|
||||
Cfg: cfg, DB: db, Sectors: s, Sched: sched, LLM: llmc,
|
||||
Validate: validator.New(),
|
||||
Hub: NewHub(),
|
||||
Commit: readCommit(),
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
srv.Engine = &routines.Engine{DB: db, Notifier: sched.Notifier, UserKey: cfg.DemoUserKey,
|
||||
Publish: srv.Hub.Publish}
|
||||
@@ -66,33 +71,41 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/auth/callback", s.AuthCallback)
|
||||
r.Get("/auth/me", s.AuthMe)
|
||||
r.Post("/auth/logout", s.AuthLogout)
|
||||
r.Post("/auth/signup", s.AuthSignup)
|
||||
r.Post("/auth/login", s.AuthLogin)
|
||||
r.Get("/version", s.Version)
|
||||
r.Get("/stream", s.Stream)
|
||||
// Publik baca: dashboard bisa dibuka tanpa login.
|
||||
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)
|
||||
// Fitur + filter: wajib login (session cookie, tanpa demo bypass).
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireLogin)
|
||||
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())
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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 resolved once at startup (see readCommit); an old process keeps
|
||||
// reporting its old commit even after CI writes a new version.txt.
|
||||
func (s *Server) Version(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"commit": s.Commit,
|
||||
"started_at": s.StartedAt.UTC().Format(time.RFC3339),
|
||||
"google_configured": s.Cfg.HasGoogle(),
|
||||
})
|
||||
}
|
||||
|
||||
// readCommit resolves the deployed commit once at startup: CI writes
|
||||
// $GITHUB_SHA to version.txt (WorkingDirectory) before restarting.
|
||||
func readCommit() string {
|
||||
if v := strings.TrimSpace(os.Getenv("FLOWSIGHT_COMMIT")); v != "" {
|
||||
return v
|
||||
}
|
||||
for _, p := range []string{"version.txt", "/var/lib/flowsight/version.txt"} {
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
if v := strings.TrimSpace(string(b)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
@@ -190,12 +190,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
|
||||
}
|
||||
|
||||
@@ -5,10 +5,24 @@ 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 +122,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);
|
||||
@@ -540,6 +540,28 @@ 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) {
|
||||
rows, err := db.Query(`SELECT DISTINCT ticker FROM snapshots
|
||||
WHERE ticker NOT IN ('IDX','ROE') ORDER BY ticker`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var t string
|
||||
if err := rows.Scan(&t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
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(?,?,?)
|
||||
|
||||
@@ -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 {
|
||||
@@ -30,7 +31,30 @@ export function useAuth() {
|
||||
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 }) {
|
||||
|
||||
+76
-22
@@ -1,8 +1,8 @@
|
||||
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, 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";
|
||||
@@ -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,14 +23,16 @@ 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);
|
||||
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>
|
||||
{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"}`}
|
||||
@@ -42,11 +44,12 @@ function 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,12 +61,13 @@ 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}`);
|
||||
// Report butuh login: arahkan yg belum login ke /login.
|
||||
if (t) nav(props.loggedIn ? `/report/${t}` : "/login");
|
||||
};
|
||||
return (
|
||||
<form class="flex max-w-sm flex-1 items-center gap-2" onSubmit={(e) => { e.preventDefault(); go(); }}>
|
||||
@@ -77,19 +81,51 @@ function TickerSearch() {
|
||||
}
|
||||
|
||||
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]">
|
||||
<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>
|
||||
@@ -101,10 +137,10 @@ function Shell(props: RouteSectionProps) {
|
||||
const logout = async () => { await api.logout(); refetch(); };
|
||||
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 +148,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() !== null} 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}>
|
||||
|
||||
@@ -54,6 +54,9 @@ export const api = {
|
||||
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 {
|
||||
|
||||
@@ -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());
|
||||
@@ -127,3 +127,9 @@ export default function Alerts() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import { Gate } from "../index";
|
||||
|
||||
export default function Alerts() {
|
||||
return <Gate fitur="Notifikasi">{AlertsInner()}</Gate>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -66,3 +66,9 @@ export default function Portfolio() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import { Gate } from "../index";
|
||||
|
||||
export default function Portfolio() {
|
||||
return <Gate fitur="Portofolio">{PortfolioInner()}</Gate>;
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
@@ -103,3 +103,9 @@ export default function Report() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import { Gate } from "../index";
|
||||
|
||||
export default function Report() {
|
||||
return <Gate fitur="Report saham">{<ReportInner />}</Gate>;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ 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");
|
||||
@@ -97,3 +97,9 @@ export default function Routines() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import { Gate } from "../index";
|
||||
|
||||
export default function Routines() {
|
||||
return <Gate fitur="Jadwal otomatis">{RoutinesInner()}</Gate>;
|
||||
}
|
||||
|
||||
@@ -8,15 +8,16 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../co
|
||||
import { Skeleton } from "../components/ui/skeleton";
|
||||
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);
|
||||
@@ -31,6 +32,8 @@ export default function Screener() {
|
||||
} catch (e) { setErr(String(e)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
// Default = semua: auto-jalan preset "Semua" sekali saat halaman dibuka.
|
||||
if (!ran() && !busy()) void runPreset(PRESET_SEMUA);
|
||||
const hasil = () => rows().map((r) => ({ row: r, ...verdictFor(r) }));
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
@@ -77,3 +80,9 @@ export default function Screener() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import { Gate } from "../index";
|
||||
|
||||
export default function Screener() {
|
||||
return <Gate fitur="Cari saham">{ScreenerInner()}</Gate>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user