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)) for _, row := range rows { hist, _ := s.DB.RunHistory(row.ID, 1) var last any if len(hist) > 0 { last = hist[0] } 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}) }