feat: add frontend + backend + database to retrieve and compute news from Yahoo

This commit is contained in:
2026-04-18 23:53:57 +02:00
parent f9b6d35c49
commit 93668273ff
84 changed files with 15431 additions and 0 deletions

View File

@ -0,0 +1,349 @@
package handlers
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/tradarr/backend/internal/ai"
"github.com/tradarr/backend/internal/httputil"
"github.com/tradarr/backend/internal/models"
)
// ── Credentials ────────────────────────────────────────────────────────────
type credentialsRequest struct {
SourceID string `json:"source_id" binding:"required"`
Username string `json:"username" binding:"required"`
Password string `json:"password"`
}
func (h *Handler) GetCredentials(c *gin.Context) {
sources, err := h.repo.ListSources()
if err != nil {
httputil.InternalError(c, err)
return
}
type credResponse struct {
SourceID string `json:"source_id"`
SourceName string `json:"source_name"`
Username string `json:"username"`
HasPassword bool `json:"has_password"`
}
var result []credResponse
for _, src := range sources {
if src.Type != "bloomberg" {
continue
}
cred, _ := h.repo.GetCredentials(src.ID)
r := credResponse{SourceID: src.ID, SourceName: src.Name}
if cred != nil {
r.Username = cred.Username
r.HasPassword = cred.PasswordEncrypted != ""
}
result = append(result, r)
}
httputil.OK(c, result)
}
func (h *Handler) UpsertCredentials(c *gin.Context) {
var req credentialsRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
encPwd := ""
if req.Password != "" {
var err error
encPwd, err = h.enc.Encrypt(req.Password)
if err != nil {
httputil.InternalError(c, err)
return
}
}
if err := h.repo.UpsertCredentials(req.SourceID, req.Username, encPwd); err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, gin.H{"ok": true})
}
// ── AI Providers ───────────────────────────────────────────────────────────
type aiProviderRequest struct {
Name string `json:"name" binding:"required"`
APIKey string `json:"api_key"`
Model string `json:"model"`
Endpoint string `json:"endpoint"`
}
func (h *Handler) ListAIProviders(c *gin.Context) {
providers, err := h.repo.ListAIProviders()
if err != nil {
httputil.InternalError(c, err)
return
}
// Ne pas exposer les clés chiffrées — juste indiquer si elle existe
type safeProvider struct {
ID string `json:"id"`
Name string `json:"name"`
Model string `json:"model"`
Endpoint string `json:"endpoint"`
IsActive bool `json:"is_active"`
HasKey bool `json:"has_key"`
}
var result []safeProvider
for _, p := range providers {
result = append(result, safeProvider{
ID: p.ID,
Name: p.Name,
Model: p.Model,
Endpoint: p.Endpoint,
IsActive: p.IsActive,
HasKey: p.APIKeyEncrypted != "",
})
}
httputil.OK(c, result)
}
func (h *Handler) CreateAIProvider(c *gin.Context) {
var req aiProviderRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
encKey := ""
if req.APIKey != "" {
var err error
encKey, err = h.enc.Encrypt(req.APIKey)
if err != nil {
httputil.InternalError(c, err)
return
}
}
p, err := h.repo.CreateAIProvider(req.Name, encKey, req.Model, req.Endpoint)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.Created(c, p)
}
func (h *Handler) UpdateAIProvider(c *gin.Context) {
id := c.Param("id")
var req aiProviderRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
existing, err := h.repo.GetAIProviderByID(id)
if err != nil || existing == nil {
httputil.NotFound(c)
return
}
encKey := existing.APIKeyEncrypted
if req.APIKey != "" {
encKey, err = h.enc.Encrypt(req.APIKey)
if err != nil {
httputil.InternalError(c, err)
return
}
}
if err := h.repo.UpdateAIProvider(id, encKey, req.Model, req.Endpoint); err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, gin.H{"ok": true})
}
func (h *Handler) SetActiveAIProvider(c *gin.Context) {
id := c.Param("id")
if err := h.repo.SetActiveAIProvider(id); err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, gin.H{"ok": true})
}
func (h *Handler) DeleteAIProvider(c *gin.Context) {
if err := h.repo.DeleteAIProvider(c.Param("id")); err != nil {
httputil.InternalError(c, err)
return
}
httputil.NoContent(c)
}
func (h *Handler) ListAIModels(c *gin.Context) {
id := c.Param("id")
p, err := h.repo.GetAIProviderByID(id)
if err != nil || p == nil {
httputil.NotFound(c)
return
}
apiKey := ""
if p.APIKeyEncrypted != "" {
apiKey, err = h.enc.Decrypt(p.APIKeyEncrypted)
if err != nil {
httputil.InternalError(c, err)
return
}
}
provider, err := h.pipeline.BuildProvider(p.Name, apiKey, p.Endpoint)
if err != nil {
httputil.InternalError(c, err)
return
}
models, err := provider.ListModels(c.Request.Context())
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, models)
}
// ── Sources ────────────────────────────────────────────────────────────────
func (h *Handler) ListSources(c *gin.Context) {
sources, err := h.repo.ListSources()
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, sources)
}
type updateSourceRequest struct {
Enabled bool `json:"enabled"`
}
func (h *Handler) UpdateSource(c *gin.Context) {
var req updateSourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
if err := h.repo.UpdateSource(c.Param("id"), req.Enabled); err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, gin.H{"ok": true})
}
// ── Scrape Jobs ────────────────────────────────────────────────────────────
func (h *Handler) ListScrapeJobs(c *gin.Context) {
jobs, err := h.repo.ListScrapeJobs(100)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, jobs)
}
func (h *Handler) TriggerScrapeJob(c *gin.Context) {
type triggerRequest struct {
SourceID string `json:"source_id" binding:"required"`
}
var req triggerRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
go func() {
if err := h.registry.Run(req.SourceID); err != nil {
fmt.Printf("scrape job error: %v\n", err)
}
}()
c.JSON(http.StatusAccepted, gin.H{"ok": true, "message": "job started"})
}
// ── Settings ───────────────────────────────────────────────────────────────
func (h *Handler) ListSettings(c *gin.Context) {
settings, err := h.repo.ListSettings()
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, settings)
}
type updateSettingsRequest struct {
Settings []models.Setting `json:"settings"`
}
func (h *Handler) UpdateSettings(c *gin.Context) {
var req updateSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
for _, s := range req.Settings {
if err := h.repo.SetSetting(s.Key, s.Value); err != nil {
httputil.InternalError(c, err)
return
}
}
httputil.OK(c, gin.H{"ok": true})
}
func (h *Handler) GetDefaultSystemPrompt(c *gin.Context) {
httputil.OK(c, gin.H{"prompt": ai.DefaultSystemPrompt})
}
// ── Admin Users ────────────────────────────────────────────────────────────
func (h *Handler) ListUsers(c *gin.Context) {
users, err := h.repo.ListUsers()
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, users)
}
type updateUserRequest struct {
Email string `json:"email" binding:"required,email"`
Role string `json:"role" binding:"required"`
}
func (h *Handler) UpdateAdminUser(c *gin.Context) {
id := c.Param("id")
var req updateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
if req.Role != "admin" && req.Role != "user" {
httputil.BadRequest(c, fmt.Errorf("role must be admin or user"))
return
}
user, err := h.repo.UpdateUser(id, req.Email, models.Role(req.Role))
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, user)
}
func (h *Handler) DeleteAdminUser(c *gin.Context) {
id := c.Param("id")
// Empêcher la suppression du dernier admin
user, err := h.repo.GetUserByID(id)
if err != nil || user == nil {
httputil.NotFound(c)
return
}
if user.Role == "admin" {
count, _ := h.repo.CountAdmins()
if count <= 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete the last admin"})
return
}
}
if err := h.repo.DeleteUser(id); err != nil {
httputil.InternalError(c, err)
return
}
httputil.NoContent(c)
}

View File

@ -0,0 +1,36 @@
package handlers
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/tradarr/backend/internal/httputil"
)
func (h *Handler) ListArticles(c *gin.Context) {
symbol := c.Query("symbol")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if limit > 100 {
limit = 100
}
articles, err := h.repo.ListArticles(symbol, limit, offset)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, articles)
}
func (h *Handler) GetArticle(c *gin.Context) {
article, err := h.repo.GetArticleByID(c.Param("id"))
if err != nil {
httputil.InternalError(c, err)
return
}
if article == nil {
httputil.NotFound(c)
return
}
httputil.OK(c, article)
}

View File

@ -0,0 +1,64 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/tradarr/backend/internal/auth"
"github.com/tradarr/backend/internal/httputil"
"github.com/tradarr/backend/internal/models"
)
type loginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type registerRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
func (h *Handler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
user, err := h.repo.GetUserByEmail(req.Email)
if err != nil || user == nil || !auth.CheckPassword(user.PasswordHash, req.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
token, err := auth.GenerateToken(user.ID, user.Email, string(user.Role), h.cfg.JWTSecret)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, gin.H{"token": token, "user": user})
}
func (h *Handler) Register(c *gin.Context) {
var req registerRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
existing, _ := h.repo.GetUserByEmail(req.Email)
if existing != nil {
c.JSON(http.StatusConflict, gin.H{"error": "email already in use"})
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
httputil.InternalError(c, err)
return
}
user, err := h.repo.CreateUser(req.Email, hash, models.RoleUser)
if err != nil {
httputil.InternalError(c, err)
return
}
token, _ := auth.GenerateToken(user.ID, user.Email, string(user.Role), h.cfg.JWTSecret)
httputil.Created(c, gin.H{"token": token, "user": user})
}

View File

@ -0,0 +1,33 @@
package handlers
import (
"github.com/tradarr/backend/internal/ai"
"github.com/tradarr/backend/internal/config"
"github.com/tradarr/backend/internal/crypto"
"github.com/tradarr/backend/internal/models"
"github.com/tradarr/backend/internal/scraper"
)
type Handler struct {
repo *models.Repository
cfg *config.Config
enc *crypto.Encryptor
registry *scraper.Registry
pipeline *ai.Pipeline
}
func New(
repo *models.Repository,
cfg *config.Config,
enc *crypto.Encryptor,
registry *scraper.Registry,
pipeline *ai.Pipeline,
) *Handler {
return &Handler{
repo: repo,
cfg: cfg,
enc: enc,
registry: registry,
pipeline: pipeline,
}
}

View File

@ -0,0 +1,33 @@
package handlers
import (
"context"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/tradarr/backend/internal/httputil"
)
func (h *Handler) ListSummaries(c *gin.Context) {
userID := c.GetString("userID")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
summaries, err := h.repo.ListSummaries(userID, limit)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, summaries)
}
func (h *Handler) GenerateSummary(c *gin.Context) {
userID := c.GetString("userID")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
summary, err := h.pipeline.GenerateForUser(ctx, userID)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.Created(c, summary)
}

View File

@ -0,0 +1,56 @@
package handlers
import (
"github.com/gin-gonic/gin"
"github.com/tradarr/backend/internal/httputil"
)
func (h *Handler) GetMe(c *gin.Context) {
userID := c.GetString("userID")
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil {
httputil.NotFound(c)
return
}
httputil.OK(c, user)
}
func (h *Handler) GetMyAssets(c *gin.Context) {
userID := c.GetString("userID")
assets, err := h.repo.GetUserAssets(userID)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, assets)
}
type addAssetRequest struct {
Symbol string `json:"symbol" binding:"required"`
Name string `json:"name"`
}
func (h *Handler) AddMyAsset(c *gin.Context) {
userID := c.GetString("userID")
var req addAssetRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
asset, err := h.repo.AddUserAsset(userID, req.Symbol, req.Name)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.Created(c, asset)
}
func (h *Handler) RemoveMyAsset(c *gin.Context) {
userID := c.GetString("userID")
symbol := c.Param("symbol")
if err := h.repo.RemoveUserAsset(userID, symbol); err != nil {
httputil.InternalError(c, err)
return
}
httputil.NoContent(c)
}