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 && \
|
sleep 3 && \
|
||||||
sudo systemctl status flowsight --no-pager --no-legend | head -5"
|
sudo systemctl status flowsight --no-pager --no-legend | head -5"
|
||||||
|
|
||||||
- name: Verify service
|
- name: Verify service anti-stale
|
||||||
run: |
|
run: |
|
||||||
ssh "$VPS_USER@$VPS_HOST" \
|
ssh "$VPS_USER@$VPS_HOST" \
|
||||||
"curl -s -o /dev/null -w 'local:%{http_code}\n' http://localhost:4022/api/health && \
|
"curl -s -o /dev/null -w 'local:%{http_code}\n' http://localhost:4022/api/health && \
|
||||||
|
|||||||
@@ -61,9 +61,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ensure the demo watchlist exists even without a seed bundle.
|
// Ensure the demo account covers the "semua" default: every ticker with
|
||||||
|
// stored data (fallback: config watchlist).
|
||||||
if wl, _ := db.Watchlist(cfg.DemoUserKey); len(wl) == 0 {
|
if wl, _ := db.Watchlist(cfg.DemoUserKey); len(wl) == 0 {
|
||||||
for _, t := range cfg.Watchlist {
|
seeds, _ := db.AllTickers()
|
||||||
|
if len(seeds) == 0 {
|
||||||
|
seeds = cfg.Watchlist
|
||||||
|
}
|
||||||
|
for _, t := range seeds {
|
||||||
_ = db.AddWatch(cfg.DemoUserKey, t)
|
_ = db.AddWatch(cfg.DemoUserKey, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ func TestBriefing(t *testing.T) {
|
|||||||
rec := do(s, "GET", "/api/briefing/today", nil)
|
rec := do(s, "GET", "/api/briefing/today", nil)
|
||||||
if rec.Code == http.StatusNotFound {
|
if rec.Code == http.StatusNotFound {
|
||||||
// No briefing yet: run the routine via engine path instead.
|
// No briefing yet: run the routine via engine path instead.
|
||||||
rows, _ := s.DB.ListRoutines("demo")
|
u, _ := s.DB.CheckLocalUser("tester", "password1234")
|
||||||
|
rows, _ := s.DB.ListRoutines(u.UserKey)
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
t.Fatal("seed has no routines")
|
t.Fatal("seed has no routines")
|
||||||
}
|
}
|
||||||
@@ -82,7 +83,7 @@ func TestBriefing(t *testing.T) {
|
|||||||
// Screener returns a ranked list with per-row breakdown.
|
// Screener returns a ranked list with per-row breakdown.
|
||||||
func TestScreen(t *testing.T) {
|
func TestScreen(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
rec := do(s, "POST", "/api/screen", map[string]any{"limit": 5})
|
rec := doAuth(t, s, "POST", "/api/screen", map[string]any{"limit": 5})
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("code = %d", rec.Code)
|
t.Fatalf("code = %d", rec.Code)
|
||||||
}
|
}
|
||||||
@@ -101,7 +102,7 @@ func TestScreen(t *testing.T) {
|
|||||||
// Report: all 7 sections populated with citations.
|
// Report: all 7 sections populated with citations.
|
||||||
func TestReport(t *testing.T) {
|
func TestReport(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
rec := do(s, "POST", "/api/report/BBCA?profile=moderate", nil)
|
rec := doAuth(t, s, "POST", "/api/report/BBCA?profile=moderate", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:300])
|
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:300])
|
||||||
}
|
}
|
||||||
@@ -122,20 +123,21 @@ func TestReport(t *testing.T) {
|
|||||||
// Subscribe -> run -> history row appears.
|
// Subscribe -> run -> history row appears.
|
||||||
func TestRoutineSubscribeRunHistory(t *testing.T) {
|
func TestRoutineSubscribeRunHistory(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
rec := do(s, "POST", "/api/routines", map[string]any{"type": "foreign-reversal"})
|
rec := doAuth(t, s, "POST", "/api/routines", map[string]any{"type": "foreign-reversal"})
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("code = %d", rec.Code)
|
t.Fatalf("code = %d", rec.Code)
|
||||||
}
|
}
|
||||||
var created map[string]any
|
var created map[string]any
|
||||||
_ = json.Unmarshal(rec.Body.Bytes(), &created)
|
_ = json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
rows, _ := s.DB.ListRoutines("demo")
|
u, _ := s.DB.CheckLocalUser("tester", "password1234")
|
||||||
|
rows, _ := s.DB.ListRoutines(u.UserKey)
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
t.Fatal("no routines")
|
t.Fatal("no routines")
|
||||||
}
|
}
|
||||||
if _, err := s.Engine.Run(httptest.NewRequest("GET", "/", nil).Context(), rows[0]); err != nil {
|
if _, err := s.Engine.Run(httptest.NewRequest("GET", "/", nil).Context(), rows[0]); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
rec = do(s, "GET", "/api/routine-runs?limit=5", nil)
|
rec = doAuth(t, s, "GET", "/api/routine-runs?limit=5", nil)
|
||||||
var out struct {
|
var out struct {
|
||||||
Runs []map[string]any `json:"runs"`
|
Runs []map[string]any `json:"runs"`
|
||||||
}
|
}
|
||||||
@@ -149,11 +151,11 @@ func TestRoutineSubscribeRunHistory(t *testing.T) {
|
|||||||
// from the persisted report, unknown report 404s.
|
// from the persisted report, unknown report 404s.
|
||||||
func TestInterrogate(t *testing.T) {
|
func TestInterrogate(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
rec := do(s, "POST", "/api/report/BBCA?profile=moderate", nil)
|
rec := doAuth(t, s, "POST", "/api/report/BBCA?profile=moderate", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("code = %d", rec.Code)
|
t.Fatalf("code = %d", rec.Code)
|
||||||
}
|
}
|
||||||
rec = do(s, "POST", "/api/report/BBCA/ask", map[string]any{"question": "kenapa conviction segitu?"})
|
rec = doAuth(t, s, "POST", "/api/report/BBCA/ask", map[string]any{"question": "kenapa conviction segitu?"})
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:200])
|
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:200])
|
||||||
}
|
}
|
||||||
@@ -164,7 +166,7 @@ func TestInterrogate(t *testing.T) {
|
|||||||
if out.Answer == "" {
|
if out.Answer == "" {
|
||||||
t.Fatal("empty interrogation answer")
|
t.Fatal("empty interrogation answer")
|
||||||
}
|
}
|
||||||
rec = do(s, "POST", "/api/report/ZZZZ/ask", map[string]any{"question": "apa?"})
|
rec = doAuth(t, s, "POST", "/api/report/ZZZZ/ask", map[string]any{"question": "apa?"})
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Fatalf("code = %d, want 404 for unknown ticker", rec.Code)
|
t.Fatalf("code = %d, want 404 for unknown ticker", rec.Code)
|
||||||
}
|
}
|
||||||
@@ -173,11 +175,11 @@ func TestInterrogate(t *testing.T) {
|
|||||||
// Concentrated fixture warns >40% sector; accuracy math covered.
|
// Concentrated fixture warns >40% sector; accuracy math covered.
|
||||||
func TestPortfolioAndAccuracy(t *testing.T) {
|
func TestPortfolioAndAccuracy(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
rec := do(s, "GET", "/api/portfolio/risk", nil)
|
rec := doAuth(t, s, "GET", "/api/portfolio/risk", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("code = %d", rec.Code)
|
t.Fatalf("code = %d", rec.Code)
|
||||||
}
|
}
|
||||||
rec = do(s, "GET", "/api/accuracy", nil)
|
rec = doAuth(t, s, "GET", "/api/accuracy", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("code = %d", rec.Code)
|
t.Fatalf("code = %d", rec.Code)
|
||||||
}
|
}
|
||||||
@@ -203,19 +205,19 @@ func TestFlowForeignWindow(t *testing.T) {
|
|||||||
// Unknown routine types are rejected; missing ids 404.
|
// Unknown routine types are rejected; missing ids 404.
|
||||||
func TestRoutineValidation(t *testing.T) {
|
func TestRoutineValidation(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
rec := do(s, "POST", "/api/routines", map[string]any{"type": "not-a-routine"})
|
rec := doAuth(t, s, "POST", "/api/routines", map[string]any{"type": "not-a-routine"})
|
||||||
if rec.Code != http.StatusUnprocessableEntity {
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
t.Fatalf("code = %d, want 422", rec.Code)
|
t.Fatalf("code = %d, want 422", rec.Code)
|
||||||
}
|
}
|
||||||
rec = do(s, "PATCH", "/api/routines/999999", map[string]any{"enabled": false})
|
rec = doAuth(t, s, "PATCH", "/api/routines/999999", map[string]any{"enabled": false})
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Fatalf("code = %d, want 404", rec.Code)
|
t.Fatalf("code = %d, want 404", rec.Code)
|
||||||
}
|
}
|
||||||
rec = do(s, "DELETE", "/api/routines/999999", nil)
|
rec = doAuth(t, s, "DELETE", "/api/routines/999999", nil)
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Fatalf("code = %d, want 404", rec.Code)
|
t.Fatalf("code = %d, want 404", rec.Code)
|
||||||
}
|
}
|
||||||
rec = do(s, "DELETE", "/api/alerts/999999", nil)
|
rec = doAuth(t, s, "DELETE", "/api/alerts/999999", nil)
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Fatalf("code = %d, want 404", rec.Code)
|
t.Fatalf("code = %d, want 404", rec.Code)
|
||||||
}
|
}
|
||||||
@@ -225,27 +227,27 @@ func TestRoutineValidation(t *testing.T) {
|
|||||||
func TestDestinations(t *testing.T) {
|
func TestDestinations(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
// Invalid kind -> 422.
|
// Invalid kind -> 422.
|
||||||
rec := do(s, "POST", "/api/destinations", map[string]any{"kind": "sms"})
|
rec := doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "sms"})
|
||||||
if rec.Code != http.StatusUnprocessableEntity {
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
t.Fatalf("code = %d, want 422", rec.Code)
|
t.Fatalf("code = %d, want 422", rec.Code)
|
||||||
}
|
}
|
||||||
// Telegram without chat_id -> 422.
|
// Telegram without chat_id -> 422.
|
||||||
rec = do(s, "POST", "/api/destinations", map[string]any{"kind": "telegram", "bot_token": "x"})
|
rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "telegram", "bot_token": "x"})
|
||||||
if rec.Code != http.StatusUnprocessableEntity {
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
t.Fatalf("code = %d, want 422", rec.Code)
|
t.Fatalf("code = %d, want 422", rec.Code)
|
||||||
}
|
}
|
||||||
// Discord non-https -> 422.
|
// Discord non-https -> 422.
|
||||||
rec = do(s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "http://x"})
|
rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "http://x"})
|
||||||
if rec.Code != http.StatusUnprocessableEntity {
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
t.Fatalf("code = %d, want 422", rec.Code)
|
t.Fatalf("code = %d, want 422", rec.Code)
|
||||||
}
|
}
|
||||||
// Valid discord create -> 201.
|
// Valid discord create -> 201.
|
||||||
rec = do(s, "POST", "/api/destinations", map[string]any{"kind": "discord", "label": "ops", "webhook_url": "https://discord.example/hook"})
|
rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "label": "ops", "webhook_url": "https://discord.example/hook"})
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String())
|
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
// List masks secrets.
|
// List masks secrets.
|
||||||
rec = do(s, "GET", "/api/destinations", nil)
|
rec = doAuth(t, s, "GET", "/api/destinations", nil)
|
||||||
var out struct {
|
var out struct {
|
||||||
Destinations []map[string]any `json:"destinations"`
|
Destinations []map[string]any `json:"destinations"`
|
||||||
}
|
}
|
||||||
@@ -262,11 +264,11 @@ func TestDestinations(t *testing.T) {
|
|||||||
t.Fatalf("configured flag = %v", out.Destinations[0])
|
t.Fatalf("configured flag = %v", out.Destinations[0])
|
||||||
}
|
}
|
||||||
// Missing id -> 404 on patch and delete.
|
// Missing id -> 404 on patch and delete.
|
||||||
rec = do(s, "PATCH", "/api/destinations/999999", map[string]any{"enabled": false})
|
rec = doAuth(t, s, "PATCH", "/api/destinations/999999", map[string]any{"enabled": false})
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Fatalf("code = %d, want 404", rec.Code)
|
t.Fatalf("code = %d, want 404", rec.Code)
|
||||||
}
|
}
|
||||||
rec = do(s, "DELETE", "/api/destinations/999999", nil)
|
rec = doAuth(t, s, "DELETE", "/api/destinations/999999", nil)
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Fatalf("code = %d, want 404", rec.Code)
|
t.Fatalf("code = %d, want 404", rec.Code)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,9 +77,13 @@ func (s *Server) AuthCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
fail("user store unavailable")
|
fail("user store unavailable")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Ensure the login user owns the default watchlist on first login.
|
// Ensure the login user owns the "semua" default watchlist on first login.
|
||||||
if wl, _ := s.DB.Watchlist(user.UserKey); len(wl) == 0 {
|
if wl, _ := s.DB.Watchlist(user.UserKey); len(wl) == 0 {
|
||||||
for _, t := range s.Cfg.Watchlist {
|
seeds, _ := s.DB.AllTickers()
|
||||||
|
if len(seeds) == 0 {
|
||||||
|
seeds = s.Cfg.Watchlist
|
||||||
|
}
|
||||||
|
for _, t := range seeds {
|
||||||
_ = s.DB.AddWatch(user.UserKey, t)
|
_ = s.DB.AddWatch(user.UserKey, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,6 +137,120 @@ func (s *Server) sessionUser(r *http.Request) (*store.User, bool) {
|
|||||||
return s.DB.SessionUser(c.Value)
|
return s.DB.SessionUser(c.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireLogin is chi middleware: 401 unless a valid session cookie is
|
||||||
|
// present. No X-User-Key/demo fallback — fitur dan filter milik user yg
|
||||||
|
// login. Dashboard (flow/*, briefing) tetap publik di luar grup ini.
|
||||||
|
func (s *Server) requireLogin(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := s.sessionUser(r); !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "login dulu untuk pakai fitur ini")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// localCreds decodes {username, password} with shared validation.
|
||||||
|
func localCreds(r *http.Request) (string, string, bool) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimSpace(req.Username)), req.Password, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
// googleTokenResp is the subset of oauth2.googleapis.com/token we need.
|
||||||
type googleTokenResp struct {
|
type googleTokenResp struct {
|
||||||
IDToken string `json:"id_token"`
|
IDToken string `json:"id_token"`
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
wl, _ := s.DB.Watchlist(s.userKey(r))
|
wl, _ := s.DB.Watchlist(s.userKey(r))
|
||||||
if len(wl) == 0 {
|
if len(wl) == 0 {
|
||||||
|
if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
|
||||||
|
wl = all
|
||||||
|
} else {
|
||||||
wl = s.Cfg.Watchlist
|
wl = s.Cfg.Watchlist
|
||||||
}
|
}
|
||||||
|
}
|
||||||
type accRow struct {
|
type accRow struct {
|
||||||
Ticker string `json:"ticker"`
|
Ticker string `json:"ticker"`
|
||||||
NetSum float64 `json:"net_sum"`
|
NetSum float64 `json:"net_sum"`
|
||||||
|
|||||||
@@ -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,8 +15,12 @@ import (
|
|||||||
func (s *Server) PortfolioRisk(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) PortfolioRisk(w http.ResponseWriter, r *http.Request) {
|
||||||
wl, _ := s.DB.Watchlist(s.userKey(r))
|
wl, _ := s.DB.Watchlist(s.userKey(r))
|
||||||
if len(wl) == 0 {
|
if len(wl) == 0 {
|
||||||
|
if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
|
||||||
|
wl = all
|
||||||
|
} else {
|
||||||
wl = s.Cfg.Watchlist
|
wl = s.Cfg.Watchlist
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Concentration: weight by latest close x assumed equal shares (seed-safe).
|
// Concentration: weight by latest close x assumed equal shares (seed-safe).
|
||||||
type bar struct {
|
type bar struct {
|
||||||
Ticker string `json:"ticker"`
|
Ticker string `json:"ticker"`
|
||||||
|
|||||||
@@ -55,9 +55,13 @@ func (s *Server) Screen(w http.ResponseWriter, r *http.Request) {
|
|||||||
if len(universe) == 0 {
|
if len(universe) == 0 {
|
||||||
universe, _ = s.DB.Watchlist(s.userKey(r))
|
universe, _ = s.DB.Watchlist(s.userKey(r))
|
||||||
if len(universe) == 0 {
|
if len(universe) == 0 {
|
||||||
|
if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
|
||||||
|
universe = all
|
||||||
|
} else {
|
||||||
universe = s.Cfg.Watchlist
|
universe = s.Cfg.Watchlist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
var rows []ScreenRow
|
var rows []ScreenRow
|
||||||
for _, tk := range universe {
|
for _, tk := range universe {
|
||||||
row := s.scoreTicker(tk)
|
row := s.scoreTicker(tk)
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ type Server struct {
|
|||||||
LLM *llm.Client
|
LLM *llm.Client
|
||||||
Validate *validator.Validate
|
Validate *validator.Validate
|
||||||
Hub *Hub
|
Hub *Hub
|
||||||
|
// Commit + StartedAt power /api/version (CI anti-stale proof).
|
||||||
|
Commit string
|
||||||
|
StartedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds a Server with all dependencies wired.
|
// 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,
|
Cfg: cfg, DB: db, Sectors: s, Sched: sched, LLM: llmc,
|
||||||
Validate: validator.New(),
|
Validate: validator.New(),
|
||||||
Hub: NewHub(),
|
Hub: NewHub(),
|
||||||
|
Commit: readCommit(),
|
||||||
|
StartedAt: time.Now(),
|
||||||
}
|
}
|
||||||
srv.Engine = &routines.Engine{DB: db, Notifier: sched.Notifier, UserKey: cfg.DemoUserKey,
|
srv.Engine = &routines.Engine{DB: db, Notifier: sched.Notifier, UserKey: cfg.DemoUserKey,
|
||||||
Publish: srv.Hub.Publish}
|
Publish: srv.Hub.Publish}
|
||||||
@@ -66,17 +71,24 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Get("/auth/callback", s.AuthCallback)
|
r.Get("/auth/callback", s.AuthCallback)
|
||||||
r.Get("/auth/me", s.AuthMe)
|
r.Get("/auth/me", s.AuthMe)
|
||||||
r.Post("/auth/logout", s.AuthLogout)
|
r.Post("/auth/logout", s.AuthLogout)
|
||||||
|
r.Post("/auth/signup", s.AuthSignup)
|
||||||
|
r.Post("/auth/login", s.AuthLogin)
|
||||||
|
r.Get("/version", s.Version)
|
||||||
r.Get("/stream", s.Stream)
|
r.Get("/stream", s.Stream)
|
||||||
|
// Publik baca: dashboard bisa dibuka tanpa login.
|
||||||
r.Get("/flow/summary", s.FlowSummary)
|
r.Get("/flow/summary", s.FlowSummary)
|
||||||
r.Get("/flow/broker", s.FlowBroker)
|
r.Get("/flow/broker", s.FlowBroker)
|
||||||
r.Get("/flow/foreign", s.FlowForeign)
|
r.Get("/flow/foreign", s.FlowForeign)
|
||||||
|
r.Get("/briefing/today", s.BriefingToday)
|
||||||
|
// 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.Post("/screen", s.Screen)
|
||||||
r.Get("/routines", s.ListRoutines)
|
r.Get("/routines", s.ListRoutines)
|
||||||
r.Post("/routines", s.CreateRoutine)
|
r.Post("/routines", s.CreateRoutine)
|
||||||
r.Patch("/routines/{id}", s.UpdateRoutine)
|
r.Patch("/routines/{id}", s.UpdateRoutine)
|
||||||
r.Delete("/routines/{id}", s.DeleteRoutine)
|
r.Delete("/routines/{id}", s.DeleteRoutine)
|
||||||
r.Get("/routine-runs", s.RunHistory)
|
r.Get("/routine-runs", s.RunHistory)
|
||||||
r.Get("/briefing/today", s.BriefingToday)
|
|
||||||
r.Get("/alerts", s.ListAlerts)
|
r.Get("/alerts", s.ListAlerts)
|
||||||
r.Post("/alerts", s.CreateAlert)
|
r.Post("/alerts", s.CreateAlert)
|
||||||
r.Delete("/alerts/{id}", s.DeleteAlert)
|
r.Delete("/alerts/{id}", s.DeleteAlert)
|
||||||
@@ -94,6 +106,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Get("/accuracy", s.Accuracy)
|
r.Get("/accuracy", s.Accuracy)
|
||||||
r.Post("/chat", s.Chat)
|
r.Post("/chat", s.Chat)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
if s.Cfg.StaticDir != "" {
|
if s.Cfg.StaticDir != "" {
|
||||||
r.NotFound(s.spaHandler())
|
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)
|
return s.watchlistFor(s.Cfg.DemoUserKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// watchlistFor resolves one owner's watchlist (demo seed fallback kept).
|
// watchlistFor resolves one owner's watchlist: personal first, then the
|
||||||
|
// "semua" default (every ticker with stored data), then the config list.
|
||||||
func (s *Scheduler) watchlistFor(owner string) []string {
|
func (s *Scheduler) watchlistFor(owner string) []string {
|
||||||
wl, err := s.DB.Watchlist(owner)
|
wl, err := s.DB.Watchlist(owner)
|
||||||
if err == nil && len(wl) > 0 {
|
if err == nil && len(wl) > 0 {
|
||||||
return wl
|
return wl
|
||||||
}
|
}
|
||||||
|
if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
|
||||||
|
return all
|
||||||
|
}
|
||||||
if owner == s.Cfg.DemoUserKey && len(s.Cfg.Watchlist) > 0 {
|
if owner == s.Cfg.DemoUserKey && len(s.Cfg.Watchlist) > 0 {
|
||||||
return s.Cfg.Watchlist
|
return s.Cfg.Watchlist
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,24 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// User is one Google-authenticated account. UserKey (`u:<google_sub>`) is
|
// User is one Google-authenticated account. UserKey (`u:<google_sub>`) is
|
||||||
// the scoping key used by every user-owned table.
|
// the scoping key used by every user-owned table. Local accounts
|
||||||
|
// (username+password signup) store google_sub='local:<username>' with
|
||||||
|
// username+password_hash set, keeping session scoping unchanged.
|
||||||
|
type authErr string
|
||||||
|
|
||||||
|
func (e authErr) Error() string { return string(e) }
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrBadLogin authErr = "username atau password salah"
|
||||||
|
ErrBadUsername authErr = "username 3-32 karakter: huruf, angka, titik, _ -"
|
||||||
|
ErrBadPassword authErr = "password minimal 8 karakter"
|
||||||
|
ErrTaken authErr = "username sudah dipakai"
|
||||||
|
)
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64
|
ID int64
|
||||||
GoogleSub string
|
GoogleSub string
|
||||||
@@ -108,3 +122,66 @@ func (db *DB) DeleteSession(token string) error {
|
|||||||
_, err := db.Exec(`DELETE FROM sessions WHERE token=?`, token)
|
_, err := db.Exec(`DELETE FROM sessions WHERE token=?`, token)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// localSub maps a username to its google_sub value for local accounts.
|
||||||
|
func localSub(username string) string { return "local:" + strings.ToLower(username) }
|
||||||
|
|
||||||
|
// CreateLocalUser registers a username+password account (bcrypt cost 10).
|
||||||
|
// Username: 3-32 chars [a-z0-9._-]; password: min 8 chars.
|
||||||
|
func (db *DB) CreateLocalUser(username, password string) (*User, error) {
|
||||||
|
username = strings.ToLower(strings.TrimSpace(username))
|
||||||
|
if !validUsername(username) {
|
||||||
|
return nil, ErrBadUsername
|
||||||
|
}
|
||||||
|
if len(password) < 8 {
|
||||||
|
return nil, ErrBadPassword
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sub := localSub(username)
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
if _, err := db.Exec(`INSERT INTO users(google_sub,email,name,username,password_hash,created_at)
|
||||||
|
VALUES(?,?,?,?,?,?)`, sub, username, username, username, string(hash), now); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
|
return nil, ErrTaken
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db.UserBySub(sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckLocalUser verifies username+password, returning the user on success.
|
||||||
|
func (db *DB) CheckLocalUser(username, password string) (*User, error) {
|
||||||
|
username = strings.ToLower(strings.TrimSpace(username))
|
||||||
|
var u User
|
||||||
|
var hash string
|
||||||
|
if err := db.QueryRow(`SELECT id,google_sub,email,name,avatar_url,created_at,password_hash
|
||||||
|
FROM users WHERE username=?`, username).
|
||||||
|
Scan(&u.ID, &u.GoogleSub, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt, &hash); err != nil {
|
||||||
|
return nil, ErrBadLogin
|
||||||
|
}
|
||||||
|
if hash == "" {
|
||||||
|
return nil, ErrBadLogin // Google-only account, no local password
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||||
|
return nil, ErrBadLogin
|
||||||
|
}
|
||||||
|
u.UserKey = UserKeyForSub(u.GoogleSub)
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validUsername allows 3-32 chars of lowercase letters, digits, . _ -.
|
||||||
|
func validUsername(u string) bool {
|
||||||
|
if len(u) < 3 || len(u) > 32 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range u {
|
||||||
|
if c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- FlowSight schema v7: local username+password login (Google stays optional).
|
||||||
|
-- Local accounts store google_sub='local:<username>' so session scoping
|
||||||
|
-- (sessions join + UserKeyForSub) works unchanged.
|
||||||
|
ALTER TABLE users ADD COLUMN username TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
@@ -540,6 +540,28 @@ func (db *DB) Watchlist(userKey string) ([]string, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AllTickers returns every ticker that has snapshot data, excluding pseudo
|
||||||
|
// tickers (IDX market-wide rows, ROE registry rows). This is the "semua"
|
||||||
|
// default universe: dashboard, screener fallback, and scheduler depth all use
|
||||||
|
// it when a user has no personal watchlist.
|
||||||
|
func (db *DB) AllTickers() ([]string, error) {
|
||||||
|
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).
|
// AddWatch inserts a ticker (idempotent).
|
||||||
func (db *DB) AddWatch(userKey, ticker string) error {
|
func (db *DB) AddWatch(userKey, ticker string) error {
|
||||||
_, err := db.Exec(`INSERT INTO watchlists(user_key,ticker,added_at) VALUES(?,?,?)
|
_, err := db.Exec(`INSERT INTO watchlists(user_key,ticker,added_at) VALUES(?,?,?)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createResource, createSignal, Show } from "solid-js";
|
|||||||
import { api, type AuthUser } from "../lib/api";
|
import { api, type AuthUser } from "../lib/api";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
|
||||||
|
|
||||||
// Theme: .dark class on <html> (shadcn convention). Default dark.
|
// Theme: .dark class on <html> (shadcn convention). Default dark.
|
||||||
function theme(): string {
|
function theme(): string {
|
||||||
@@ -30,7 +31,30 @@ export function useAuth() {
|
|||||||
const [me, { refetch }] = createResource(async (): Promise<AuthUser | null> => {
|
const [me, { refetch }] = createResource(async (): Promise<AuthUser | null> => {
|
||||||
try { return (await api.me()).user; } catch { return null; }
|
try { return (await api.me()).user; } catch { return null; }
|
||||||
});
|
});
|
||||||
return { me, refetch };
|
const [version] = createResource(async () => {
|
||||||
|
try { return await api.version(); } catch { return null; }
|
||||||
|
});
|
||||||
|
return { me, refetch, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ButuhLogin: kartu ajakan login untuk halaman fitur yg di-hide bila logout.
|
||||||
|
export function ButuhLogin(props: { fitur: string }) {
|
||||||
|
return (
|
||||||
|
<div class="mx-auto max-w-md py-[6vh]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="text-center">
|
||||||
|
<CardTitle class="text-2xl">🔒 {props.fitur} perlu login</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Biar datanya milik kamu sendiri (watchlist, notifikasi, report) —
|
||||||
|
daftar gratis, cukup username + password.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="text-center">
|
||||||
|
<a href="/login"><Button size="lg">Masuk / Daftar</Button></a>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthButton(props: { me: AuthUser | null | undefined; onLogout: () => void }) {
|
export function AuthButton(props: { me: AuthUser | null | undefined; onLogout: () => void }) {
|
||||||
|
|||||||
+74
-20
@@ -1,8 +1,8 @@
|
|||||||
import { render } from "solid-js/web";
|
import { render } from "solid-js/web";
|
||||||
import { Router, Route, useLocation, useNavigate, useParams, type RouteSectionProps } from "@solidjs/router";
|
import { Router, Route, useLocation, useNavigate, type RouteSectionProps } from "@solidjs/router";
|
||||||
import { createSignal, Show } from "solid-js";
|
import { createResource, createSignal, Show } from "solid-js";
|
||||||
import { WatchlistDrawer, ChatSidebar } from "./components/WatchlistChat";
|
import { WatchlistDrawer, ChatSidebar } from "./components/WatchlistChat";
|
||||||
import { ThemeToggle, useAuth, AuthButton } from "./components/auth";
|
import { ThemeToggle, useAuth, AuthButton, ButuhLogin } from "./components/auth";
|
||||||
import { Button } from "./components/ui/button";
|
import { Button } from "./components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./components/ui/card";
|
||||||
import { TextField, TextFieldInput } from "./components/ui/text-field";
|
import { TextField, TextFieldInput } from "./components/ui/text-field";
|
||||||
@@ -15,7 +15,7 @@ import Report from "./pages/Report";
|
|||||||
import Portfolio from "./pages/Portfolio";
|
import Portfolio from "./pages/Portfolio";
|
||||||
import "./styles/globals.css";
|
import "./styles/globals.css";
|
||||||
|
|
||||||
const NAV = [
|
const NAV_ALL = [
|
||||||
{ href: "/", icon: "📊", label: "Dashboard" },
|
{ href: "/", icon: "📊", label: "Dashboard" },
|
||||||
{ href: "/routines", icon: "🗓️", label: "Routines" },
|
{ href: "/routines", icon: "🗓️", label: "Routines" },
|
||||||
{ href: "/screener", icon: "🔍", label: "Screener" },
|
{ href: "/screener", icon: "🔍", label: "Screener" },
|
||||||
@@ -23,14 +23,16 @@ const NAV = [
|
|||||||
{ href: "/portfolio", icon: "💼", label: "Portfolio" },
|
{ href: "/portfolio", icon: "💼", label: "Portfolio" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function Nav() {
|
function Nav(props: { loggedIn: boolean }) {
|
||||||
const loc = useLocation();
|
const loc = useLocation();
|
||||||
|
// Belum login: hanya Dashboard yg terlihat (fitur lain di-hide).
|
||||||
|
const items = () => props.loggedIn ? NAV_ALL : NAV_ALL.slice(0, 1);
|
||||||
return (
|
return (
|
||||||
<nav class="flex w-56 shrink-0 flex-col gap-1 border-r bg-card p-4 max-lg:hidden">
|
<nav class="flex w-56 shrink-0 flex-col gap-1 border-r bg-card p-4 max-lg:hidden">
|
||||||
<div class="mb-4 px-2 text-xl font-bold tracking-tight">
|
<div class="mb-4 px-2 text-xl font-bold tracking-tight">
|
||||||
Flow<span class="text-primary">Sight</span>
|
Flow<span class="text-primary">Sight</span>
|
||||||
</div>
|
</div>
|
||||||
{NAV.map((n) => (
|
{items().map((n) => (
|
||||||
<a
|
<a
|
||||||
href={n.href}
|
href={n.href}
|
||||||
class={`flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground ${loc.pathname === n.href ? "bg-accent text-accent-foreground" : "text-muted-foreground"}`}
|
class={`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 loc = useLocation();
|
||||||
|
const items = () => props.loggedIn ? NAV_ALL : NAV_ALL.slice(0, 1);
|
||||||
return (
|
return (
|
||||||
<nav class="fixed inset-x-0 bottom-0 z-40 flex border-t bg-card px-2 py-1 lg:hidden">
|
<nav class="fixed inset-x-0 bottom-0 z-40 flex border-t bg-card px-2 py-1 lg:hidden">
|
||||||
{NAV.map((n) => (
|
{items().map((n) => (
|
||||||
<a
|
<a
|
||||||
href={n.href}
|
href={n.href}
|
||||||
class={`flex flex-1 flex-col items-center rounded-md py-1.5 text-[11px] font-medium ${loc.pathname === n.href ? "text-primary" : "text-muted-foreground"}`}
|
class={`flex flex-1 flex-col items-center rounded-md py-1.5 text-[11px] font-medium ${loc.pathname === n.href ? "text-primary" : "text-muted-foreground"}`}
|
||||||
@@ -58,12 +61,13 @@ function MobileNav() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TickerSearch() {
|
function TickerSearch(props: { loggedIn: boolean }) {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const [q, setQ] = createSignal("");
|
const [q, setQ] = createSignal("");
|
||||||
const go = () => {
|
const go = () => {
|
||||||
const t = q().toUpperCase().trim();
|
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 (
|
return (
|
||||||
<form class="flex max-w-sm flex-1 items-center gap-2" onSubmit={(e) => { e.preventDefault(); go(); }}>
|
<form class="flex max-w-sm flex-1 items-center gap-2" onSubmit={(e) => { e.preventDefault(); go(); }}>
|
||||||
@@ -77,19 +81,51 @@ function TickerSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const params = useParams<{ error?: string }>();
|
|
||||||
const err = () => new URLSearchParams(window.location.search).get("error");
|
const err = () => new URLSearchParams(window.location.search).get("error");
|
||||||
void params;
|
const [mode, setMode] = createSignal<"masuk" | "daftar">("masuk");
|
||||||
|
const [username, setUsername] = createSignal("");
|
||||||
|
const [password, setPassword] = createSignal("");
|
||||||
|
const [busy, setBusy] = createSignal(false);
|
||||||
|
const [formErr, setFormErr] = createSignal("");
|
||||||
|
const [version] = createResource(() => api.version().catch(() => null));
|
||||||
|
async function submit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormErr(""); setBusy(true);
|
||||||
|
try {
|
||||||
|
if (mode() === "daftar") await api.signup(username().trim(), password());
|
||||||
|
else await api.login(username().trim(), password());
|
||||||
|
window.location.href = "/";
|
||||||
|
} catch (e2) { setFormErr(String(e2)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div class="mx-auto max-w-md py-[8vh]">
|
<div class="mx-auto max-w-md py-[8vh]">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="text-center">
|
<CardHeader class="text-center">
|
||||||
<CardTitle class="text-2xl">Welcome to FlowSight</CardTitle>
|
<CardTitle class="text-2xl">Masuk ke FlowSight 👋</CardTitle>
|
||||||
<CardDescription>Sign in with Google to get your own watchlist, routines, alerts and reports.</CardDescription>
|
<CardDescription>
|
||||||
|
Daftar gratis pakai username + password — watchlist, notifikasi,
|
||||||
|
dan report jadi milik kamu sendiri.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="text-center">
|
<CardContent class="space-y-3">
|
||||||
<Show when={err()}><p class="mb-3 text-sm text-destructive">Login failed: {err()}</p></Show>
|
<Show when={err()}><p class="text-sm text-destructive">Login gagal: {err()}</p></Show>
|
||||||
<a href="/api/auth/start"><Button size="lg">Sign in with Google</Button></a>
|
<div class="flex gap-1 rounded-md bg-muted p-1">
|
||||||
|
<Button size="sm" variant={mode() === "masuk" ? "default" : "ghost"} class="flex-1" onClick={() => setMode("masuk")}>Masuk</Button>
|
||||||
|
<Button size="sm" variant={mode() === "daftar" ? "default" : "ghost"} class="flex-1" onClick={() => setMode("daftar")}>Daftar baru</Button>
|
||||||
|
</div>
|
||||||
|
<form class="space-y-3" onSubmit={submit}>
|
||||||
|
<TextField><TextFieldInput placeholder="username (mis. budi)" value={username()} onInput={(e) => setUsername(e.currentTarget.value)} autocomplete="username" /></TextField>
|
||||||
|
<TextField><TextFieldInput type="password" placeholder="password (min. 8 karakter)" value={password()} onInput={(e) => setPassword(e.currentTarget.value)} autocomplete={mode() === "daftar" ? "new-password" : "current-password"} /></TextField>
|
||||||
|
<Show when={formErr()}><p class="text-sm text-destructive">{formErr()}</p></Show>
|
||||||
|
<Button type="submit" size="lg" class="w-full" disabled={busy()}>{busy() ? "…" : mode() === "daftar" ? "Daftar & Masuk" : "Masuk"}</Button>
|
||||||
|
</form>
|
||||||
|
<Show when={version()?.google_configured}>
|
||||||
|
<div class="pt-1 text-center">
|
||||||
|
<p class="mb-2 text-xs text-muted-foreground">atau</p>
|
||||||
|
<a href="/api/auth/start"><Button size="lg" variant="outline" class="w-full">Sign in with Google</Button></a>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,10 +137,10 @@ function Shell(props: RouteSectionProps) {
|
|||||||
const logout = async () => { await api.logout(); refetch(); };
|
const logout = async () => { await api.logout(); refetch(); };
|
||||||
return (
|
return (
|
||||||
<div class="flex min-h-screen bg-background text-foreground">
|
<div class="flex min-h-screen bg-background text-foreground">
|
||||||
<Nav />
|
<Nav loggedIn={!!me()} />
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
<header class="sticky top-0 z-30 flex items-center gap-3 border-b bg-background/95 px-4 py-2.5 backdrop-blur">
|
<header class="sticky top-0 z-30 flex items-center gap-3 border-b bg-background/95 px-4 py-2.5 backdrop-blur">
|
||||||
<TickerSearch />
|
<TickerSearch loggedIn={!!me()} />
|
||||||
<span class="flex-1" />
|
<span class="flex-1" />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<AuthButton me={me()} onLogout={logout} />
|
<AuthButton me={me()} onLogout={logout} />
|
||||||
@@ -112,14 +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>
|
<main class="mx-auto w-full max-w-6xl flex-1 space-y-6 p-4 pb-20 lg:p-6">{props.children}</main>
|
||||||
</div>
|
</div>
|
||||||
<aside class="hidden w-80 shrink-0 flex-col gap-4 border-l bg-card p-4 xl:flex">
|
<aside class="hidden w-80 shrink-0 flex-col gap-4 border-l bg-card p-4 xl:flex">
|
||||||
|
<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 />
|
<WatchlistDrawer />
|
||||||
<ChatSidebar />
|
<ChatSidebar />
|
||||||
|
</Show>
|
||||||
</aside>
|
</aside>
|
||||||
<MobileNav />
|
<MobileNav loggedIn={!!me()} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gate: bungkus halaman fitur — belum login tampilkan ButuhLogin.
|
||||||
|
export function Gate(props: { fitur: string; children: import("solid-js").JSX.Element }) {
|
||||||
|
const { me } = useAuth();
|
||||||
|
return (
|
||||||
|
<Show when={me() !== 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(
|
render(
|
||||||
() => (
|
() => (
|
||||||
<Router root={Shell}>
|
<Router root={Shell}>
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export const api = {
|
|||||||
chat: (message: string) => req<{ answer: string }>(`/api/chat`, { method: "POST", body: JSON.stringify({ message }) }),
|
chat: (message: string) => req<{ answer: string }>(`/api/chat`, { method: "POST", body: JSON.stringify({ message }) }),
|
||||||
me: () => req<{ user: AuthUser }>(`/api/auth/me`),
|
me: () => req<{ user: AuthUser }>(`/api/auth/me`),
|
||||||
logout: () => req<{ ok: boolean }>(`/api/auth/logout`, { method: "POST" }),
|
logout: () => req<{ ok: boolean }>(`/api/auth/logout`, { method: "POST" }),
|
||||||
|
signup: (username: string, password: string) => req<{ user: AuthUser }>(`/api/auth/signup`, { method: "POST", body: JSON.stringify({ username, password }) }),
|
||||||
|
login: (username: string, password: string) => req<{ user: AuthUser }>(`/api/auth/login`, { method: "POST", body: JSON.stringify({ username, password }) }),
|
||||||
|
version: () => req<{ commit: string; started_at: string; google_configured: boolean }>(`/api/version`),
|
||||||
};
|
};
|
||||||
// SSE hook helper: subscribe to channels agents|alerts|activity.
|
// SSE hook helper: subscribe to channels agents|alerts|activity.
|
||||||
export function subscribeSSE(onEvent: (channel: string, data: string) => void): () => void {
|
export function subscribeSSE(onEvent: (channel: string, data: string) => void): () => void {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const TEMPLATES: { label: string; desc: string; rule: Record<string, unknown> }[
|
|||||||
{ label: "📢 Semua gerakan mencolok", desc: "Skor gabungan di atas ambang", rule: { all: [{ field: "score", op: ">=", value: 60 }] } },
|
{ label: "📢 Semua gerakan mencolok", desc: "Skor gabungan di atas ambang", rule: { all: [{ field: "score", op: ">=", value: 60 }] } },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Alerts() {
|
function AlertsInner() {
|
||||||
const [alerts, { refetch }] = createResource(() => api.alerts());
|
const [alerts, { refetch }] = createResource(() => api.alerts());
|
||||||
const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 30)));
|
const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 30)));
|
||||||
const [dests, { refetch: refetchDests }] = createResource(() => api.destinations());
|
const [dests, { refetch: refetchDests }] = createResource(() => api.destinations());
|
||||||
@@ -127,3 +127,9 @@ export default function Alerts() {
|
|||||||
</div>
|
</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";
|
import { Skeleton } from "../components/ui/skeleton";
|
||||||
Chart.register(...registerables);
|
Chart.register(...registerables);
|
||||||
|
|
||||||
export default function Portfolio() {
|
function PortfolioInner() {
|
||||||
const [risk] = createResource(() => api.risk());
|
const [risk] = createResource(() => api.risk());
|
||||||
const conc = () => risk()?.concentration || [];
|
const conc = () => risk()?.concentration || [];
|
||||||
const betaN = () => Number(risk()?.beta ?? 1);
|
const betaN = () => Number(risk()?.beta ?? 1);
|
||||||
@@ -66,3 +66,9 @@ export default function Portfolio() {
|
|||||||
</div>
|
</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 { Skeleton } from "../components/ui/skeleton";
|
||||||
import { TextField, TextFieldInput } from "../components/ui/text-field";
|
import { TextField, TextFieldInput } from "../components/ui/text-field";
|
||||||
|
|
||||||
export default function Report() {
|
function ReportInner() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [rep, setRep] = createSignal<ReportPayload | null>(null);
|
const [rep, setRep] = createSignal<ReportPayload | null>(null);
|
||||||
const [md, setMd] = createSignal("");
|
const [md, setMd] = createSignal("");
|
||||||
@@ -103,3 +103,9 @@ export default function Report() {
|
|||||||
</div>
|
</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." },
|
"weekend-review": { judul: "📝 Review mingguan", desc: "Ringkasan seminggu: apa yang terjadi dan pelajaran." },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Routines() {
|
function RoutinesInner() {
|
||||||
const [data, { refetch }] = createResource(() => api.routines());
|
const [data, { refetch }] = createResource(() => api.routines());
|
||||||
const [runs, { refetch: refetchRuns }] = createResource(() => api.runs().then((r) => r.runs.slice(0, 20)));
|
const [runs, { refetch: refetchRuns }] = createResource(() => api.runs().then((r) => r.runs.slice(0, 20)));
|
||||||
const [type_, setType] = createSignal("morning-briefing");
|
const [type_, setType] = createSignal("morning-briefing");
|
||||||
@@ -97,3 +97,9 @@ export default function Routines() {
|
|||||||
</div>
|
</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 { Skeleton } from "../components/ui/skeleton";
|
||||||
import { useNavigate } from "@solidjs/router";
|
import { useNavigate } from "@solidjs/router";
|
||||||
|
|
||||||
// Preset awam -> body /api/screen.
|
// Preset awam -> body /api/screen. PRESET_SEMUA = default auto-jalan.
|
||||||
|
export const PRESET_SEMUA = { label: "📦 Semua — urut paling menarik", desc: "Tanpa filter, ranking gabungan", body: { limit: 20 } };
|
||||||
const PRESETS: { label: string; desc: string; body: Record<string, unknown> }[] = [
|
const PRESETS: { label: string; desc: string; body: Record<string, unknown> }[] = [
|
||||||
{ label: "🔥 Yang lagi diborong bandar", desc: "Broker besar net-beli besar", body: { institutional: { broker_score_min: 5 }, limit: 20 } },
|
{ label: "🔥 Yang lagi diborong bandar", desc: "Broker besar net-beli besar", body: { institutional: { broker_score_min: 5 }, limit: 20 } },
|
||||||
{ label: "🌍 Yang asing lagi beli", desc: "Uang luar negeri masuk", body: { institutional: { foreign_inflow: true }, limit: 20 } },
|
{ label: "🌍 Yang asing lagi beli", desc: "Uang luar negeri masuk", body: { institutional: { foreign_inflow: true }, limit: 20 } },
|
||||||
{ label: "🕵️ Yang orang dalamnya ikut beli", desc: "Insider buying terdeteksi", body: { institutional: { insider_buying: true }, limit: 20 } },
|
{ label: "🕵️ Yang orang dalamnya ikut beli", desc: "Insider buying terdeteksi", body: { institutional: { insider_buying: true }, limit: 20 } },
|
||||||
{ label: "📦 Semua — urut paling menarik", desc: "Tanpa filter, ranking gabungan", body: { limit: 20 } },
|
PRESET_SEMUA,
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Screener() {
|
function ScreenerInner() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [rows, setRows] = createSignal<ScreenRow[]>([]);
|
const [rows, setRows] = createSignal<ScreenRow[]>([]);
|
||||||
const [ran, setRan] = createSignal(false);
|
const [ran, setRan] = createSignal(false);
|
||||||
@@ -31,6 +32,8 @@ export default function Screener() {
|
|||||||
} catch (e) { setErr(String(e)); }
|
} catch (e) { setErr(String(e)); }
|
||||||
finally { setBusy(false); }
|
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) }));
|
const hasil = () => rows().map((r) => ({ row: r, ...verdictFor(r) }));
|
||||||
return (
|
return (
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -77,3 +80,9 @@ export default function Screener() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { Gate } from "../index";
|
||||||
|
|
||||||
|
export default function Screener() {
|
||||||
|
return <Gate fitur="Cari saham">{ScreenerInner()}</Gate>;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user