fix: audit round 2 — session cookie Secure kondisional, logout clear cookie, IDOR report owner-scoping (migrasi 0008), health force login-gate, SSE stream login-gate, FE: Gate undefined-flicker, auth-expired global redirect, X-User-Key demo dihapus, error handling alerts/routines/watchlist, chart label, encodeURIComponent, Screener onMount
This commit is contained in:
@@ -66,7 +66,7 @@ func TestBriefing(t *testing.T) {
|
|||||||
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.
|
||||||
u, _ := s.DB.CheckLocalUser("tester", "password1234")
|
u, _ := s.DB.CheckLocalUser("tester", "password1234")
|
||||||
rows, _ := s.DB.ListRoutines(u.UserKey)
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func (s *Server) AuthCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
|
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
|
||||||
Secure: true, SameSite: http.SameSiteLaxMode,
|
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
|
||||||
Expires: time.Now().Add(sessionTTL),
|
Expires: time.Now().Add(sessionTTL),
|
||||||
})
|
})
|
||||||
http.Redirect(w, r, "/", http.StatusFound)
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
@@ -123,6 +123,7 @@ func (s *Server) AuthLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "fs_session", Value: "", Path: "/", HttpOnly: true,
|
Name: "fs_session", Value: "", Path: "/", HttpOnly: true,
|
||||||
|
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
})
|
})
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
@@ -162,8 +163,16 @@ func localCreds(r *http.Request) (string, string, bool) {
|
|||||||
return strings.ToLower(strings.TrimSpace(req.Username)), req.Password, true
|
return strings.ToLower(strings.TrimSpace(req.Username)), req.Password, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isHTTPS checks X-Forwarded-Proto (Caddy) or raw TLS.
|
||||||
|
func isHTTPS(r *http.Request) bool {
|
||||||
|
if strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return r.TLS != nil
|
||||||
|
}
|
||||||
|
|
||||||
// mintSession creates a session + sets the fs_session cookie.
|
// mintSession creates a session + sets the fs_session cookie.
|
||||||
func (s *Server) mintSession(w http.ResponseWriter, user *store.User) bool {
|
func (s *Server) mintSession(w http.ResponseWriter, r *http.Request, user *store.User) bool {
|
||||||
tok, err := s.DB.CreateSession(user.ID, user.UserKey, sessionTTL)
|
tok, err := s.DB.CreateSession(user.ID, user.UserKey, sessionTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusBadGateway, "session store unavailable")
|
writeErr(w, http.StatusBadGateway, "session store unavailable")
|
||||||
@@ -171,7 +180,7 @@ func (s *Server) mintSession(w http.ResponseWriter, user *store.User) bool {
|
|||||||
}
|
}
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
|
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
|
||||||
Secure: true, SameSite: http.SameSiteLaxMode,
|
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
|
||||||
Expires: time.Now().Add(sessionTTL),
|
Expires: time.Now().Add(sessionTTL),
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
@@ -225,7 +234,7 @@ func (s *Server) AuthSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.seedWatchlistSemua(user.UserKey)
|
s.seedWatchlistSemua(user.UserKey)
|
||||||
if !s.mintSession(w, user) {
|
if !s.mintSession(w, r, user) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{"user": s.userJSON(user)})
|
writeJSON(w, http.StatusCreated, map[string]any{"user": s.userJSON(user)})
|
||||||
@@ -245,7 +254,7 @@ func (s *Server) AuthLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.seedWatchlistSemua(user.UserKey)
|
s.seedWatchlistSemua(user.UserKey)
|
||||||
if !s.mintSession(w, user) {
|
if !s.mintSession(w, r, user) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"user": s.userJSON(user)})
|
writeJSON(w, http.StatusOK, map[string]any{"user": s.userJSON(user)})
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ func (s *Server) Chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, http.StatusUnprocessableEntity, "message is required")
|
writeErr(w, http.StatusUnprocessableEntity, "message is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Scope grounding: report citations when scoped.
|
// Scope grounding: report citations when scoped (owner-scoped).
|
||||||
var ground, citesRaw string
|
var ground, citesRaw string
|
||||||
if req.Scope != nil && req.Scope.ReportID > 0 {
|
if req.Scope != nil && req.Scope.ReportID > 0 {
|
||||||
var cites string
|
var cites string
|
||||||
var at string
|
var at string
|
||||||
err := s.DB.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports WHERE id=?`,
|
err := s.DB.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports WHERE id=? AND user_key=?`,
|
||||||
req.Scope.ReportID).Scan(&ground, &cites, &at)
|
req.Scope.ReportID, s.userKey(r)).Scan(&ground, &cites, &at)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusNotFound, "report not found")
|
writeErr(w, http.StatusNotFound, "report not found")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ func (s *Server) FlowForeign(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"ticker": q.Ticker, "dates": dates, "nets": nets, "reversal": reversal,
|
"ticker": q.Ticker, "dates": dates, "nets": nets, "reversal": reversal,
|
||||||
"citations": []model.Citation{model.Cite("v2/foreign-flow/"+q.Ticker+"/", q.Ticker, dates[len(dates)-1])},
|
"citations": []model.Citation{model.Cite("v2/foreign-flow/"+q.Ticker+"/", q.Ticker, dates[len(dates)-1])},
|
||||||
"start": startOf(dates), "end": dates[len(dates)-1],
|
"start": startOf(dates), "end": dates[len(dates)-1],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package api
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"time"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Gated routes 401 without session; public routes stay 200.
|
// Gated routes 401 without session; public routes stay 200.
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Health serves GET /api/health: last cycle time + credits spent today +
|
// Health serves GET /api/health: last cycle time + credits spent today +
|
||||||
// scheduler state + stale flags (docs/API.md).
|
// scheduler state + stale flags (docs/API.md). force=1 runs a probe cycle —
|
||||||
|
// only for logged-in users (anon force would burn Sectors credits = DoS).
|
||||||
func (s *Server) Health(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) Health(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Query().Get("force") == "1" {
|
if r.URL.Query().Get("force") == "1" {
|
||||||
|
if _, ok := s.sessionUser(r); !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "login dulu untuk memaksa siklus")
|
||||||
|
return
|
||||||
|
}
|
||||||
_ = s.Sched.RunCycle(r.Context()) // synchronous probe cycle
|
_ = s.Sched.RunCycle(r.Context()) // synchronous probe cycle
|
||||||
}
|
}
|
||||||
lastCycle, schedOK := s.Sched.Status()
|
lastCycle, schedOK := s.Sched.Status()
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func (s *Server) Interrogate(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, http.StatusUnprocessableEntity, "question is required")
|
writeErr(w, http.StatusUnprocessableEntity, "question is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
payload, citesRaw, at, id, err := s.loadReport(ticker, req.ReportID)
|
payload, citesRaw, at, id, err := s.loadReport(ticker, req.ReportID, s.userKey(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusNotFound, "no report for "+ticker+" yet — POST /api/report/"+ticker+" first")
|
writeErr(w, http.StatusNotFound, "no report for "+ticker+" yet — POST /api/report/"+ticker+" first")
|
||||||
return
|
return
|
||||||
@@ -49,13 +49,13 @@ func (s *Server) Interrogate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadReport fetches (payload, citations, generated_at, id) for an explicit
|
// loadReport fetches (payload, citations, generated_at, id) for an explicit
|
||||||
// report id or the latest report for a ticker.
|
// report id (scoped to the caller's userKey) or the latest report for a ticker.
|
||||||
func (s *Server) loadReport(ticker string, id int64) (string, string, string, int64, error) {
|
func (s *Server) loadReport(ticker string, id int64, userKey string) (string, string, string, int64, error) {
|
||||||
if id > 0 {
|
if id > 0 {
|
||||||
var t, p, c, at string
|
var t, p, c, at string
|
||||||
var rid int64
|
var rid int64
|
||||||
err := s.DB.QueryRow(`SELECT id, ticker, payload_json, citations_json, generated_at
|
err := s.DB.QueryRow(`SELECT id, ticker, payload_json, citations_json, generated_at
|
||||||
FROM reports WHERE id=?`, id).Scan(&rid, &t, &p, &c, &at)
|
FROM reports WHERE id=? AND user_key=?`, id, userKey).Scan(&rid, &t, &p, &c, &at)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", "", 0, err
|
return "", "", "", 0, err
|
||||||
}
|
}
|
||||||
@@ -69,8 +69,8 @@ func (s *Server) loadReport(ticker string, id int64) (string, string, string, in
|
|||||||
return "", "", "", 0, err
|
return "", "", "", 0, err
|
||||||
}
|
}
|
||||||
var rid int64
|
var rid int64
|
||||||
_ = s.DB.QueryRow(`SELECT id FROM reports WHERE ticker=? ORDER BY id DESC LIMIT 1`,
|
_ = s.DB.QueryRow(`SELECT id FROM reports WHERE ticker=? AND user_key=? ORDER BY id DESC LIMIT 1`,
|
||||||
ticker).Scan(&rid)
|
ticker, userKey).Scan(&rid)
|
||||||
return p, c, at, rid, nil
|
return p, c, at, rid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func (s *Server) BuildReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
if profile == "" {
|
if profile == "" {
|
||||||
profile = "moderate"
|
profile = "moderate"
|
||||||
}
|
}
|
||||||
rep, id, err := s.Builder.Build(r.Context(), ticker, profile)
|
rep, id, err := s.Builder.Build(r.Context(), ticker, profile, s.userKey(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusBadGateway, "report: "+err.Error())
|
writeErr(w, http.StatusBadGateway, "report: "+err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ func New(cfg config.Config, db *store.DB, cache *store.Cache, s *sectors.Client)
|
|||||||
sched := scheduler.New(cfg, db, cache, s)
|
sched := scheduler.New(cfg, db, cache, s)
|
||||||
srv := &Server{
|
srv := &Server{
|
||||||
Cfg: cfg, DB: db, Sectors: s, Sched: sched, LLM: llmc,
|
Cfg: cfg, DB: db, Sectors: s, Sched: sched, LLM: llmc,
|
||||||
Validate: validator.New(),
|
Validate: validator.New(),
|
||||||
Hub: NewHub(),
|
Hub: NewHub(),
|
||||||
StartedAt: time.Now(),
|
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,
|
||||||
@@ -72,15 +72,15 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Post("/auth/signup", s.AuthSignup)
|
r.Post("/auth/signup", s.AuthSignup)
|
||||||
r.Post("/auth/login", s.AuthLogin)
|
r.Post("/auth/login", s.AuthLogin)
|
||||||
r.Get("/version", s.Version)
|
r.Get("/version", s.Version)
|
||||||
r.Get("/stream", s.Stream)
|
// Publik baca: dashboard bisa dibuka tanpa login. Fitur + filter di bawah
|
||||||
// Publik baca: dashboard bisa dibuka tanpa login.
|
// wajib login (session cookie, tanpa demo bypass).
|
||||||
r.Get("/flow/summary", s.FlowSummary)
|
r.Get("/flow/summary", s.FlowSummary)
|
||||||
r.Get("/flow/broker", s.FlowBroker)
|
r.Get("/flow/broker", s.FlowBroker)
|
||||||
r.Get("/flow/foreign", s.FlowForeign)
|
r.Get("/flow/foreign", s.FlowForeign)
|
||||||
r.Get("/briefing/today", s.BriefingToday)
|
r.Get("/briefing/today", s.BriefingToday)
|
||||||
// Fitur + filter: wajib login (session cookie, tanpa demo bypass).
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(s.requireLogin)
|
r.Use(s.requireLogin)
|
||||||
|
r.Get("/stream", s.Stream)
|
||||||
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)
|
||||||
|
|||||||
@@ -81,23 +81,23 @@ func Load() Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Config{
|
return Config{
|
||||||
Port: getenv("PORT", "8080"),
|
Port: getenv("PORT", "8080"),
|
||||||
SectorsAPIKey: os.Getenv("SECTORS_API_KEY"),
|
SectorsAPIKey: os.Getenv("SECTORS_API_KEY"),
|
||||||
SectorsBaseURL: getenv("SECTORS_BASE_URL", "https://api.sectors.app/v2/"),
|
SectorsBaseURL: getenv("SECTORS_BASE_URL", "https://api.sectors.app/v2/"),
|
||||||
DBPath: getenv("DB_PATH", "data/flowsight.db"),
|
DBPath: getenv("DB_PATH", "data/flowsight.db"),
|
||||||
RedisURL: os.Getenv("REDIS_URL"),
|
RedisURL: os.Getenv("REDIS_URL"),
|
||||||
DemoUserKey: getenv("DEMO_USER_KEY", "demo"),
|
DemoUserKey: getenv("DEMO_USER_KEY", "demo"),
|
||||||
LLMBaseURL: os.Getenv("LLM_BASE_URL"),
|
LLMBaseURL: os.Getenv("LLM_BASE_URL"),
|
||||||
LLMAPIKey: os.Getenv("LLM_API_KEY"),
|
LLMAPIKey: os.Getenv("LLM_API_KEY"),
|
||||||
LLMTriage: getenv("LLM_MODEL_TRIAGE", "gpt-4o-mini"),
|
LLMTriage: getenv("LLM_MODEL_TRIAGE", "gpt-4o-mini"),
|
||||||
LLMSynth: getenv("LLM_MODEL_SYNTH", "gpt-4o"),
|
LLMSynth: getenv("LLM_MODEL_SYNTH", "gpt-4o"),
|
||||||
TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
|
TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
|
||||||
TelegramChatID: os.Getenv("TELEGRAM_CHAT_ID"),
|
TelegramChatID: os.Getenv("TELEGRAM_CHAT_ID"),
|
||||||
DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
|
DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
|
||||||
CreditCapPerCycle: getenvInt("CREDIT_CAP_PER_CYCLE", 120),
|
CreditCapPerCycle: getenvInt("CREDIT_CAP_PER_CYCLE", 120),
|
||||||
Watchlist: tickers,
|
Watchlist: tickers,
|
||||||
StaticDir: os.Getenv("WEB_DIST_DIR"),
|
StaticDir: os.Getenv("WEB_DIST_DIR"),
|
||||||
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
|
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
|
||||||
GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
|
GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
|
||||||
GoogleRedirectURL: os.Getenv("GOOGLE_REDIRECT_URL"),
|
GoogleRedirectURL: os.Getenv("GOOGLE_REDIRECT_URL"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ type RiskFn func(ticker string) (concentration, beta string)
|
|||||||
|
|
||||||
// Build runs A1..A6 + A7 and assembles all 7 sections, returning the
|
// Build runs A1..A6 + A7 and assembles all 7 sections, returning the
|
||||||
// persisted report id for citation-scoped chat interrogation.
|
// persisted report id for citation-scoped chat interrogation.
|
||||||
func (b *Builder) Build(ctx context.Context, ticker, profile string) (Report, int64, error) {
|
func (b *Builder) Build(ctx context.Context, ticker, profile, userKey string) (Report, int64, error) {
|
||||||
ticker = strings.ToUpper(ticker)
|
ticker = strings.ToUpper(ticker)
|
||||||
results := agents.RunAll(ctx, b.Deps, ticker)
|
results := agents.RunAll(ctx, b.Deps, ticker)
|
||||||
synth := agents.Synthesize(ctx, b.Deps, ticker, agents.RiskProfile(profile), results)
|
synth := agents.Synthesize(ctx, b.Deps, ticker, agents.RiskProfile(profile), results)
|
||||||
@@ -79,7 +79,7 @@ func (b *Builder) Build(ctx context.Context, ticker, profile string) (Report, in
|
|||||||
narasi := b.narasiAwam(ctx, ticker, synth, sections)
|
narasi := b.narasiAwam(ctx, ticker, synth, sections)
|
||||||
raw, _ := json.Marshal(map[string]any{"ticker": ticker, "sections": sections, "synthesis": synth, "narasi_awam": narasi})
|
raw, _ := json.Marshal(map[string]any{"ticker": ticker, "sections": sections, "synthesis": synth, "narasi_awam": narasi})
|
||||||
citesRaw, _ := json.Marshal(all)
|
citesRaw, _ := json.Marshal(all)
|
||||||
id, err := b.DB.SaveReport(ticker, string(raw), string(citesRaw))
|
id, err := b.DB.SaveReport(ticker, string(raw), string(citesRaw), userKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Report{}, 0, err
|
return Report{}, 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
ErrBadPassword authErr = "password minimal 8 karakter"
|
ErrBadPassword authErr = "password minimal 8 karakter"
|
||||||
ErrTaken authErr = "username sudah dipakai"
|
ErrTaken authErr = "username sudah dipakai"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64
|
ID int64
|
||||||
GoogleSub string
|
GoogleSub string
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- 0008_reports_owner.sql: reports now scoped to the owning user.
|
||||||
|
-- Existing rows are assigned to the shared demo key so historic reports
|
||||||
|
-- remain readable by the demo/seed pipeline; new reports carry user_key.
|
||||||
|
CREATE TABLE IF NOT EXISTS reports_new(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ticker TEXT NOT NULL, generated_at TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL, citations_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
user_key TEXT NOT NULL DEFAULT 'demo'
|
||||||
|
);
|
||||||
|
INSERT INTO reports_new(id, ticker, generated_at, payload_json, citations_json, user_key)
|
||||||
|
SELECT id, ticker, generated_at, payload_json, citations_json, 'demo' FROM reports;
|
||||||
|
DROP TABLE reports;
|
||||||
|
ALTER TABLE reports_new RENAME TO reports;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reports_ticker ON reports(ticker, id);
|
||||||
|
|
||||||
|
-- SQLite ignores IF NOT EXISTS on column add; guard on table existence.
|
||||||
@@ -611,10 +611,10 @@ func (db *DB) FilingsSince(ticker, since string, limit int) ([]map[string]any, e
|
|||||||
|
|
||||||
// Reports reports / briefings.
|
// Reports reports / briefings.
|
||||||
|
|
||||||
// SaveReport stores a generated report; returns its id.
|
// SaveReport stores a generated report owned by userKey; returns its id.
|
||||||
func (db *DB) SaveReport(ticker, payload, cites string) (int64, error) {
|
func (db *DB) SaveReport(ticker, payload, cites, userKey string) (int64, error) {
|
||||||
res, err := db.Exec(`INSERT INTO reports(ticker,generated_at,payload_json,citations_json)
|
res, err := db.Exec(`INSERT INTO reports(ticker,generated_at,payload_json,citations_json,user_key)
|
||||||
VALUES(?,?,?,?)`, ticker, time.Now().UTC().Format(time.RFC3339), payload, cites)
|
VALUES(?,?,?,?,?)`, ticker, time.Now().UTC().Format(time.RFC3339), payload, cites, userKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func TestStoreParity(t *testing.T) {
|
|||||||
if err != nil || len(fils) != 1 {
|
if err != nil || len(fils) != 1 {
|
||||||
t.Fatalf("filings = %v, %v", fils, err)
|
t.Fatalf("filings = %v, %v", fils, err)
|
||||||
}
|
}
|
||||||
_, _ = db.SaveReport("BBCA", `{"a":1}`, `[]`)
|
_, _ = db.SaveReport("BBCA", `{"a":1}`, `[]`, "tester")
|
||||||
reps, err := db.ListReports("BBCA", 10)
|
reps, err := db.ListReports("BBCA", 10)
|
||||||
if err != nil || len(reps) != 1 {
|
if err != nil || len(reps) != 1 {
|
||||||
t.Fatalf("reports = %v, %v", reps, err)
|
t.Fatalf("reports = %v, %v", reps, err)
|
||||||
|
|||||||
@@ -11,27 +11,32 @@ export function WatchlistDrawer() {
|
|||||||
const [err, setErr] = createSignal("");
|
const [err, setErr] = createSignal("");
|
||||||
async function add() {
|
async function add() {
|
||||||
setErr("");
|
setErr("");
|
||||||
|
const t = ticker().toUpperCase().trim();
|
||||||
|
if (!t) return;
|
||||||
try {
|
try {
|
||||||
await api.addWatch(ticker().toUpperCase().trim());
|
await api.addWatch(t);
|
||||||
setTicker("");
|
setTicker("");
|
||||||
refetch();
|
refetch();
|
||||||
} catch (e) { setErr(String(e)); }
|
} catch (e) { setErr(String(e)); }
|
||||||
}
|
}
|
||||||
async function del(t: string) {
|
async function del(t: string) {
|
||||||
await api.removeWatch(t);
|
try {
|
||||||
refetch();
|
await api.removeWatch(t);
|
||||||
|
refetch();
|
||||||
|
} catch (e) { setErr(String(e)); }
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="pb-2"><CardTitle class="text-base">Watchlist</CardTitle></CardHeader>
|
<CardHeader class="pb-2"><CardTitle class="text-base">Watchlist</CardTitle></CardHeader>
|
||||||
<CardContent class="space-y-2">
|
<CardContent class="space-y-2">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<TextField class="w-24"><TextFieldInput placeholder="BBCA" value={ticker()} onInput={(e) => setTicker(e.currentTarget.value)} /></TextField>
|
<label class="sr-only" for="wl-ticker">Tambah ticker</label>
|
||||||
|
<TextField class="w-24"><TextFieldInput id="wl-ticker" placeholder="BBCA" value={ticker()} onInput={(e) => setTicker(e.currentTarget.value)} /></TextField>
|
||||||
<Button size="sm" onClick={add}>Add</Button>
|
<Button size="sm" onClick={add}>Add</Button>
|
||||||
</div>
|
</div>
|
||||||
<Show when={err()}><p class="text-xs text-destructive">{err()}</p></Show>
|
<Show when={err()}><p class="text-xs text-destructive">{err()}</p></Show>
|
||||||
<ul class="flex flex-wrap gap-1.5">
|
<ul class="flex flex-wrap gap-1.5">
|
||||||
<For each={wl()?.watchlist || []}>{(t) => <li><a href={`/report/${t}`}><Badge variant="secondary">{t}</Badge></a> <button class="text-xs text-muted-foreground hover:text-foreground" onClick={() => del(t)}>×</button></li>}</For>
|
<For each={wl()?.watchlist || []}>{(t) => <li class="flex items-center gap-1"><a href={`/report/${encodeURIComponent(t)}`}><Badge variant="secondary">{t}</Badge></a> <button class="text-xs text-muted-foreground hover:text-foreground" aria-label={`Hapus ${t} dari watchlist`} onClick={() => del(t)}>×</button></li>}</For>
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -62,10 +67,11 @@ export function ChatSidebar() {
|
|||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<TextField class="flex-1"><TextFieldInput placeholder="Ask about your watchlist…" value={msg()} onInput={(e) => setMsg(e.currentTarget.value)} onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") send(); }} /></TextField>
|
<label class="sr-only" for="chat-msg">Tanya AI</label>
|
||||||
|
<TextField class="flex-1"><TextFieldInput id="chat-msg" placeholder="Ask about your watchlist…" value={msg()} onInput={(e) => setMsg(e.currentTarget.value)} onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") send(); }} /></TextField>
|
||||||
<Button size="sm" onClick={send}>Send</Button>
|
<Button size="sm" onClick={send}>Send</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,7 @@ export function ThemeToggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
|
// me() is undefined while loading, null when logged out, AuthUser when in.
|
||||||
const [me, { refetch }] = createResource(async (): Promise<AuthUser | null> => {
|
const [me, { refetch }] = createResource(async (): Promise<AuthUser | null> => {
|
||||||
try { return (await api.me()).user; } catch { return null; }
|
try { return (await api.me()).user; } catch { return null; }
|
||||||
});
|
});
|
||||||
@@ -59,9 +60,7 @@ export function ButuhLogin(props: { fitur: string }) {
|
|||||||
|
|
||||||
export function AuthButton(props: { me: AuthUser | null | undefined; onLogout: () => void }) {
|
export function AuthButton(props: { me: AuthUser | null | undefined; onLogout: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Show when={props.me} fallback={
|
<Show when={props.me} fallback={<a href="/login"><Button>Masuk / Daftar</Button></a>}>
|
||||||
<a href="/api/auth/start"><Button>Sign in with Google</Button></a>
|
|
||||||
}>
|
|
||||||
{(u) => (
|
{(u) => (
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
<Avatar class="size-8">
|
<Avatar class="size-8">
|
||||||
|
|||||||
+15
-4
@@ -1,12 +1,12 @@
|
|||||||
import { render } from "solid-js/web";
|
import { render } from "solid-js/web";
|
||||||
import { Router, Route, useLocation, useNavigate, type RouteSectionProps } from "@solidjs/router";
|
import { Router, Route, useLocation, useNavigate, type RouteSectionProps } from "@solidjs/router";
|
||||||
import { createResource, createSignal, Show } from "solid-js";
|
import { createResource, createSignal, createEffect, Show } from "solid-js";
|
||||||
import { WatchlistDrawer, ChatSidebar } from "./components/WatchlistChat";
|
import { WatchlistDrawer, ChatSidebar } from "./components/WatchlistChat";
|
||||||
import { ThemeToggle, useAuth, AuthButton, ButuhLogin } from "./components/auth";
|
import { ThemeToggle, useAuth, AuthButton, ButuhLogin } from "./components/auth";
|
||||||
import { Button } from "./components/ui/button";
|
import { Button } from "./components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./components/ui/card";
|
||||||
import { TextField, TextFieldInput } from "./components/ui/text-field";
|
import { TextField, TextFieldInput } from "./components/ui/text-field";
|
||||||
import { api } from "./lib/api";
|
import { api, AUTH_EXPIRED_EVENT } from "./lib/api";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import Routines from "./pages/Routines";
|
import Routines from "./pages/Routines";
|
||||||
import Screener from "./pages/Screener";
|
import Screener from "./pages/Screener";
|
||||||
@@ -134,7 +134,18 @@ function LoginPage() {
|
|||||||
|
|
||||||
function Shell(props: RouteSectionProps) {
|
function Shell(props: RouteSectionProps) {
|
||||||
const { me, refetch } = useAuth();
|
const { me, refetch } = useAuth();
|
||||||
const logout = async () => { await api.logout(); refetch(); };
|
const nav = useNavigate();
|
||||||
|
// Any 401 on a gated API while logged in → session expired; go to login.
|
||||||
|
createEffect(() => {
|
||||||
|
const h = () => { if (me()) { refetch(); nav("/login"); } };
|
||||||
|
window.addEventListener(AUTH_EXPIRED_EVENT, h);
|
||||||
|
return () => window.removeEventListener(AUTH_EXPIRED_EVENT, h);
|
||||||
|
});
|
||||||
|
const logout = async () => {
|
||||||
|
try { await api.logout(); } catch { /* server gone — clear locally anyway */ }
|
||||||
|
refetch();
|
||||||
|
nav("/");
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div class="flex min-h-screen bg-background text-foreground">
|
<div class="flex min-h-screen bg-background text-foreground">
|
||||||
<Nav loggedIn={!!me()} />
|
<Nav loggedIn={!!me()} />
|
||||||
@@ -166,7 +177,7 @@ function Shell(props: RouteSectionProps) {
|
|||||||
export function Gate(props: { fitur: string; children: import("solid-js").JSX.Element }) {
|
export function Gate(props: { fitur: string; children: import("solid-js").JSX.Element }) {
|
||||||
const { me } = useAuth();
|
const { me } = useAuth();
|
||||||
return (
|
return (
|
||||||
<Show when={me() !== null} fallback={<p class="text-sm text-muted-foreground">Memeriksa login…</p>}>
|
<Show when={me() !== undefined} fallback={<p class="text-sm text-muted-foreground">Memeriksa login…</p>}>
|
||||||
<Show when={me()} fallback={<ButuhLogin fitur={props.fitur} />}>
|
<Show when={me()} fallback={<ButuhLogin fitur={props.fitur} />}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
+16
-7
@@ -1,11 +1,19 @@
|
|||||||
// Typed backend client (backend is the only source of truth; web never calls Sectors directly).
|
// Typed backend client (backend is the only source of truth; web never calls Sectors directly).
|
||||||
const BASE = "";
|
const BASE = "";
|
||||||
function headers(): HeadersInit {
|
function headers(): HeadersInit {
|
||||||
return { "Content-Type": "application/json", "X-User-Key": localStorage.getItem("fs-key") || "demo" };
|
// fs-key is legacy demo identity; cookie auth wins server-side. Keep sending
|
||||||
|
// session cookie implicitly (same-origin default) — no token in localStorage.
|
||||||
|
return { "Content-Type": "application/json" };
|
||||||
|
}
|
||||||
|
// auth-expired: global signal → Shell refetches /me and redirects to /login.
|
||||||
|
export const AUTH_EXPIRED_EVENT = "fs:auth-expired";
|
||||||
|
function notifyAuthExpired() {
|
||||||
|
try { window.dispatchEvent(new CustomEvent(AUTH_EXPIRED_EVENT)); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const r = await fetch(BASE + path, { ...init, headers: { ...headers(), ...(init?.headers || {}) } });
|
const r = await fetch(BASE + path, { ...init, headers: { ...headers(), ...(init?.headers || {}) } });
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
|
if (r.status === 401 && !path.startsWith("/api/auth/")) notifyAuthExpired();
|
||||||
let msg = r.statusText;
|
let msg = r.statusText;
|
||||||
try { const e = await r.json(); msg = e?.error?.message || msg; } catch { /* keep status */ }
|
try { const e = await r.json(); msg = e?.error?.message || msg; } catch { /* keep status */ }
|
||||||
throw new Error(msg);
|
throw new Error(msg);
|
||||||
@@ -27,12 +35,13 @@ export interface ForeignSeries { dates: string[]; nets: number[]; reversal: bool
|
|||||||
export const api = {
|
export const api = {
|
||||||
health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`),
|
health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`),
|
||||||
flowSummary: () => req<FlowSummary>("/api/flow/summary"),
|
flowSummary: () => req<FlowSummary>("/api/flow/summary"),
|
||||||
flowBroker: (ticker: string) => req<{ buyers: unknown[]; sellers: unknown[]; citations: Citation[] }>(`/api/flow/broker?ticker=${ticker}`),
|
flowBroker: (ticker: string) => req<{ buyers: unknown[]; sellers: unknown[]; citations: Citation[] }>(`/api/flow/broker?ticker=${encodeURIComponent(ticker)}`),
|
||||||
flowForeign: (ticker: string) => req<ForeignSeries>(`/api/flow/foreign?ticker=${ticker}`),
|
flowForeign: (ticker: string) => req<ForeignSeries>(`/api/flow/foreign?ticker=${encodeURIComponent(ticker)}`),
|
||||||
screen: (body: Record<string, unknown>) => req<{ rows: ScreenRow[]; count: number }>("/api/screen", { method: "POST", body: JSON.stringify(body) }),
|
screen: (body: Record<string, unknown>) => req<{ rows: ScreenRow[]; count: number }>("/api/screen", { method: "POST", body: JSON.stringify(body) }),
|
||||||
routines: () => req<{ routines: Routine[] }>("/api/routines"),
|
routines: () => req<{ routines: Routine[] }>("/api/routines"),
|
||||||
createRoutine: (body: Record<string, unknown>) => req<{ id: number }>("/api/routines", { method: "POST", body: JSON.stringify(body) }),
|
createRoutine: (body: Record<string, unknown>) => req<{ id: number }>("/api/routines", { method: "POST", body: JSON.stringify(body) }),
|
||||||
updateRoutine: (id: number, body: Record<string, unknown>) => req<unknown>(`/api/routines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
updateRoutine: (id: number, body: Record<string, unknown>) => req<unknown>(`/api/routines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
|
deleteRoutine: (id: number) => req<unknown>(`/api/routines/${id}`, { method: "DELETE" }),
|
||||||
runs: (routine_id?: number) => req<{ runs: Record<string, unknown>[] }>(`/api/routine-runs${routine_id ? `?routine_id=${routine_id}` : ""}`),
|
runs: (routine_id?: number) => req<{ runs: Record<string, unknown>[] }>(`/api/routine-runs${routine_id ? `?routine_id=${routine_id}` : ""}`),
|
||||||
briefing: () => req<{ date: string; payload: string; citations: string; narasi?: string }>("/api/briefing/today"),
|
briefing: () => req<{ date: string; payload: string; citations: string; narasi?: string }>("/api/briefing/today"),
|
||||||
destinations: () => req<{ destinations: { id: number; kind: string; label: string; enabled: boolean; configured: boolean }[] }>("/api/destinations"),
|
destinations: () => req<{ destinations: { id: number; kind: string; label: string; enabled: boolean; configured: boolean }[] }>("/api/destinations"),
|
||||||
@@ -43,12 +52,12 @@ export const api = {
|
|||||||
createAlert: (body: Record<string, unknown>) => req<{ id: number }>("/api/alerts", { method: "POST", body: JSON.stringify(body) }),
|
createAlert: (body: Record<string, unknown>) => req<{ id: number }>("/api/alerts", { method: "POST", body: JSON.stringify(body) }),
|
||||||
deleteAlert: (id: number) => req<unknown>(`/api/alerts/${id}`, { method: "DELETE" }),
|
deleteAlert: (id: number) => req<unknown>(`/api/alerts/${id}`, { method: "DELETE" }),
|
||||||
alertEvents: (since = "2000-01-01", ticker = "") => req<{ events: Record<string, unknown>[] }>(`/api/alert-events?since=${since}${ticker ? `&ticker=${ticker}` : ""}`),
|
alertEvents: (since = "2000-01-01", ticker = "") => req<{ events: Record<string, unknown>[] }>(`/api/alert-events?since=${since}${ticker ? `&ticker=${ticker}` : ""}`),
|
||||||
report: (ticker: string, profile = "moderate") => req<ReportPayload>(`/api/report/${ticker}?profile=${profile}`, { method: "POST" }),
|
report: (ticker: string, profile = "moderate") => req<ReportPayload>(`/api/report/${encodeURIComponent(ticker)}?profile=${profile}`, { method: "POST" }),
|
||||||
reportMd: (ticker: string) => req<string>(`/api/report/${ticker}?format=md`, { method: "POST" }),
|
reportMd: (ticker: string) => req<string>(`/api/report/${encodeURIComponent(ticker)}?format=md`, { method: "POST" }),
|
||||||
interrogate: (ticker: string, question: string, report_id?: number) => req<{ answer: string; report_id: number; citations: unknown }>(`/api/report/${ticker}/ask`, { method: "POST", body: JSON.stringify({ question, report_id }) }),
|
interrogate: (ticker: string, question: string, report_id?: number) => req<{ answer: string; report_id: number; citations: unknown }>(`/api/report/${encodeURIComponent(ticker)}/ask`, { method: "POST", body: JSON.stringify({ question, report_id }) }),
|
||||||
watchlist: () => req<{ watchlist: string[] }>("/api/watchlist"),
|
watchlist: () => req<{ watchlist: string[] }>("/api/watchlist"),
|
||||||
addWatch: (ticker: string) => req<unknown>("/api/watchlist", { method: "POST", body: JSON.stringify({ ticker }) }),
|
addWatch: (ticker: string) => req<unknown>("/api/watchlist", { method: "POST", body: JSON.stringify({ ticker }) }),
|
||||||
removeWatch: (ticker: string) => req<unknown>(`/api/watchlist/${ticker}`, { method: "DELETE" }),
|
removeWatch: (ticker: string) => req<unknown>(`/api/watchlist/${encodeURIComponent(ticker)}`, { method: "DELETE" }),
|
||||||
risk: () => req<{ concentration: { ticker: string; sector: string; weight: number }[]; correlation: Record<string, Record<string, number>>; beta: number; warnings: string[] }>("/api/portfolio/risk"),
|
risk: () => req<{ concentration: { ticker: string; sector: string; weight: number }[]; correlation: Record<string, Record<string, number>>; beta: number; warnings: string[] }>("/api/portfolio/risk"),
|
||||||
accuracy: () => req<{ agents: { agent: string; calls: number; resolved: number; hits: number; hit_rate: number }[] }>("/api/accuracy"),
|
accuracy: () => req<{ agents: { agent: string; calls: number; resolved: number; hits: number; hit_rate: number }[] }>("/api/accuracy"),
|
||||||
chat: (message: string) => req<{ answer: string }>(`/api/chat`, { method: "POST", body: JSON.stringify({ message }) }),
|
chat: (message: string) => req<{ answer: string }>(`/api/chat`, { method: "POST", body: JSON.stringify({ message }) }),
|
||||||
|
|||||||
@@ -28,14 +28,16 @@ function AlertsInner() {
|
|||||||
const [dchat, setDchat] = createSignal("");
|
const [dchat, setDchat] = createSignal("");
|
||||||
const [dhook, setDhook] = createSignal("");
|
const [dhook, setDhook] = createSignal("");
|
||||||
const [derr, setDerr] = createSignal("");
|
const [derr, setDerr] = createSignal("");
|
||||||
|
const [err, setErr] = createSignal("");
|
||||||
async function create() {
|
async function create() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.createAlert({ name: tpl().label, rule: tpl().rule, channels: channels().split(",").map((c) => c.trim()).filter(Boolean) });
|
await api.createAlert({ name: tpl().label, rule: tpl().rule, channels: channels().split(",").map((c) => c.trim()).filter(Boolean) });
|
||||||
refetch();
|
refetch();
|
||||||
} finally { setBusy(false); }
|
} catch (e) { setErr(String(e)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
async function del(id: number) { await api.deleteAlert(id); refetch(); }
|
async function del(id: number) { try { await api.deleteAlert(id); refetch(); } catch (e) { setErr(String(e)); } }
|
||||||
async function addDest() {
|
async function addDest() {
|
||||||
setDerr("");
|
setDerr("");
|
||||||
try {
|
try {
|
||||||
@@ -47,11 +49,12 @@ function AlertsInner() {
|
|||||||
refetchDests();
|
refetchDests();
|
||||||
} catch (e) { setDerr(String(e)); }
|
} catch (e) { setDerr(String(e)); }
|
||||||
}
|
}
|
||||||
async function toggleDest(id: number, enabled: boolean) { await api.updateDestination(id, { enabled: !enabled }); refetchDests(); }
|
async function toggleDest(id: number, enabled: boolean) { try { await api.updateDestination(id, { enabled: !enabled }); refetchDests(); } catch (e) { setDerr(String(e)); } }
|
||||||
async function delDest(id: number) { await api.deleteDestination(id); refetchDests(); }
|
async function delDest(id: number) { try { await api.deleteDestination(id); refetchDests(); } catch (e) { setDerr(String(e)); } }
|
||||||
return (
|
return (
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<PageHead title="Notifikasi otomatis 🔔" sub="Pilih kejadian yang mau kamu dikabari — tanpa perlu paham angka." />
|
<PageHead title="Notifikasi otomatis 🔔" sub="Pilih kejadian yang mau kamu dikabari — tanpa perlu paham angka." />
|
||||||
|
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>1. Mau dikabari soal apa?</CardTitle><CardDescription>Pilih satu template <Term kata="alert" />.</CardDescription></CardHeader>
|
<CardHeader><CardTitle>1. Mau dikabari soal apa?</CardTitle><CardDescription>Pilih satu template <Term kata="alert" />.</CardDescription></CardHeader>
|
||||||
<CardContent class="space-y-3">
|
<CardContent class="space-y-3">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createResource, createSignal, For, Show } from "solid-js";
|
import { createResource, createSignal, createMemo, For, Show } from "solid-js";
|
||||||
import { A, useNavigate } from "@solidjs/router";
|
import { A, useNavigate } from "@solidjs/router";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
|
import { useAuth } from "../components/auth";
|
||||||
import { verdictFor, heroSummary, konteksPasar, fmtRp, fmtHarga } from "../lib/awam";
|
import { verdictFor, heroSummary, konteksPasar, fmtRp, fmtHarga } from "../lib/awam";
|
||||||
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
|
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
|
||||||
import { Badge } from "../components/ui/badge";
|
import { Badge } from "../components/ui/badge";
|
||||||
@@ -13,10 +14,12 @@ Chart.register(...registerables);
|
|||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { me } = useAuth();
|
||||||
|
// screen+alertEvents are login-gated; source = me() so login/logout refetches.
|
||||||
const [flow] = createResource(() => api.flowSummary());
|
const [flow] = createResource(() => api.flowSummary());
|
||||||
const [screen] = createResource(() => api.screen({ limit: 5 }).catch(() => null));
|
const [screen] = createResource(me, (user) => (user ? api.screen({ limit: 5 }).catch(() => null) : null));
|
||||||
const [briefing] = createResource(() => api.briefing().catch(() => null));
|
const [briefing] = createResource(() => api.briefing().catch(() => null));
|
||||||
const [events] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 5)).catch(() => []));
|
const [events] = createResource(me, (user) => (user ? api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 5)).catch(() => []) : []));
|
||||||
const [chartTiker, setChartTiker] = createSignal("BBCA");
|
const [chartTiker, setChartTiker] = createSignal("BBCA");
|
||||||
const [foreign] = createResource(chartTiker, (t) => api.flowForeign(t).catch(() => null));
|
const [foreign] = createResource(chartTiker, (t) => api.flowForeign(t).catch(() => null));
|
||||||
|
|
||||||
@@ -31,7 +34,7 @@ export default function Dashboard() {
|
|||||||
const chartData = () => ({
|
const chartData = () => ({
|
||||||
labels: (foreign()?.dates || []) as string[],
|
labels: (foreign()?.dates || []) as string[],
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: `${chartTiker()} — uang asing harian (${fmtRp(0).slice(0, 2)}7700`,
|
label: `${chartTiker()} — uang asing harian (Rp juta)`,
|
||||||
data: (foreign()?.nets || []) as number[],
|
data: (foreign()?.nets || []) as number[],
|
||||||
borderColor: "#3b82f6",
|
borderColor: "#3b82f6",
|
||||||
backgroundColor: "rgba(59,130,246,.15)",
|
backgroundColor: "rgba(59,130,246,.15)",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, For, Show, onMount } from "solid-js";
|
import { createSignal, createEffect, For, Show, on } from "solid-js";
|
||||||
import { useParams } from "@solidjs/router";
|
import { useParams } from "@solidjs/router";
|
||||||
import { api, type ReportPayload } from "../lib/api";
|
import { api, type ReportPayload } from "../lib/api";
|
||||||
import { Citations } from "../components/Citations";
|
import { Citations } from "../components/Citations";
|
||||||
@@ -19,14 +19,15 @@ function ReportInner() {
|
|||||||
const [busy, setBusy] = createSignal(true);
|
const [busy, setBusy] = createSignal(true);
|
||||||
const [asking, setAsking] = createSignal(false);
|
const [asking, setAsking] = createSignal(false);
|
||||||
const [err, setErr] = createSignal("");
|
const [err, setErr] = createSignal("");
|
||||||
const authHeaders = () => ({ "X-User-Key": localStorage.getItem("fs-key") || "demo" });
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setErr(""); setBusy(true);
|
setErr(""); setBusy(true);
|
||||||
try { setRep(await api.report(params.ticker)); }
|
try { setRep(await api.report(params.ticker)); }
|
||||||
catch (e) { setErr(String(e)); }
|
catch (e) { setErr(String(e)); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
onMount(load);
|
// Refetch when the ticker param changes (ReportInner stays mounted
|
||||||
|
// because Gate wraps it, but params.ticker is reactive).
|
||||||
|
createEffect(on(() => params.ticker, () => load()));
|
||||||
async function exportMd() { setMd(await api.reportMd(params.ticker)); }
|
async function exportMd() { setMd(await api.reportMd(params.ticker)); }
|
||||||
async function ask() {
|
async function ask() {
|
||||||
if (!question().trim()) return;
|
if (!question().trim()) return;
|
||||||
@@ -39,6 +40,8 @@ function ReportInner() {
|
|||||||
function dl(url: string, name: string) {
|
function dl(url: string, name: string) {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url; a.download = name; a.click();
|
a.href = url; a.download = name; a.click();
|
||||||
|
// Revoke after a short delay to avoid blob URL leak.
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -72,15 +75,15 @@ function ReportInner() {
|
|||||||
<CardContent class="flex flex-wrap gap-2">
|
<CardContent class="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" onClick={exportMd}>Markdown</Button>
|
<Button variant="outline" onClick={exportMd}>Markdown</Button>
|
||||||
<Button variant="outline" onClick={async () => {
|
<Button variant="outline" onClick={async () => {
|
||||||
const r = await fetch(`/api/report/${params.ticker}?format=html`, { method: "POST", headers: authHeaders() });
|
const r = await fetch(`/api/report/${encodeURIComponent(params.ticker)}?format=html`, { method: "POST", credentials: "same-origin" });
|
||||||
dl(URL.createObjectURL(new Blob([await r.text()], { type: "text/html" })), `${params.ticker}-report.html`);
|
if (r.ok) dl(URL.createObjectURL(new Blob([await r.text()], { type: "text/html" })), `${params.ticker}-report.html`);
|
||||||
}}>HTML</Button>
|
}}>HTML</Button>
|
||||||
<Button variant="outline" onClick={() => {
|
<Button variant="outline" onClick={() => {
|
||||||
dl(URL.createObjectURL(new Blob([JSON.stringify(rep(), null, 1)], { type: "application/json" })), `${params.ticker}-report.json`);
|
dl(URL.createObjectURL(new Blob([JSON.stringify(rep(), null, 1)], { type: "application/json" })), `${params.ticker}-report.json`);
|
||||||
}}>JSON</Button>
|
}}>JSON</Button>
|
||||||
<Button variant="outline" onClick={async () => {
|
<Button variant="outline" onClick={async () => {
|
||||||
const r = await fetch(`/api/report/${params.ticker}?format=pdf`, { method: "POST", headers: authHeaders() });
|
const r = await fetch(`/api/report/${encodeURIComponent(params.ticker)}?format=pdf`, { method: "POST", credentials: "same-origin" });
|
||||||
dl(URL.createObjectURL(await r.blob()), `${params.ticker}-report.pdf`);
|
if (r.ok) dl(URL.createObjectURL(await r.blob()), `${params.ticker}-report.pdf`);
|
||||||
}}>PDF</Button>
|
}}>PDF</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -107,5 +110,5 @@ function ReportInner() {
|
|||||||
import { Gate } from "../index";
|
import { Gate } from "../index";
|
||||||
|
|
||||||
export default function Report() {
|
export default function Report() {
|
||||||
return <Gate fitur="Report saham">{<ReportInner />}</Gate>;
|
return <Gate fitur="Report saham"><ReportInner /></Gate>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,20 +22,24 @@ function RoutinesInner() {
|
|||||||
const [runs, { refetch: refetchRuns }] = createResource(() => api.runs().then((r) => r.runs.slice(0, 20)));
|
const [runs, { refetch: refetchRuns }] = createResource(() => api.runs().then((r) => r.runs.slice(0, 20)));
|
||||||
const [type_, setType] = createSignal("morning-briefing");
|
const [type_, setType] = createSignal("morning-briefing");
|
||||||
const [busy, setBusy] = createSignal(false);
|
const [busy, setBusy] = createSignal(false);
|
||||||
|
const [err, setErr] = createSignal("");
|
||||||
const [briefing] = createResource(() => api.briefing().catch(() => null));
|
const [briefing] = createResource(() => api.briefing().catch(() => null));
|
||||||
async function subscribe() {
|
async function subscribe() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try { await api.createRoutine({ type: type_() }); refetch(); }
|
try { await api.createRoutine({ type: type_() }); refetch(); }
|
||||||
|
catch (e) { setErr(String(e)); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
async function toggle(id: number, enabled: boolean) { await api.updateRoutine(id, { enabled: !enabled }); refetch(); }
|
async function toggle(id: number, enabled: boolean) {
|
||||||
|
try { await api.updateRoutine(id, { enabled: !enabled }); refetch(); } catch (e) { setErr(String(e)); }
|
||||||
|
}
|
||||||
async function del(id: number) {
|
async function del(id: number) {
|
||||||
await fetch(`/api/routines/${id}`, { method: "DELETE", headers: { "X-User-Key": localStorage.getItem("fs-key") || "demo" } });
|
try { await api.deleteRoutine(id); refetch(); } catch (e) { setErr(String(e)); }
|
||||||
refetch();
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<PageHead title="Jadwal otomatis 🗓️" sub="Pilih sekali — sistem yang kerja tiap hari. Ini namanya routine." />
|
<PageHead title="Jadwal otomatis 🗓️" sub="Pilih sekali — sistem yang kerja tiap hari. Ini namanya routine." />
|
||||||
|
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>Mau dilayani apa?</CardTitle><CardDescription>Jadwal standar sudah diatur (mis. ringkasan jam 07:30) — tidak perlu isi cron.</CardDescription></CardHeader>
|
<CardHeader><CardTitle>Mau dilayani apa?</CardTitle><CardDescription>Jadwal standar sudah diatur (mis. ringkasan jam 07:30) — tidak perlu isi cron.</CardDescription></CardHeader>
|
||||||
<CardContent class="space-y-3">
|
<CardContent class="space-y-3">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, For, Show } from "solid-js";
|
import { createSignal, For, Show, onMount } from "solid-js";
|
||||||
import { api, type ScreenRow } from "../lib/api";
|
import { api, type ScreenRow } from "../lib/api";
|
||||||
import { verdictFor } from "../lib/awam";
|
import { verdictFor } from "../lib/awam";
|
||||||
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
|
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
|
||||||
@@ -33,7 +33,7 @@ function ScreenerInner() {
|
|||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
// Default = semua: auto-jalan preset "Semua" sekali saat halaman dibuka.
|
// Default = semua: auto-jalan preset "Semua" sekali saat halaman dibuka.
|
||||||
if (!ran() && !busy()) void runPreset(PRESET_SEMUA);
|
onMount(() => { if (!ran()) void runPreset(PRESET_SEMUA); });
|
||||||
const hasil = () => rows().map((r) => ({ row: r, ...verdictFor(r) }));
|
const hasil = () => rows().map((r) => ({ row: r, ...verdictFor(r) }));
|
||||||
return (
|
return (
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -84,5 +84,5 @@ function ScreenerInner() {
|
|||||||
import { Gate } from "../index";
|
import { Gate } from "../index";
|
||||||
|
|
||||||
export default function Screener() {
|
export default function Screener() {
|
||||||
return <Gate fitur="Cari saham">{ScreenerInner()}</Gate>;
|
return <Gate fitur="Cari saham"><ScreenerInner /></Gate>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user