Files
flowsight/backend/internal/api/routines.go
T
asepharyana 1ec860f9be fix: audit round 3 — IDOR report-scoping, auth rate-limit, briefing chat scoping, DailyVolumes dedup, N+1 list routines, FE export/ask error handling
- IDOR: interrogate without report_id now scoped to user_key (LatestReportForUser); regression test TestInterrogateIDORScoped
- auth: signup/login per-IP rate limit 10/60s (new internal/api/ratelimit.go) + test
- chat unscoped grounding: build caller's own briefing instead of global LatestBriefing
- DailyVolumes: dedupe by bar date (snapshot rows hold 30-day windows) — fixes volume-anomaly skew
- GetDestination: direct (id,user_key) query instead of listing all
- ListRoutines: single LastRunsByRoutine query instead of N+1 RunHistory
- FE: exportMd/ask/HTML/PDF export now surface errors; alerts create clears channels
2026-09-16 14:12:32 +07:00

177 lines
5.6 KiB
Go

package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"flowsight/internal/routines"
"flowsight/internal/store"
)
// todayUTC returns today's date (UTC) for briefing persistence.
func todayUTC() string { return time.Now().UTC().Format("2006-01-02") }
// ListRoutines serves GET /api/routines with last-run status.
func (s *Server) ListRoutines(w http.ResponseWriter, r *http.Request) {
rows, err := s.DB.ListRoutines(s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
type rowOut struct {
store.Routine
LastRun any `json:"last_run"`
}
out := make([]rowOut, 0, len(rows))
lastByRoutine, lerr := s.DB.LastRunsByRoutine(s.userKey(r))
if lerr != nil {
lastByRoutine = nil
}
for _, row := range rows {
var last any
if lastByRoutine != nil {
if v, ok := lastByRoutine[row.ID]; ok {
last = v
}
}
out = append(out, rowOut{row, last})
}
writeJSON(w, http.StatusOK, map[string]any{"routines": out})
}
// CreateRoutine serves POST /api/routines {type, schedule_cron?, channels[]}.
func (s *Server) CreateRoutine(w http.ResponseWriter, r *http.Request) {
var req struct {
Type string `json:"type" validate:"required"`
Schedule string `json:"schedule_cron"`
Channels []string `json:"channels"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := s.Validate.Struct(req); err != nil {
writeErr(w, http.StatusUnprocessableEntity, "type is required")
return
}
if !routines.KnownType(req.Type) {
writeErr(w, http.StatusUnprocessableEntity, "unknown routine type")
return
}
if req.Schedule == "" {
req.Schedule = routines.DefaultSchedule(req.Type)
}
id, err := s.DB.CreateRoutine(s.userKey(r), req.Type, req.Schedule, req.Channels)
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id, "type": req.Type, "schedule_cron": req.Schedule})
}
// UpdateRoutine serves PATCH /api/routines/:id.
func (s *Server) UpdateRoutine(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
var req struct {
Enabled *bool `json:"enabled"`
Schedule string `json:"schedule_cron"`
Channels []string `json:"channels"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid JSON body")
return
}
ok, err := s.DB.UpdateRoutine(id, s.userKey(r), req.Enabled, req.Schedule, req.Channels)
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
if !ok {
writeErr(w, http.StatusNotFound, "routine not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true})
}
// RunHistory serves GET /api/routine-runs?routine_id=&limit=.
func (s *Server) RunHistory(w http.ResponseWriter, r *http.Request) {
rid, _ := strconv.ParseInt(r.URL.Query().Get("routine_id"), 10, 64)
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
hist, err := s.DB.RunHistory(rid, limit, s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
if hist == nil {
hist = []map[string]any{}
}
writeJSON(w, http.StatusOK, map[string]any{"runs": hist})
}
// BriefingToday serves GET /api/briefing/today: latest payload + citations.
// When no briefing exists yet it generates one on demand from current
// snapshots (so the UI never shows an empty page), then persists it.
func (s *Server) BriefingToday(w http.ResponseWriter, r *http.Request) {
date, payload, cites, narasi, err := s.DB.LatestBriefing()
if err != nil {
uk := s.userKey(r)
p, cc, gerr := s.Engine.BriefingFor(r.Context(), uk)
if gerr != nil {
writeErr(w, http.StatusNotFound, "no briefing yet — subscribe to the morning-briefing routine")
return
}
ccJSON, _ := json.Marshal(cc)
if err := s.DB.SaveBriefing(todayUTC(), p, string(ccJSON)); err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
date, payload, cites = todayUTC(), p, string(ccJSON)
}
// Lazy narration: polish once via LLM, cache in DB, serve thereafter.
// Any LLM failure leaves narasi empty (FE falls back to rules summary).
if narasi == "" && s.LLM.Available() {
if text, nerr := s.LLM.Complete(r.Context(), s.Cfg.LLMSynth,
"Kamu asisten pasar saham Indonesia yang ramah. Poles ringkasan data "+
"berikut jadi TEPAT 3 kalimat Bahasa Indonesia santai untuk orang awam. "+
"Kalimat 1: saham apa yang paling diborong + artinya. "+
"Kalimat 2: arah uang asing. Kalimat 3: hal yang perlu diperhatikan. "+
"Setiap angka WAJIB berasal dari data — jangan mengarang. Tanpa sapaan.",
"Data:\n"+head(payload, 2500), 300); nerr == nil && text != "" {
narasi = strings.TrimSpace(text)
_ = s.DB.SaveNarasi(date, narasi)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"date": date, "payload": payload, "citations": cites, "narasi": narasi,
})
}
// DeleteRoutine serves DELETE /api/routines/:id.
func (s *Server) DeleteRoutine(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
ok, err := s.DB.DeleteRoutine(id, s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
return
}
if !ok {
writeErr(w, http.StatusNotFound, "routine not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true})
}