- Implemented Citations component to display citation data. - Created WatchlistDrawer and ChatSidebar components for managing watchlists and AI chat functionality. - Integrated API calls for watchlist management and chat interactions. - Updated index.tsx to include new components in the main application layout. - Added API client in lib/api.ts for structured API interactions. - Developed Alerts, Dashboard, Portfolio, Routines, Screener, and Report pages with relevant data fetching and UI components. - Introduced styles in tokens.css for consistent theming across the application. - Configured TypeScript and Vite for project setup and development.
157 lines
4.6 KiB
Go
157 lines
4.6 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"flowsight/internal/store"
|
|
)
|
|
|
|
// destOut is the masked API shape: secrets never leave the server.
|
|
type destOut struct {
|
|
ID int64 `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Label string `json:"label"`
|
|
Enabled bool `json:"enabled"`
|
|
Configured bool `json:"configured"`
|
|
}
|
|
|
|
func maskDestinations(rows []store.Destination) []destOut {
|
|
out := make([]destOut, 0, len(rows))
|
|
for _, d := range rows {
|
|
cfg := false
|
|
switch d.Kind {
|
|
case store.DestTelegram:
|
|
cfg = d.BotToken != "" && d.ChatID != ""
|
|
case store.DestDiscord:
|
|
cfg = d.WebhookURL != ""
|
|
}
|
|
out = append(out, destOut{ID: d.ID, Kind: d.Kind, Label: d.Label, Enabled: d.Enabled, Configured: cfg})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ListDestinations serves GET /api/destinations (secrets masked).
|
|
func (s *Server) ListDestinations(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := s.DB.ListDestinations(s.userKey(r))
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"destinations": maskDestinations(rows)})
|
|
}
|
|
|
|
// CreateDestination serves POST /api/destinations.
|
|
func (s *Server) CreateDestination(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Kind string `json:"kind" validate:"required,oneof=telegram discord"`
|
|
Label string `json:"label"`
|
|
BotToken string `json:"bot_token"`
|
|
ChatID string `json:"chat_id"`
|
|
WebhookURL string `json:"webhook_url"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
req.Kind = strings.ToLower(strings.TrimSpace(req.Kind))
|
|
if err := s.Validate.Struct(req); err != nil {
|
|
writeErr(w, http.StatusUnprocessableEntity, "kind must be telegram or discord")
|
|
return
|
|
}
|
|
if msg := checkDestSecrets(req.Kind, req.BotToken, req.ChatID, req.WebhookURL); msg != "" {
|
|
writeErr(w, http.StatusUnprocessableEntity, msg)
|
|
return
|
|
}
|
|
enabled := true
|
|
if req.Enabled != nil {
|
|
enabled = *req.Enabled
|
|
}
|
|
id, err := s.DB.CreateDestination(store.Destination{
|
|
UserKey: s.userKey(r), Kind: req.Kind, Label: req.Label,
|
|
BotToken: req.BotToken, ChatID: req.ChatID, WebhookURL: req.WebhookURL,
|
|
Enabled: enabled,
|
|
})
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"id": id, "kind": req.Kind})
|
|
}
|
|
|
|
// UpdateDestination serves PATCH /api/destinations/:id. Kind is immutable;
|
|
// omitted secret fields keep their stored value.
|
|
func (s *Server) UpdateDestination(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 {
|
|
Label *string `json:"label"`
|
|
Enabled *bool `json:"enabled"`
|
|
BotToken *string `json:"bot_token"`
|
|
ChatID *string `json:"chat_id"`
|
|
WebhookURL *string `json:"webhook_url"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
ok, err := s.DB.UpdateDestination(id, s.userKey(r), req.Label, req.Enabled, req.BotToken, req.ChatID, req.WebhookURL)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
|
|
return
|
|
}
|
|
if !ok {
|
|
writeErr(w, http.StatusNotFound, "destination not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true})
|
|
}
|
|
|
|
// DeleteDestination serves DELETE /api/destinations/:id.
|
|
func (s *Server) DeleteDestination(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.DeleteDestination(id, s.userKey(r))
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadGateway, "db: "+err.Error())
|
|
return
|
|
}
|
|
if !ok {
|
|
writeErr(w, http.StatusNotFound, "destination not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"id": id, "ok": true})
|
|
}
|
|
|
|
// checkDestSecrets validates kind-appropriate secrets.
|
|
func checkDestSecrets(kind, botToken, chatID, webhookURL string) string {
|
|
switch kind {
|
|
case store.DestTelegram:
|
|
if strings.TrimSpace(botToken) == "" || strings.TrimSpace(chatID) == "" {
|
|
return "telegram needs bot_token and chat_id"
|
|
}
|
|
case store.DestDiscord:
|
|
u := strings.TrimSpace(webhookURL)
|
|
if u == "" {
|
|
return "discord needs webhook_url"
|
|
}
|
|
if !strings.HasPrefix(u, "https://") {
|
|
return "webhook_url must be https"
|
|
}
|
|
default:
|
|
return "kind must be telegram or discord"
|
|
}
|
|
return ""
|
|
}
|