feat: default watchlist semua, login-gate fitur, CI anti-stale via /api/version

This commit is contained in:
asepharyana
2026-09-15 22:12:09 +07:00
parent 5c953289b1
commit 79f42dfefa
22 changed files with 602 additions and 84 deletions
+1 -1
View File
@@ -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 && \
+7 -2
View File
@@ -61,9 +61,14 @@ func main() {
}
}
}
// Ensure the demo watchlist exists even without a seed bundle.
// Ensure the demo account covers the "semua" default: every ticker with
// stored data (fallback: config watchlist).
if wl, _ := db.Watchlist(cfg.DemoUserKey); len(wl) == 0 {
for _, t := range cfg.Watchlist {
seeds, _ := db.AllTickers()
if len(seeds) == 0 {
seeds = cfg.Watchlist
}
for _, t := range seeds {
_ = db.AddWatch(cfg.DemoUserKey, t)
}
}
+24 -22
View File
@@ -65,7 +65,8 @@ func TestBriefing(t *testing.T) {
rec := do(s, "GET", "/api/briefing/today", nil)
if rec.Code == http.StatusNotFound {
// No briefing yet: run the routine via engine path instead.
rows, _ := s.DB.ListRoutines("demo")
u, _ := s.DB.CheckLocalUser("tester", "password1234")
rows, _ := s.DB.ListRoutines(u.UserKey)
if len(rows) == 0 {
t.Fatal("seed has no routines")
}
@@ -82,7 +83,7 @@ func TestBriefing(t *testing.T) {
// Screener returns a ranked list with per-row breakdown.
func TestScreen(t *testing.T) {
s := testServer(t)
rec := do(s, "POST", "/api/screen", map[string]any{"limit": 5})
rec := doAuth(t, s, "POST", "/api/screen", map[string]any{"limit": 5})
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
@@ -101,7 +102,7 @@ func TestScreen(t *testing.T) {
// Report: all 7 sections populated with citations.
func TestReport(t *testing.T) {
s := testServer(t)
rec := do(s, "POST", "/api/report/BBCA?profile=moderate", nil)
rec := doAuth(t, s, "POST", "/api/report/BBCA?profile=moderate", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:300])
}
@@ -122,20 +123,21 @@ func TestReport(t *testing.T) {
// Subscribe -> run -> history row appears.
func TestRoutineSubscribeRunHistory(t *testing.T) {
s := testServer(t)
rec := do(s, "POST", "/api/routines", map[string]any{"type": "foreign-reversal"})
rec := doAuth(t, s, "POST", "/api/routines", map[string]any{"type": "foreign-reversal"})
if rec.Code != http.StatusCreated {
t.Fatalf("code = %d", rec.Code)
}
var created map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &created)
rows, _ := s.DB.ListRoutines("demo")
u, _ := s.DB.CheckLocalUser("tester", "password1234")
rows, _ := s.DB.ListRoutines(u.UserKey)
if len(rows) == 0 {
t.Fatal("no routines")
}
if _, err := s.Engine.Run(httptest.NewRequest("GET", "/", nil).Context(), rows[0]); err != nil {
t.Fatal(err)
}
rec = do(s, "GET", "/api/routine-runs?limit=5", nil)
rec = doAuth(t, s, "GET", "/api/routine-runs?limit=5", nil)
var out struct {
Runs []map[string]any `json:"runs"`
}
@@ -149,11 +151,11 @@ func TestRoutineSubscribeRunHistory(t *testing.T) {
// from the persisted report, unknown report 404s.
func TestInterrogate(t *testing.T) {
s := testServer(t)
rec := do(s, "POST", "/api/report/BBCA?profile=moderate", nil)
rec := doAuth(t, s, "POST", "/api/report/BBCA?profile=moderate", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
rec = do(s, "POST", "/api/report/BBCA/ask", map[string]any{"question": "kenapa conviction segitu?"})
rec = doAuth(t, s, "POST", "/api/report/BBCA/ask", map[string]any{"question": "kenapa conviction segitu?"})
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:200])
}
@@ -164,7 +166,7 @@ func TestInterrogate(t *testing.T) {
if out.Answer == "" {
t.Fatal("empty interrogation answer")
}
rec = do(s, "POST", "/api/report/ZZZZ/ask", map[string]any{"question": "apa?"})
rec = doAuth(t, s, "POST", "/api/report/ZZZZ/ask", map[string]any{"question": "apa?"})
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404 for unknown ticker", rec.Code)
}
@@ -173,11 +175,11 @@ func TestInterrogate(t *testing.T) {
// Concentrated fixture warns >40% sector; accuracy math covered.
func TestPortfolioAndAccuracy(t *testing.T) {
s := testServer(t)
rec := do(s, "GET", "/api/portfolio/risk", nil)
rec := doAuth(t, s, "GET", "/api/portfolio/risk", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
rec = do(s, "GET", "/api/accuracy", nil)
rec = doAuth(t, s, "GET", "/api/accuracy", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
@@ -203,19 +205,19 @@ func TestFlowForeignWindow(t *testing.T) {
// Unknown routine types are rejected; missing ids 404.
func TestRoutineValidation(t *testing.T) {
s := testServer(t)
rec := do(s, "POST", "/api/routines", map[string]any{"type": "not-a-routine"})
rec := doAuth(t, s, "POST", "/api/routines", map[string]any{"type": "not-a-routine"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code)
}
rec = do(s, "PATCH", "/api/routines/999999", map[string]any{"enabled": false})
rec = doAuth(t, s, "PATCH", "/api/routines/999999", map[string]any{"enabled": false})
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
rec = do(s, "DELETE", "/api/routines/999999", nil)
rec = doAuth(t, s, "DELETE", "/api/routines/999999", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
rec = do(s, "DELETE", "/api/alerts/999999", nil)
rec = doAuth(t, s, "DELETE", "/api/alerts/999999", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
@@ -225,27 +227,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)
}
+120 -2
View File
@@ -77,9 +77,13 @@ func (s *Server) AuthCallback(w http.ResponseWriter, r *http.Request) {
fail("user store unavailable")
return
}
// Ensure the login user owns the default watchlist on first login.
// Ensure the login user owns the "semua" default watchlist on first login.
if wl, _ := s.DB.Watchlist(user.UserKey); len(wl) == 0 {
for _, t := range s.Cfg.Watchlist {
seeds, _ := s.DB.AllTickers()
if len(seeds) == 0 {
seeds = s.Cfg.Watchlist
}
for _, t := range seeds {
_ = s.DB.AddWatch(user.UserKey, t)
}
}
@@ -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"`
+4
View File
@@ -19,8 +19,12 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
}
wl, _ := s.DB.Watchlist(s.userKey(r))
if len(wl) == 0 {
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"`
NetSum float64 `json:"net_sum"`
+109
View File
@@ -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")
}
}
+4
View File
@@ -15,8 +15,12 @@ import (
func (s *Server) PortfolioRisk(w http.ResponseWriter, r *http.Request) {
wl, _ := s.DB.Watchlist(s.userKey(r))
if len(wl) == 0 {
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 {
Ticker string `json:"ticker"`
+4
View File
@@ -55,9 +55,13 @@ 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 {
if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
universe = all
} else {
universe = s.Cfg.Watchlist
}
}
}
var rows []ScreenRow
for _, tk := range universe {
row := s.scoreTicker(tk)
+14 -1
View File
@@ -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,17 +71,24 @@ 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.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.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)
@@ -94,6 +106,7 @@ func (s *Server) Router() http.Handler {
r.Get("/accuracy", s.Accuracy)
r.Post("/chat", s.Chat)
})
})
if s.Cfg.StaticDir != "" {
r.NotFound(s.spaHandler())
}
+36
View File
@@ -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"
}
+5 -1
View File
@@ -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
}
+78 -1
View File
@@ -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);
+22
View File
@@ -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(?,?,?)
+25 -1
View File
@@ -2,6 +2,7 @@ import { createResource, createSignal, Show } from "solid-js";
import { api, type AuthUser } from "../lib/api";
import { Button } from "./ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
// Theme: .dark class on <html> (shadcn convention). Default dark.
function theme(): string {
@@ -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 }) {
+74 -20
View File
@@ -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">
<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}>
+3
View File
@@ -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 {
+7 -1
View File
@@ -15,7 +15,7 @@ const TEMPLATES: { label: string; desc: string; rule: Record<string, unknown> }[
{ label: "📢 Semua gerakan mencolok", desc: "Skor gabungan di atas ambang", rule: { all: [{ field: "score", op: ">=", value: 60 }] } },
];
export default function Alerts() {
function AlertsInner() {
const [alerts, { refetch }] = createResource(() => api.alerts());
const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 30)));
const [dests, { refetch: refetchDests }] = createResource(() => api.destinations());
@@ -127,3 +127,9 @@ export default function Alerts() {
</div>
);
}
import { Gate } from "../index";
export default function Alerts() {
return <Gate fitur="Notifikasi">{AlertsInner()}</Gate>;
}
+7 -1
View File
@@ -9,7 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../co
import { Skeleton } from "../components/ui/skeleton";
Chart.register(...registerables);
export default function Portfolio() {
function PortfolioInner() {
const [risk] = createResource(() => api.risk());
const conc = () => risk()?.concentration || [];
const betaN = () => Number(risk()?.beta ?? 1);
@@ -66,3 +66,9 @@ export default function Portfolio() {
</div>
);
}
import { Gate } from "../index";
export default function Portfolio() {
return <Gate fitur="Portofolio">{PortfolioInner()}</Gate>;
}
+7 -1
View File
@@ -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>;
}
+7 -1
View File
@@ -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>;
}
+12 -3
View File
@@ -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>;
}