Compare commits

...

2 Commits

85 changed files with 15461 additions and 0 deletions

View File

@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"WebSearch",
"Bash(go mod *)",
"Read(//usr/local/go/bin/**)",
"Read(//home/anthony/go/bin/**)",
"Read(//usr/**)",
"Bash(/home/anthony/go/bin/go mod *)",
"Bash(/home/anthony/go/bin/go build *)",
"Bash(npm install *)",
"Bash(/home/anthony/.config/JetBrains/WebStorm2026.1/node/versions/24.14.1/bin/npm install *)",
"Bash(npm run *)"
]
}
}

16
.env.example Normal file
View File

@ -0,0 +1,16 @@
# PostgreSQL
POSTGRES_DB=admin
POSTGRES_USER=admin
POSTGRES_PASSWORD=#Azuw169ytq
# Backend
JWT_SECRET=bK8T5X83JJlTMZc3ZoIoBQbmHybAuEjJ
# 32 bytes en hex (générer avec: openssl rand -hex 32)
ENCRYPTION_KEY=5a6a104d5ad8d2aee3ccf92d9982b7da0d94167a6c1a01057c1328e640bc977e
# Compte admin initial (créé au démarrage si inexistant)
ADMIN_EMAIL=blomios@gmail.com
ADMIN_PASSWORD=#Azuw169ytq
# Port exposé du frontend (défaut: 80)
FRONTEND_PORT=80

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Environment
.env
.env.local
.env.*.local
# Go
backend/tradarr
backend/vendor/
backend/tmp/
# Node / Frontend
frontend/node_modules/
frontend/dist/
frontend/.vite/
# Docker volumes (si montés localement)
pgdata/
# IDE
.idea/
.vscode/
*.iml
*.swp
# OS
.DS_Store
Thumbs.db
# Logs
*.log

29
Makefile Normal file
View File

@ -0,0 +1,29 @@
.PHONY: up down build logs deps
# Démarrer tous les services
up:
docker-compose up --build
# Arrêter
down:
docker-compose down
# Build uniquement
build:
docker-compose build
# Logs
logs:
docker-compose logs -f
# Télécharger les dépendances Go (à lancer avant le premier build)
deps:
cd backend && go mod tidy
cd frontend && npm install
# Lancer en développement (backend + frontend séparément)
dev-backend:
cd backend && go run ./cmd/server
dev-frontend:
cd frontend && npm run dev

29
backend/Dockerfile Normal file
View File

@ -0,0 +1,29 @@
FROM golang:1.23-bookworm AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o tradarr ./cmd/server
FROM debian:bookworm-slim
# Chromium pour le scraping Bloomberg
RUN apt-get update && apt-get install -y \
chromium \
chromium-driver \
ca-certificates \
fonts-liberation \
libnss3 \
--no-install-recommends && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/tradarr .
COPY --from=builder /app/internal/database/migrations ./internal/database/migrations
ENV CHROME_PATH=/usr/bin/chromium
EXPOSE 8080
CMD ["./tradarr"]

View File

@ -0,0 +1,95 @@
package main
import (
"fmt"
"log"
"github.com/tradarr/backend/internal/ai"
"github.com/tradarr/backend/internal/api"
"github.com/tradarr/backend/internal/api/handlers"
"github.com/tradarr/backend/internal/auth"
"github.com/tradarr/backend/internal/config"
"github.com/tradarr/backend/internal/crypto"
"github.com/tradarr/backend/internal/database"
"github.com/tradarr/backend/internal/models"
"github.com/tradarr/backend/internal/scheduler"
"github.com/tradarr/backend/internal/scraper"
"github.com/tradarr/backend/internal/scraper/bloomberg"
"github.com/tradarr/backend/internal/scraper/yahoofinance"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
db, err := database.Connect(cfg.DatabaseURL)
if err != nil {
log.Fatalf("database: %v", err)
}
defer db.Close()
if err := database.RunMigrations(db); err != nil {
log.Fatalf("migrations: %v", err)
}
repo := models.NewRepository(db)
enc := crypto.New(cfg.EncryptionKey)
pipeline := ai.NewPipeline(repo, enc)
// Créer le compte admin initial si nécessaire
if err := ensureAdmin(repo, cfg); err != nil {
log.Printf("ensure admin: %v", err)
}
// Configurer les scrapers
registry := scraper.NewRegistry(repo)
// Bloomberg (credentials chargés depuis la DB à chaque run)
bbScraper := bloomberg.NewDynamic(repo, enc, cfg.ChromePath)
registry.Register(bbScraper)
stScraper := yahoofinance.New()
registry.Register(stScraper)
// Scheduler
sched := scheduler.New(registry, pipeline, repo)
if err := sched.Start(); err != nil {
log.Printf("scheduler: %v", err)
}
defer sched.Stop()
// API
h := handlers.New(repo, cfg, enc, registry, pipeline)
r := api.SetupRouter(h, cfg.JWTSecret)
addr := fmt.Sprintf(":%s", cfg.Port)
log.Printf("server listening on %s", addr)
if err := r.Run(addr); err != nil {
log.Fatalf("server: %v", err)
}
}
func ensureAdmin(repo *models.Repository, cfg *config.Config) error {
if cfg.AdminEmail == "" {
return nil
}
existing, err := repo.GetUserByEmail(cfg.AdminEmail)
if err != nil {
return err
}
if existing != nil {
return nil
}
hash, err := auth.HashPassword(cfg.AdminPassword)
if err != nil {
return err
}
_, err = repo.CreateUser(cfg.AdminEmail, hash, models.RoleAdmin)
if err != nil {
return err
}
log.Printf("admin account created: %s", cfg.AdminEmail)
return nil
}

55
backend/go.mod Normal file
View File

@ -0,0 +1,55 @@
module github.com/tradarr/backend
go 1.23
require (
github.com/chromedp/chromedp v0.11.2
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/golang-migrate/migrate/v4 v4.18.1
github.com/lib/pq v1.10.9
github.com/robfig/cron/v3 v3.0.1
github.com/sashabaranov/go-openai v1.36.1
golang.org/x/crypto v0.32.0
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/otel/metric v1.31.0 // indirect
go.opentelemetry.io/otel/trace v1.31.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.36.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

166
backend/go.sum Normal file
View File

@ -0,0 +1,166 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb h1:noKVm2SsG4v0Yd0lHNtFYc9EUxIVvrr4kJ6hM8wvIYU=
github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0=
github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.3 h1:wquqUxAFdcUgabAVLvSCOKOlag5cIZuaOjYIBOWdsR0=
github.com/dhui/dktest v0.4.3/go.mod h1:zNK8IwktWzQRm6I/l2Wjp7MakiyaFWv4G1hjmodmMTs=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/sashabaranov/go-openai v1.36.1 h1:EVfRXwIlW2rUzpx6vR+aeIKCK/xylSrVYAx1TMTSX3g=
github.com/sashabaranov/go-openai v1.36.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -0,0 +1,79 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
type anthropicProvider struct {
apiKey string
model string
client *http.Client
}
func newAnthropic(apiKey, model string) *anthropicProvider {
if model == "" {
model = "claude-sonnet-4-6"
}
return &anthropicProvider{
apiKey: apiKey,
model: model,
client: &http.Client{},
}
}
func (p *anthropicProvider) Name() string { return "anthropic" }
func (p *anthropicProvider) Summarize(ctx context.Context, prompt string) (string, error) {
body := map[string]interface{}{
"model": p.model,
"max_tokens": 4096,
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
}
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(b))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", p.apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("anthropic API error %d: %s", resp.StatusCode, raw)
}
var result struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return "", err
}
if len(result.Content) == 0 {
return "", nil
}
return result.Content[0].Text, nil
}
func (p *anthropicProvider) ListModels(_ context.Context) ([]string, error) {
return []string{
"claude-opus-4-7",
"claude-sonnet-4-6",
"claude-haiku-4-5-20251001",
}, nil
}

View File

@ -0,0 +1,84 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
type geminiProvider struct {
apiKey string
model string
client *http.Client
}
func newGemini(apiKey, model string) *geminiProvider {
if model == "" {
model = "gemini-2.0-flash"
}
return &geminiProvider{
apiKey: apiKey,
model: model,
client: &http.Client{},
}
}
func (p *geminiProvider) Name() string { return "gemini" }
func (p *geminiProvider) Summarize(ctx context.Context, prompt string) (string, error) {
url := fmt.Sprintf(
"https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s",
p.model, p.apiKey,
)
body := map[string]interface{}{
"contents": []map[string]interface{}{
{"parts": []map[string]string{{"text": prompt}}},
},
}
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("gemini API error %d: %s", resp.StatusCode, raw)
}
var result struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return "", err
}
if len(result.Candidates) == 0 || len(result.Candidates[0].Content.Parts) == 0 {
return "", nil
}
return result.Candidates[0].Content.Parts[0].Text, nil
}
func (p *geminiProvider) ListModels(_ context.Context) ([]string, error) {
return []string{
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
"gemini-1.5-pro",
"gemini-1.5-flash",
}, nil
}

View File

@ -0,0 +1,95 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
type ollamaProvider struct {
endpoint string
model string
client *http.Client
}
func newOllama(endpoint, model string) *ollamaProvider {
if endpoint == "" {
endpoint = "http://ollama:11434"
}
if model == "" {
model = "llama3"
}
return &ollamaProvider{
endpoint: endpoint,
model: model,
client: &http.Client{},
}
}
func (p *ollamaProvider) Name() string { return "ollama" }
func (p *ollamaProvider) Summarize(ctx context.Context, prompt string) (string, error) {
body := map[string]interface{}{
"model": p.model,
"prompt": prompt,
"stream": false,
"options": map[string]interface{}{
"num_ctx": 32768,
},
}
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint+"/api/generate", bytes.NewReader(b))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("ollama API error %d: %s", resp.StatusCode, raw)
}
var result struct {
Response string `json:"response"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return "", err
}
return result.Response, nil
}
func (p *ollamaProvider) ListModels(ctx context.Context) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.endpoint+"/api/tags", nil)
if err != nil {
return nil, err
}
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var result struct {
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return nil, err
}
var models []string
for _, m := range result.Models {
models = append(models, m.Name)
}
return models, nil
}

View File

@ -0,0 +1,52 @@
package ai
import (
"context"
openai "github.com/sashabaranov/go-openai"
)
type openAIProvider struct {
client *openai.Client
model string
}
func newOpenAI(apiKey, model string) *openAIProvider {
if model == "" {
model = openai.GPT4oMini
}
return &openAIProvider{
client: openai.NewClient(apiKey),
model: model,
}
}
func (p *openAIProvider) Name() string { return "openai" }
func (p *openAIProvider) Summarize(ctx context.Context, prompt string) (string, error) {
resp, err := p.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Model: p.model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleUser, Content: prompt},
},
})
if err != nil {
return "", err
}
if len(resp.Choices) == 0 {
return "", nil
}
return resp.Choices[0].Message.Content, nil
}
func (p *openAIProvider) ListModels(ctx context.Context) ([]string, error) {
resp, err := p.client.ListModels(ctx)
if err != nil {
return nil, err
}
var models []string
for _, m := range resp.Models {
models = append(models, m.ID)
}
return models, nil
}

View File

@ -0,0 +1,160 @@
package ai
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/tradarr/backend/internal/crypto"
"github.com/tradarr/backend/internal/models"
)
const DefaultSystemPrompt = `Tu es un assistant spécialisé en trading financier. Analyse l'ensemble des actualités suivantes, toutes sources confondues, et crée un résumé global structuré en français, orienté trading.
Structure ton résumé ainsi :
1. **Vue macro** : tendances globales du marché (économie, géopolitique, secteurs)
2. **Actifs surveillés** : pour chaque actif de la watchlist mentionné dans les news :
- Sentiment (haussier/baissier/neutre)
- Faits clés et catalyseurs
- Risques et opportunités
3. **Autres mouvements notables** : actifs hors watchlist à surveiller
4. **Synthèse** : points d'attention prioritaires pour la journée`
type Pipeline struct {
repo *models.Repository
enc *crypto.Encryptor
}
func NewPipeline(repo *models.Repository, enc *crypto.Encryptor) *Pipeline {
return &Pipeline{repo: repo, enc: enc}
}
// BuildProvider instancie un provider à partir de ses paramètres
func (p *Pipeline) BuildProvider(name, apiKey, endpoint string) (Provider, error) {
provider, err := p.repo.GetActiveAIProvider()
if err != nil {
return nil, err
}
model := ""
if provider != nil {
model = provider.Model
}
return NewProvider(name, apiKey, model, endpoint)
}
// GenerateForUser génère un résumé personnalisé pour un utilisateur
func (p *Pipeline) GenerateForUser(ctx context.Context, userID string) (*models.Summary, error) {
// Récupérer le provider actif
providerCfg, err := p.repo.GetActiveAIProvider()
if err != nil {
return nil, fmt.Errorf("get active provider: %w", err)
}
if providerCfg == nil {
return nil, fmt.Errorf("no active AI provider configured")
}
apiKey := ""
if providerCfg.APIKeyEncrypted != "" {
apiKey, err = p.enc.Decrypt(providerCfg.APIKeyEncrypted)
if err != nil {
return nil, fmt.Errorf("decrypt API key: %w", err)
}
}
provider, err := NewProvider(providerCfg.Name, apiKey, providerCfg.Model, providerCfg.Endpoint)
if err != nil {
return nil, fmt.Errorf("build provider: %w", err)
}
// Récupérer la watchlist de l'utilisateur (pour le contexte IA uniquement)
assets, err := p.repo.GetUserAssets(userID)
if err != nil {
return nil, fmt.Errorf("get user assets: %w", err)
}
symbols := make([]string, len(assets))
for i, a := range assets {
symbols[i] = a.Symbol
}
// Récupérer TOUS les articles récents, toutes sources confondues
hoursStr, _ := p.repo.GetSetting("articles_lookback_hours")
hours, _ := strconv.Atoi(hoursStr)
if hours == 0 {
hours = 24
}
articles, err := p.repo.GetRecentArticles(hours)
if err != nil {
return nil, fmt.Errorf("get articles: %w", err)
}
if len(articles) == 0 {
return nil, fmt.Errorf("no recent articles found")
}
maxStr, _ := p.repo.GetSetting("summary_max_articles")
maxArticles, _ := strconv.Atoi(maxStr)
if maxArticles == 0 {
maxArticles = 50
}
if len(articles) > maxArticles {
articles = articles[:maxArticles]
}
systemPrompt, _ := p.repo.GetSetting("ai_system_prompt")
if systemPrompt == "" {
systemPrompt = DefaultSystemPrompt
}
prompt := buildPrompt(systemPrompt, symbols, articles)
summary, err := provider.Summarize(ctx, prompt)
if err != nil {
return nil, fmt.Errorf("AI summarize: %w", err)
}
return p.repo.CreateSummary(userID, summary, &providerCfg.ID)
}
// GenerateForAll génère les résumés pour tous les utilisateurs ayant une watchlist
func (p *Pipeline) GenerateForAll(ctx context.Context) error {
users, err := p.repo.ListUsers()
if err != nil {
return err
}
for _, user := range users {
if _, err := p.GenerateForUser(ctx, user.ID); err != nil {
fmt.Printf("summary for user %s: %v\n", user.Email, err)
}
}
return nil
}
func buildPrompt(systemPrompt string, symbols []string, articles []models.Article) string {
var sb strings.Builder
sb.WriteString(systemPrompt)
sb.WriteString("\n\n")
if len(symbols) > 0 {
sb.WriteString("Le trader surveille particulièrement ces actifs (sois attentif à toute mention) : ")
sb.WriteString(strings.Join(symbols, ", "))
sb.WriteString(".\n\n")
}
sb.WriteString(fmt.Sprintf("Date d'analyse : %s\n\n", time.Now().Format("02/01/2006 15:04")))
sb.WriteString("## Actualités\n\n")
for i, a := range articles {
sb.WriteString(fmt.Sprintf("### [%d] %s\n", i+1, a.Title))
sb.WriteString(fmt.Sprintf("Source : %s\n", a.SourceName))
if a.PublishedAt.Valid {
sb.WriteString(fmt.Sprintf("Date : %s\n", a.PublishedAt.Time.Format("02/01/2006 15:04")))
}
content := a.Content
if len(content) > 1000 {
content = content[:1000] + "..."
}
sb.WriteString(content)
sb.WriteString("\n\n")
}
return sb.String()
}

View File

@ -0,0 +1,27 @@
package ai
import (
"context"
"fmt"
)
type Provider interface {
Name() string
Summarize(ctx context.Context, prompt string) (string, error)
ListModels(ctx context.Context) ([]string, error)
}
func NewProvider(name, apiKey, model, endpoint string) (Provider, error) {
switch name {
case "openai":
return newOpenAI(apiKey, model), nil
case "anthropic":
return newAnthropic(apiKey, model), nil
case "gemini":
return newGemini(apiKey, model), nil
case "ollama":
return newOllama(endpoint, model), nil
default:
return nil, fmt.Errorf("unknown provider: %s", name)
}
}

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)
}

View File

@ -0,0 +1,73 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/tradarr/backend/internal/api/handlers"
"github.com/tradarr/backend/internal/auth"
)
func SetupRouter(h *handlers.Handler, jwtSecret string) *gin.Engine {
r := gin.Default()
r.Use(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
c.Header("Access-Control-Allow-Headers", "Authorization,Content-Type")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
api := r.Group("/api")
// Auth public
api.POST("/auth/login", h.Login)
api.POST("/auth/register", h.Register)
// Routes authentifiées
authed := api.Group("/")
authed.Use(auth.Middleware(jwtSecret))
authed.GET("/me", h.GetMe)
authed.GET("/me/assets", h.GetMyAssets)
authed.POST("/me/assets", h.AddMyAsset)
authed.DELETE("/me/assets/:symbol", h.RemoveMyAsset)
authed.GET("/articles", h.ListArticles)
authed.GET("/articles/:id", h.GetArticle)
authed.GET("/summaries", h.ListSummaries)
authed.POST("/summaries/generate", h.GenerateSummary)
// Admin
admin := authed.Group("/admin")
admin.Use(auth.AdminOnly())
admin.GET("/credentials", h.GetCredentials)
admin.PUT("/credentials", h.UpsertCredentials)
admin.GET("/ai-providers", h.ListAIProviders)
admin.POST("/ai-providers", h.CreateAIProvider)
admin.PUT("/ai-providers/:id", h.UpdateAIProvider)
admin.POST("/ai-providers/:id/activate", h.SetActiveAIProvider)
admin.DELETE("/ai-providers/:id", h.DeleteAIProvider)
admin.GET("/ai-providers/:id/models", h.ListAIModels)
admin.GET("/sources", h.ListSources)
admin.PUT("/sources/:id", h.UpdateSource)
admin.GET("/scrape-jobs", h.ListScrapeJobs)
admin.POST("/scrape-jobs/trigger", h.TriggerScrapeJob)
admin.GET("/settings", h.ListSettings)
admin.PUT("/settings", h.UpdateSettings)
admin.GET("/settings/default-prompt", h.GetDefaultSystemPrompt)
admin.GET("/users", h.ListUsers)
admin.PUT("/users/:id", h.UpdateAdminUser)
admin.DELETE("/users/:id", h.DeleteAdminUser)
return r
}

View File

@ -0,0 +1,46 @@
package auth
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func GenerateToken(userID, email, role, secret string) (string, error) {
claims := Claims{
UserID: userID,
Email: email,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func ValidateToken(tokenStr, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}

View File

@ -0,0 +1,37 @@
package auth
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func Middleware(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
claims, err := ValidateToken(strings.TrimPrefix(header, "Bearer "), secret)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set("userID", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Next()
}
}
func AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
if c.GetString("role") != "admin" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
c.Next()
}
}

View File

@ -0,0 +1,12 @@
package auth
import "golang.org/x/crypto/bcrypt"
func HashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(b), err
}
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

View File

@ -0,0 +1,53 @@
package config
import (
"encoding/hex"
"fmt"
"os"
)
type Config struct {
DatabaseURL string
JWTSecret string
EncryptionKey []byte
Port string
ChromePath string
AdminEmail string
AdminPassword string
}
func Load() (*Config, error) {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
return nil, fmt.Errorf("JWT_SECRET is required")
}
encHex := os.Getenv("ENCRYPTION_KEY")
if encHex == "" {
return nil, fmt.Errorf("ENCRYPTION_KEY is required")
}
encKey, err := hex.DecodeString(encHex)
if err != nil || len(encKey) != 32 {
return nil, fmt.Errorf("ENCRYPTION_KEY must be a valid 32-byte hex string")
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
return &Config{
DatabaseURL: dbURL,
JWTSecret: jwtSecret,
EncryptionKey: encKey,
Port: port,
ChromePath: os.Getenv("CHROME_PATH"),
AdminEmail: os.Getenv("ADMIN_EMAIL"),
AdminPassword: os.Getenv("ADMIN_PASSWORD"),
}, nil
}

View File

@ -0,0 +1,59 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
)
type Encryptor struct {
key []byte
}
func New(key []byte) *Encryptor {
return &Encryptor{key: key}
}
func (e *Encryptor) Encrypt(plaintext string) (string, error) {
block, err := aes.NewCipher(e.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func (e *Encryptor) Decrypt(encoded string) (string, error) {
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
block, err := aes.NewCipher(e.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(data) < gcm.NonceSize() {
return "", fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plain), nil
}

View File

@ -0,0 +1,43 @@
package database
import (
"database/sql"
"fmt"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
_ "github.com/lib/pq"
)
func Connect(databaseURL string) (*sql.DB, error) {
db, err := sql.Open("postgres", databaseURL)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping db: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
return db, nil
}
func RunMigrations(db *sql.DB) error {
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
return fmt.Errorf("migration driver: %w", err)
}
m, err := migrate.NewWithDatabaseInstance(
"file://internal/database/migrations",
"postgres",
driver,
)
if err != nil {
return fmt.Errorf("migrate init: %w", err)
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("migrate up: %w", err)
}
return nil
}

View File

@ -0,0 +1,10 @@
DROP TABLE IF EXISTS settings CASCADE;
DROP TABLE IF EXISTS summaries CASCADE;
DROP TABLE IF EXISTS ai_providers CASCADE;
DROP TABLE IF EXISTS scrape_jobs CASCADE;
DROP TABLE IF EXISTS scrape_credentials CASCADE;
DROP TABLE IF EXISTS article_symbols CASCADE;
DROP TABLE IF EXISTS articles CASCADE;
DROP TABLE IF EXISTS sources CASCADE;
DROP TABLE IF EXISTS user_assets CASCADE;
DROP TABLE IF EXISTS users CASCADE;

View File

@ -0,0 +1,106 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE user_assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
symbol VARCHAR(20) NOT NULL,
name TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, symbol)
);
CREATE TABLE sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('bloomberg', 'stocktwits')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_id UUID NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL UNIQUE,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE article_symbols (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
symbol VARCHAR(20) NOT NULL,
UNIQUE (article_id, symbol)
);
CREATE TABLE scrape_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_id UUID NOT NULL REFERENCES sources(id) ON DELETE CASCADE UNIQUE,
username TEXT NOT NULL DEFAULT '',
password_encrypted TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE scrape_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_id UUID NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'done', 'error')),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
articles_found INT NOT NULL DEFAULT 0,
error_msg TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE ai_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL CHECK (name IN ('openai', 'anthropic', 'gemini', 'ollama')),
api_key_encrypted TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
endpoint TEXT NOT NULL DEFAULT '',
is_active BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE summaries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
ai_provider_id UUID REFERENCES ai_providers(id) ON DELETE SET NULL,
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
-- Index pour les performances
CREATE INDEX idx_articles_source_id ON articles(source_id);
CREATE INDEX idx_articles_published_at ON articles(published_at DESC);
CREATE INDEX idx_article_symbols_symbol ON article_symbols(symbol);
CREATE INDEX idx_summaries_user_id ON summaries(user_id);
CREATE INDEX idx_summaries_generated_at ON summaries(generated_at DESC);
CREATE INDEX idx_scrape_jobs_status ON scrape_jobs(status);
CREATE INDEX idx_user_assets_user_id ON user_assets(user_id);
-- Sources initiales
INSERT INTO sources (name, type, enabled) VALUES
('Bloomberg', 'bloomberg', TRUE),
('StockTwits', 'stocktwits', TRUE);
-- Paramètres par défaut
INSERT INTO settings (key, value) VALUES
('scrape_interval_minutes', '60'),
('articles_lookback_hours', '24'),
('summary_max_articles', '50');

View File

@ -0,0 +1 @@
DELETE FROM settings WHERE key = 'ai_system_prompt';

View File

@ -0,0 +1,13 @@
INSERT INTO settings (key, value) VALUES (
'ai_system_prompt',
'Tu es un assistant spécialisé en trading financier. Analyse l''ensemble des actualités suivantes, toutes sources confondues, et crée un résumé global structuré en français, orienté trading.
Structure ton résumé ainsi :
1. **Vue macro** : tendances globales du marché (économie, géopolitique, secteurs)
2. **Actifs surveillés** : pour chaque actif de la watchlist mentionné dans les news :
- Sentiment (haussier/baissier/neutre)
- Faits clés et catalyseurs
- Risques et opportunités
3. **Autres mouvements notables** : actifs hors watchlist à surveiller
4. **Synthèse** : points d''attention prioritaires pour la journée'
) ON CONFLICT (key) DO NOTHING;

View File

@ -0,0 +1,48 @@
package httputil
import (
"net/http"
"reflect"
"github.com/gin-gonic/gin"
)
// nilToEmpty converts nil slices to empty slices so JSON serializes as [] not null
func nilToEmpty(data interface{}) interface{} {
if data == nil {
return data
}
v := reflect.ValueOf(data)
if v.Kind() == reflect.Slice && v.IsNil() {
return reflect.MakeSlice(v.Type(), 0, 0).Interface()
}
return data
}
func OK(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, gin.H{"data": nilToEmpty(data)})
}
func Created(c *gin.Context, data interface{}) {
c.JSON(http.StatusCreated, gin.H{"data": nilToEmpty(data)})
}
func NoContent(c *gin.Context) {
c.Status(http.StatusNoContent)
}
func BadRequest(c *gin.Context, err error) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
func Unauthorized(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
}
func NotFound(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
}
func InternalError(c *gin.Context, err error) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}

View File

@ -0,0 +1,99 @@
package models
import (
"database/sql"
"time"
)
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
type User struct {
ID string `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"`
Role Role `json:"role"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type UserAsset struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Symbol string `json:"symbol"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
type Source struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
}
type Article struct {
ID string `json:"id"`
SourceID string `json:"source_id"`
SourceName string `json:"source_name,omitempty"`
Title string `json:"title"`
Content string `json:"content"`
URL string `json:"url"`
PublishedAt sql.NullTime `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
Symbols []string `json:"symbols,omitempty"`
}
type ArticleSymbol struct {
ID string `json:"id"`
ArticleID string `json:"article_id"`
Symbol string `json:"symbol"`
}
type ScrapeCredential struct {
ID string `json:"id"`
SourceID string `json:"source_id"`
Username string `json:"username"`
PasswordEncrypted string `json:"-"`
UpdatedAt time.Time `json:"updated_at"`
}
type ScrapeJob struct {
ID string `json:"id"`
SourceID string `json:"source_id"`
SourceName string `json:"source_name,omitempty"`
Status string `json:"status"`
StartedAt sql.NullTime `json:"started_at"`
FinishedAt sql.NullTime `json:"finished_at"`
ArticlesFound int `json:"articles_found"`
ErrorMsg string `json:"error_msg"`
CreatedAt time.Time `json:"created_at"`
}
type AIProvider struct {
ID string `json:"id"`
Name string `json:"name"`
APIKeyEncrypted string `json:"-"`
Model string `json:"model"`
Endpoint string `json:"endpoint"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
}
type Summary struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Content string `json:"content"`
AIProviderID *string `json:"ai_provider_id"`
GeneratedAt time.Time `json:"generated_at"`
}
type Setting struct {
Key string `json:"key"`
Value string `json:"value"`
}

View File

@ -0,0 +1,538 @@
package models
import (
"database/sql"
"fmt"
"strings"
"time"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
// ── Users ──────────────────────────────────────────────────────────────────
func (r *Repository) CreateUser(email, passwordHash string, role Role) (*User, error) {
u := &User{}
err := r.db.QueryRow(`
INSERT INTO users (email, password_hash, role)
VALUES ($1, $2, $3)
RETURNING id, email, password_hash, role, created_at, updated_at`,
email, passwordHash, role,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, &u.UpdatedAt)
return u, err
}
func (r *Repository) GetUserByEmail(email string) (*User, error) {
u := &User{}
err := r.db.QueryRow(`
SELECT id, email, password_hash, role, created_at, updated_at
FROM users WHERE email = $1`, email,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, &u.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return u, err
}
func (r *Repository) GetUserByID(id string) (*User, error) {
u := &User{}
err := r.db.QueryRow(`
SELECT id, email, password_hash, role, created_at, updated_at
FROM users WHERE id = $1`, id,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, &u.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return u, err
}
func (r *Repository) ListUsers() ([]User, error) {
rows, err := r.db.Query(`
SELECT id, email, password_hash, role, created_at, updated_at
FROM users ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, err
}
users = append(users, u)
}
return users, nil
}
func (r *Repository) UpdateUser(id, email string, role Role) (*User, error) {
u := &User{}
err := r.db.QueryRow(`
UPDATE users SET email=$1, role=$2, updated_at=NOW()
WHERE id=$3
RETURNING id, email, password_hash, role, created_at, updated_at`,
email, role, id,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, &u.UpdatedAt)
return u, err
}
func (r *Repository) DeleteUser(id string) error {
_, err := r.db.Exec(`DELETE FROM users WHERE id=$1`, id)
return err
}
func (r *Repository) CountAdmins() (int, error) {
var count int
err := r.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role='admin'`).Scan(&count)
return count, err
}
// ── User Assets ────────────────────────────────────────────────────────────
func (r *Repository) GetUserAssets(userID string) ([]UserAsset, error) {
rows, err := r.db.Query(`
SELECT id, user_id, symbol, name, created_at
FROM user_assets WHERE user_id=$1 ORDER BY symbol`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var assets []UserAsset
for rows.Next() {
var a UserAsset
if err := rows.Scan(&a.ID, &a.UserID, &a.Symbol, &a.Name, &a.CreatedAt); err != nil {
return nil, err
}
assets = append(assets, a)
}
return assets, nil
}
func (r *Repository) AddUserAsset(userID, symbol, name string) (*UserAsset, error) {
a := &UserAsset{}
err := r.db.QueryRow(`
INSERT INTO user_assets (user_id, symbol, name)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, symbol) DO UPDATE SET name=EXCLUDED.name
RETURNING id, user_id, symbol, name, created_at`,
userID, strings.ToUpper(symbol), name,
).Scan(&a.ID, &a.UserID, &a.Symbol, &a.Name, &a.CreatedAt)
return a, err
}
func (r *Repository) RemoveUserAsset(userID, symbol string) error {
_, err := r.db.Exec(`
DELETE FROM user_assets WHERE user_id=$1 AND symbol=$2`,
userID, strings.ToUpper(symbol))
return err
}
func (r *Repository) GetAllWatchedSymbols() ([]string, error) {
rows, err := r.db.Query(`SELECT DISTINCT symbol FROM user_assets ORDER BY symbol`)
if err != nil {
return nil, err
}
defer rows.Close()
var symbols []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
symbols = append(symbols, s)
}
return symbols, nil
}
// ── Sources ────────────────────────────────────────────────────────────────
func (r *Repository) ListSources() ([]Source, error) {
rows, err := r.db.Query(`SELECT id, name, type, enabled, created_at FROM sources ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var sources []Source
for rows.Next() {
var s Source
if err := rows.Scan(&s.ID, &s.Name, &s.Type, &s.Enabled, &s.CreatedAt); err != nil {
return nil, err
}
sources = append(sources, s)
}
return sources, nil
}
func (r *Repository) GetSourceByType(sourceType string) (*Source, error) {
s := &Source{}
err := r.db.QueryRow(`
SELECT id, name, type, enabled, created_at FROM sources WHERE type=$1`, sourceType,
).Scan(&s.ID, &s.Name, &s.Type, &s.Enabled, &s.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return s, err
}
func (r *Repository) UpdateSource(id string, enabled bool) error {
_, err := r.db.Exec(`UPDATE sources SET enabled=$1 WHERE id=$2`, enabled, id)
return err
}
// ── Articles ───────────────────────────────────────────────────────────────
func (r *Repository) UpsertArticle(sourceID, title, content, url string, publishedAt *time.Time) (*Article, error) {
a := &Article{}
var pa sql.NullTime
if publishedAt != nil {
pa = sql.NullTime{Time: *publishedAt, Valid: true}
}
err := r.db.QueryRow(`
INSERT INTO articles (source_id, title, content, url, published_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (url) DO UPDATE SET title=EXCLUDED.title, content=EXCLUDED.content
RETURNING id, source_id, title, content, url, published_at, created_at`,
sourceID, title, content, url, pa,
).Scan(&a.ID, &a.SourceID, &a.Title, &a.Content, &a.URL, &a.PublishedAt, &a.CreatedAt)
return a, err
}
func (r *Repository) AddArticleSymbol(articleID, symbol string) error {
_, err := r.db.Exec(`
INSERT INTO article_symbols (article_id, symbol)
VALUES ($1, $2) ON CONFLICT DO NOTHING`,
articleID, strings.ToUpper(symbol))
return err
}
func (r *Repository) ListArticles(symbol string, limit, offset int) ([]Article, error) {
query := `
SELECT a.id, a.source_id, s.name, a.title, a.content, a.url, a.published_at, a.created_at
FROM articles a
JOIN sources s ON s.id = a.source_id`
args := []interface{}{}
if symbol != "" {
query += `
JOIN article_symbols asy ON asy.article_id = a.id AND asy.symbol = $1`
args = append(args, strings.ToUpper(symbol))
}
query += ` ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC`
query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset)
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var articles []Article
for rows.Next() {
var a Article
if err := rows.Scan(&a.ID, &a.SourceID, &a.SourceName, &a.Title, &a.Content, &a.URL, &a.PublishedAt, &a.CreatedAt); err != nil {
return nil, err
}
articles = append(articles, a)
}
return articles, nil
}
func (r *Repository) GetArticleByID(id string) (*Article, error) {
a := &Article{}
err := r.db.QueryRow(`
SELECT a.id, a.source_id, s.name, a.title, a.content, a.url, a.published_at, a.created_at
FROM articles a JOIN sources s ON s.id=a.source_id
WHERE a.id=$1`, id,
).Scan(&a.ID, &a.SourceID, &a.SourceName, &a.Title, &a.Content, &a.URL, &a.PublishedAt, &a.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return a, err
}
func (r *Repository) GetRecentArticles(hours int) ([]Article, error) {
rows, err := r.db.Query(`
SELECT a.id, a.source_id, s.name, a.title, a.content, a.url, a.published_at, a.created_at
FROM articles a
JOIN sources s ON s.id = a.source_id
WHERE a.created_at > NOW() - ($1 * INTERVAL '1 hour')
ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC`, hours)
if err != nil {
return nil, err
}
defer rows.Close()
var articles []Article
for rows.Next() {
var a Article
if err := rows.Scan(&a.ID, &a.SourceID, &a.SourceName, &a.Title, &a.Content, &a.URL, &a.PublishedAt, &a.CreatedAt); err != nil {
return nil, err
}
articles = append(articles, a)
}
return articles, nil
}
func (r *Repository) GetRecentArticlesForSymbols(symbols []string, hours int) ([]Article, error) {
if len(symbols) == 0 {
return nil, nil
}
placeholders := make([]string, len(symbols))
args := []interface{}{hours}
for i, s := range symbols {
placeholders[i] = fmt.Sprintf("$%d", i+2)
args = append(args, strings.ToUpper(s))
}
query := fmt.Sprintf(`
SELECT DISTINCT a.id, a.source_id, s.name, a.title, a.content, a.url, a.published_at, a.created_at
FROM articles a
JOIN sources s ON s.id = a.source_id
JOIN article_symbols asy ON asy.article_id = a.id
WHERE asy.symbol IN (%s)
AND a.created_at > NOW() - ($1 * INTERVAL '1 hour')
ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC`,
strings.Join(placeholders, ","))
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var articles []Article
for rows.Next() {
var a Article
if err := rows.Scan(&a.ID, &a.SourceID, &a.SourceName, &a.Title, &a.Content, &a.URL, &a.PublishedAt, &a.CreatedAt); err != nil {
return nil, err
}
articles = append(articles, a)
}
return articles, nil
}
// ── Scrape Credentials ─────────────────────────────────────────────────────
func (r *Repository) GetCredentials(sourceID string) (*ScrapeCredential, error) {
c := &ScrapeCredential{}
err := r.db.QueryRow(`
SELECT id, source_id, username, password_encrypted, updated_at
FROM scrape_credentials WHERE source_id=$1`, sourceID,
).Scan(&c.ID, &c.SourceID, &c.Username, &c.PasswordEncrypted, &c.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return c, err
}
func (r *Repository) UpsertCredentials(sourceID, username, passwordEncrypted string) error {
_, err := r.db.Exec(`
INSERT INTO scrape_credentials (source_id, username, password_encrypted)
VALUES ($1, $2, $3)
ON CONFLICT (source_id) DO UPDATE
SET username=EXCLUDED.username, password_encrypted=EXCLUDED.password_encrypted, updated_at=NOW()`,
sourceID, username, passwordEncrypted)
return err
}
// ── Scrape Jobs ────────────────────────────────────────────────────────────
func (r *Repository) CreateScrapeJob(sourceID string) (*ScrapeJob, error) {
j := &ScrapeJob{}
err := r.db.QueryRow(`
INSERT INTO scrape_jobs (source_id) VALUES ($1)
RETURNING id, source_id, status, started_at, finished_at, articles_found, error_msg, created_at`,
sourceID,
).Scan(&j.ID, &j.SourceID, &j.Status, &j.StartedAt, &j.FinishedAt, &j.ArticlesFound, &j.ErrorMsg, &j.CreatedAt)
return j, err
}
func (r *Repository) UpdateScrapeJob(id, status string, articlesFound int, errMsg string) error {
var finishedAt *time.Time
if status == "done" || status == "error" {
now := time.Now()
finishedAt = &now
}
_, err := r.db.Exec(`
UPDATE scrape_jobs
SET status=$1, articles_found=$2, error_msg=$3, finished_at=$4,
started_at=CASE WHEN status='pending' THEN NOW() ELSE started_at END
WHERE id=$5`,
status, articlesFound, errMsg, finishedAt, id)
return err
}
func (r *Repository) ListScrapeJobs(limit int) ([]ScrapeJob, error) {
rows, err := r.db.Query(`
SELECT j.id, j.source_id, s.name, j.status, j.started_at, j.finished_at,
j.articles_found, j.error_msg, j.created_at
FROM scrape_jobs j JOIN sources s ON s.id=j.source_id
ORDER BY j.created_at DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var jobs []ScrapeJob
for rows.Next() {
var j ScrapeJob
if err := rows.Scan(&j.ID, &j.SourceID, &j.SourceName, &j.Status, &j.StartedAt,
&j.FinishedAt, &j.ArticlesFound, &j.ErrorMsg, &j.CreatedAt); err != nil {
return nil, err
}
jobs = append(jobs, j)
}
return jobs, nil
}
// ── AI Providers ───────────────────────────────────────────────────────────
func (r *Repository) ListAIProviders() ([]AIProvider, error) {
rows, err := r.db.Query(`
SELECT id, name, api_key_encrypted, model, endpoint, is_active, created_at
FROM ai_providers ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
var providers []AIProvider
for rows.Next() {
var p AIProvider
if err := rows.Scan(&p.ID, &p.Name, &p.APIKeyEncrypted, &p.Model, &p.Endpoint, &p.IsActive, &p.CreatedAt); err != nil {
return nil, err
}
providers = append(providers, p)
}
return providers, nil
}
func (r *Repository) GetAIProviderByID(id string) (*AIProvider, error) {
p := &AIProvider{}
err := r.db.QueryRow(`
SELECT id, name, api_key_encrypted, model, endpoint, is_active, created_at
FROM ai_providers WHERE id=$1`, id,
).Scan(&p.ID, &p.Name, &p.APIKeyEncrypted, &p.Model, &p.Endpoint, &p.IsActive, &p.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return p, err
}
func (r *Repository) GetActiveAIProvider() (*AIProvider, error) {
p := &AIProvider{}
err := r.db.QueryRow(`
SELECT id, name, api_key_encrypted, model, endpoint, is_active, created_at
FROM ai_providers WHERE is_active=TRUE LIMIT 1`,
).Scan(&p.ID, &p.Name, &p.APIKeyEncrypted, &p.Model, &p.Endpoint, &p.IsActive, &p.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return p, err
}
func (r *Repository) CreateAIProvider(name, apiKeyEncrypted, model, endpoint string) (*AIProvider, error) {
p := &AIProvider{}
err := r.db.QueryRow(`
INSERT INTO ai_providers (name, api_key_encrypted, model, endpoint)
VALUES ($1, $2, $3, $4)
RETURNING id, name, api_key_encrypted, model, endpoint, is_active, created_at`,
name, apiKeyEncrypted, model, endpoint,
).Scan(&p.ID, &p.Name, &p.APIKeyEncrypted, &p.Model, &p.Endpoint, &p.IsActive, &p.CreatedAt)
return p, err
}
func (r *Repository) UpdateAIProvider(id, apiKeyEncrypted, model, endpoint string) error {
_, err := r.db.Exec(`
UPDATE ai_providers SET api_key_encrypted=$1, model=$2, endpoint=$3 WHERE id=$4`,
apiKeyEncrypted, model, endpoint, id)
return err
}
func (r *Repository) SetActiveAIProvider(id string) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE ai_providers SET is_active=FALSE`); err != nil {
return err
}
if _, err := tx.Exec(`UPDATE ai_providers SET is_active=TRUE WHERE id=$1`, id); err != nil {
return err
}
return tx.Commit()
}
func (r *Repository) DeleteAIProvider(id string) error {
_, err := r.db.Exec(`DELETE FROM ai_providers WHERE id=$1`, id)
return err
}
// ── Summaries ──────────────────────────────────────────────────────────────
func (r *Repository) CreateSummary(userID, content string, providerID *string) (*Summary, error) {
s := &Summary{}
err := r.db.QueryRow(`
INSERT INTO summaries (user_id, content, ai_provider_id)
VALUES ($1, $2, $3)
RETURNING id, user_id, content, ai_provider_id, generated_at`,
userID, content, providerID,
).Scan(&s.ID, &s.UserID, &s.Content, &s.AIProviderID, &s.GeneratedAt)
return s, err
}
func (r *Repository) ListSummaries(userID string, limit int) ([]Summary, error) {
rows, err := r.db.Query(`
SELECT id, user_id, content, ai_provider_id, generated_at
FROM summaries WHERE user_id=$1
ORDER BY generated_at DESC LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var summaries []Summary
for rows.Next() {
var s Summary
if err := rows.Scan(&s.ID, &s.UserID, &s.Content, &s.AIProviderID, &s.GeneratedAt); err != nil {
return nil, err
}
summaries = append(summaries, s)
}
return summaries, nil
}
// ── Settings ───────────────────────────────────────────────────────────────
func (r *Repository) GetSetting(key string) (string, error) {
var value string
err := r.db.QueryRow(`SELECT value FROM settings WHERE key=$1`, key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, err
}
func (r *Repository) SetSetting(key, value string) error {
_, err := r.db.Exec(`
INSERT INTO settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value`,
key, value)
return err
}
func (r *Repository) ListSettings() ([]Setting, error) {
rows, err := r.db.Query(`SELECT key, value FROM settings ORDER BY key`)
if err != nil {
return nil, err
}
defer rows.Close()
var settings []Setting
for rows.Next() {
var s Setting
if err := rows.Scan(&s.Key, &s.Value); err != nil {
return nil, err
}
settings = append(settings, s)
}
return settings, nil
}

View File

@ -0,0 +1,88 @@
package scheduler
import (
"context"
"fmt"
"strconv"
"github.com/robfig/cron/v3"
"github.com/tradarr/backend/internal/ai"
"github.com/tradarr/backend/internal/models"
"github.com/tradarr/backend/internal/scraper"
)
type Scheduler struct {
cron *cron.Cron
registry *scraper.Registry
pipeline *ai.Pipeline
repo *models.Repository
entryID cron.EntryID
}
func New(registry *scraper.Registry, pipeline *ai.Pipeline, repo *models.Repository) *Scheduler {
return &Scheduler{
cron: cron.New(),
registry: registry,
pipeline: pipeline,
repo: repo,
}
}
func (s *Scheduler) Start() error {
interval, err := s.getInterval()
if err != nil {
return err
}
spec := fmt.Sprintf("@every %dm", interval)
s.entryID, err = s.cron.AddFunc(spec, s.run)
if err != nil {
return fmt.Errorf("add cron: %w", err)
}
s.cron.Start()
fmt.Printf("scheduler started, running every %d minutes\n", interval)
return nil
}
func (s *Scheduler) Stop() {
s.cron.Stop()
}
func (s *Scheduler) Reload() error {
s.cron.Remove(s.entryID)
interval, err := s.getInterval()
if err != nil {
return err
}
spec := fmt.Sprintf("@every %dm", interval)
s.entryID, err = s.cron.AddFunc(spec, s.run)
return err
}
func (s *Scheduler) run() {
fmt.Println("scheduler: running scraping cycle")
if err := s.registry.RunAll(); err != nil {
fmt.Printf("scheduler scrape error: %v\n", err)
return
}
fmt.Println("scheduler: running AI summaries")
if err := s.pipeline.GenerateForAll(context.Background()); err != nil {
fmt.Printf("scheduler summary error: %v\n", err)
}
}
func (s *Scheduler) getInterval() (int, error) {
v, err := s.repo.GetSetting("scrape_interval_minutes")
if err != nil {
return 60, nil
}
if v == "" {
return 60, nil
}
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
return 60, nil
}
return n, nil
}

View File

@ -0,0 +1,206 @@
package bloomberg
import (
"context"
"fmt"
"strings"
"time"
"github.com/chromedp/chromedp"
"github.com/tradarr/backend/internal/scraper"
)
type Bloomberg struct {
username string
password string
chromePath string
}
func New(username, password, chromePath string) *Bloomberg {
return &Bloomberg{username: username, password: password, chromePath: chromePath}
}
func (b *Bloomberg) Name() string { return "bloomberg" }
func (b *Bloomberg) Scrape(ctx context.Context, symbols []string) ([]scraper.Article, error) {
if b.username == "" || b.password == "" {
return nil, fmt.Errorf("bloomberg credentials not configured")
}
opts := []chromedp.ExecAllocatorOption{
chromedp.NoFirstRun,
chromedp.NoDefaultBrowserCheck,
chromedp.Headless,
chromedp.DisableGPU,
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-setuid-sandbox", true),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.Flag("disable-blink-features", "AutomationControlled"),
chromedp.Flag("disable-infobars", true),
chromedp.Flag("window-size", "1920,1080"),
chromedp.Flag("ignore-certificate-errors", true),
chromedp.UserAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"),
}
if b.chromePath != "" {
opts = append(opts, chromedp.ExecPath(b.chromePath))
}
allocCtx, cancelAlloc := chromedp.NewExecAllocator(ctx, opts...)
defer cancelAlloc()
chromeCtx, cancelChrome := chromedp.NewContext(allocCtx)
defer cancelChrome()
timeoutCtx, cancelTimeout := context.WithTimeout(chromeCtx, 5*time.Minute)
defer cancelTimeout()
if err := b.login(timeoutCtx); err != nil {
return nil, fmt.Errorf("bloomberg login: %w", err)
}
var articles []scraper.Article
pages := []string{
"https://www.bloomberg.com/markets",
"https://www.bloomberg.com/technology",
"https://www.bloomberg.com/economics",
}
for _, u := range pages {
pageArticles, err := b.scrapePage(timeoutCtx, u, symbols)
if err != nil {
fmt.Printf("bloomberg scrape %s: %v\n", u, err)
continue
}
articles = append(articles, pageArticles...)
}
fmt.Printf("bloomberg: %d articles fetched total\n", len(articles))
return articles, nil
}
func (b *Bloomberg) login(ctx context.Context) error {
loginCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
// Masquer la détection d'automation via JS
if err := chromedp.Run(loginCtx,
chromedp.ActionFunc(func(ctx context.Context) error {
return chromedp.Evaluate(`
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
window.chrome = { runtime: {} };
`, nil).Do(ctx)
}),
); err != nil {
fmt.Printf("bloomberg: could not inject stealth JS: %v\n", err)
}
err := chromedp.Run(loginCtx,
chromedp.Navigate("https://www.bloomberg.com/account/signin"),
chromedp.Sleep(2*time.Second),
// Essayer plusieurs sélecteurs pour l'email
chromedp.ActionFunc(func(ctx context.Context) error {
selectors := []string{
`input[name="email"]`,
`input[type="email"]`,
`input[data-type="email"]`,
`input[placeholder*="email" i]`,
`input[placeholder*="mail" i]`,
}
for _, sel := range selectors {
var count int
if err := chromedp.Evaluate(fmt.Sprintf(`document.querySelectorAll('%s').length`, sel), &count).Do(ctx); err == nil && count > 0 {
fmt.Printf("bloomberg: using email selector: %s\n", sel)
return chromedp.SendKeys(sel, b.username, chromedp.ByQuery).Do(ctx)
}
}
return fmt.Errorf("could not find email input — Bloomberg login page structure may have changed")
}),
chromedp.Sleep(500*time.Millisecond),
// Submit email
chromedp.ActionFunc(func(ctx context.Context) error {
selectors := []string{`button[type="submit"]`, `input[type="submit"]`, `button[data-testid*="submit"]`}
for _, sel := range selectors {
var count int
if err := chromedp.Evaluate(fmt.Sprintf(`document.querySelectorAll('%s').length`, sel), &count).Do(ctx); err == nil && count > 0 {
return chromedp.Click(sel, chromedp.ByQuery).Do(ctx)
}
}
// Fallback: press Enter
return chromedp.KeyEvent("\r").Do(ctx)
}),
chromedp.Sleep(2*time.Second),
// Password
chromedp.ActionFunc(func(ctx context.Context) error {
selectors := []string{`input[type="password"]`, `input[name="password"]`}
for _, sel := range selectors {
var count int
if err := chromedp.Evaluate(fmt.Sprintf(`document.querySelectorAll('%s').length`, sel), &count).Do(ctx); err == nil && count > 0 {
fmt.Printf("bloomberg: using password selector: %s\n", sel)
return chromedp.SendKeys(sel, b.password, chromedp.ByQuery).Do(ctx)
}
}
return fmt.Errorf("could not find password input")
}),
chromedp.Sleep(500*time.Millisecond),
chromedp.ActionFunc(func(ctx context.Context) error {
selectors := []string{`button[type="submit"]`, `input[type="submit"]`}
for _, sel := range selectors {
var count int
if err := chromedp.Evaluate(fmt.Sprintf(`document.querySelectorAll('%s').length`, sel), &count).Do(ctx); err == nil && count > 0 {
return chromedp.Click(sel, chromedp.ByQuery).Do(ctx)
}
}
return chromedp.KeyEvent("\r").Do(ctx)
}),
chromedp.Sleep(3*time.Second),
)
return err
}
func (b *Bloomberg) scrapePage(ctx context.Context, pageURL string, symbols []string) ([]scraper.Article, error) {
pageCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
var articleNodes []map[string]string
err := chromedp.Run(pageCtx,
chromedp.Navigate(pageURL),
chromedp.Sleep(3*time.Second),
chromedp.Evaluate(`
(function() {
var items = [];
var seen = new Set();
var links = document.querySelectorAll('a[href*="/news/articles"], a[href*="/opinion/"], a[href*="/markets/"]');
links.forEach(function(a) {
if (seen.has(a.href)) return;
seen.add(a.href);
var title = a.querySelector('h1,h2,h3,h4,[class*="headline"],[class*="title"]');
var text = title ? title.innerText.trim() : a.innerText.trim();
if (text.length > 20 && a.href.includes('bloomberg.com')) {
items.push({title: text, url: a.href});
}
});
return items.slice(0, 25);
})()
`, &articleNodes),
)
if err != nil {
return nil, fmt.Errorf("navigate %s: %w", pageURL, err)
}
var articles []scraper.Article
now := time.Now()
for _, node := range articleNodes {
title := strings.TrimSpace(node["title"])
url := node["url"]
if title == "" || url == "" || !strings.Contains(url, "bloomberg.com") {
continue
}
syms := scraper.DetectSymbols(title, symbols)
articles = append(articles, scraper.Article{
Title: title,
Content: title, // contenu minimal — l'article complet nécessite un accès payant
URL: url,
PublishedAt: &now,
Symbols: syms,
})
}
return articles, nil
}

View File

@ -0,0 +1,50 @@
package bloomberg
import (
"context"
"fmt"
"github.com/tradarr/backend/internal/crypto"
"github.com/tradarr/backend/internal/models"
"github.com/tradarr/backend/internal/scraper"
)
// DynamicBloomberg charge les credentials depuis la DB avant chaque scraping
type DynamicBloomberg struct {
repo *models.Repository
enc *crypto.Encryptor
chromePath string
}
func NewDynamic(repo *models.Repository, enc *crypto.Encryptor, chromePath string) *DynamicBloomberg {
return &DynamicBloomberg{repo: repo, enc: enc, chromePath: chromePath}
}
func (d *DynamicBloomberg) Name() string { return "bloomberg" }
func (d *DynamicBloomberg) Scrape(ctx context.Context, symbols []string) ([]scraper.Article, error) {
// Récupérer la source Bloomberg
source, err := d.repo.GetSourceByType("bloomberg")
if err != nil || source == nil {
return nil, fmt.Errorf("bloomberg source not found")
}
cred, err := d.repo.GetCredentials(source.ID)
if err != nil {
return nil, fmt.Errorf("get bloomberg credentials: %w", err)
}
if cred == nil || cred.Username == "" {
return nil, fmt.Errorf("bloomberg credentials not configured — please set them in the admin panel")
}
password := ""
if cred.PasswordEncrypted != "" {
password, err = d.enc.Decrypt(cred.PasswordEncrypted)
if err != nil {
return nil, fmt.Errorf("decrypt bloomberg password: %w", err)
}
}
b := New(cred.Username, password, d.chromePath)
return b.Scrape(ctx, symbols)
}

View File

@ -0,0 +1,106 @@
package scraper
import (
"context"
"fmt"
"time"
"github.com/tradarr/backend/internal/models"
)
type Registry struct {
scrapers map[string]Scraper
repo *models.Repository
}
func NewRegistry(repo *models.Repository) *Registry {
return &Registry{
scrapers: map[string]Scraper{},
repo: repo,
}
}
func (r *Registry) Register(s Scraper) {
r.scrapers[s.Name()] = s
}
// Run exécute le scraper associé à sourceID et persiste les articles
func (r *Registry) Run(sourceID string) error {
sources, err := r.repo.ListSources()
if err != nil {
return err
}
var source *models.Source
for i := range sources {
if sources[i].ID == sourceID {
source = &sources[i]
break
}
}
if source == nil {
return fmt.Errorf("source %s not found", sourceID)
}
scrpr, ok := r.scrapers[source.Type]
if !ok {
return fmt.Errorf("no scraper for type %s", source.Type)
}
// Créer le job
job, err := r.repo.CreateScrapeJob(sourceID)
if err != nil {
return err
}
if err := r.repo.UpdateScrapeJob(job.ID, "running", 0, ""); err != nil {
return err
}
// Récupérer les symboles surveillés
symbols, err := r.repo.GetAllWatchedSymbols()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
articles, scrapeErr := scrpr.Scrape(ctx, symbols)
if scrapeErr != nil {
_ = r.repo.UpdateScrapeJob(job.ID, "error", 0, scrapeErr.Error())
return scrapeErr
}
// Persister les articles
count := 0
for _, a := range articles {
saved, err := r.repo.UpsertArticle(sourceID, a.Title, a.Content, a.URL, a.PublishedAt)
if err != nil {
continue
}
count++
for _, sym := range a.Symbols {
_ = r.repo.AddArticleSymbol(saved.ID, sym)
}
}
return r.repo.UpdateScrapeJob(job.ID, "done", count, "")
}
// RunAll exécute tous les scrapers activés
func (r *Registry) RunAll() error {
sources, err := r.repo.ListSources()
if err != nil {
return err
}
for _, src := range sources {
if !src.Enabled {
continue
}
if err := r.Run(src.ID); err != nil {
fmt.Printf("scraper %s error: %v\n", src.Name, err)
}
}
return nil
}

View File

@ -0,0 +1,75 @@
package scraper
import (
"context"
"time"
"github.com/tradarr/backend/internal/models"
)
type Article struct {
Title string
Content string
URL string
PublishedAt *time.Time
Symbols []string
}
type Scraper interface {
Name() string
Scrape(ctx context.Context, symbols []string) ([]Article, error)
}
// detectSymbols extrait les symboles mentionnés dans un texte
func DetectSymbols(text string, watchlist []string) []string {
found := map[string]bool{}
for _, s := range watchlist {
// Recherche du symbole en majuscules dans le texte
if containsWord(text, s) {
found[s] = true
}
}
result := make([]string, 0, len(found))
for s := range found {
result = append(result, s)
}
return result
}
func containsWord(text, word string) bool {
upper := []byte(text)
w := []byte(word)
for i := 0; i <= len(upper)-len(w); i++ {
match := true
for j := range w {
c := upper[i+j]
if c >= 'a' && c <= 'z' {
c -= 32
}
if c != w[j] {
match = false
break
}
}
if match {
// Vérifier que c'est un mot entier
before := i == 0 || !isAlphaNum(upper[i-1])
after := i+len(w) >= len(upper) || !isAlphaNum(upper[i+len(w)])
if before && after {
return true
}
}
}
return false
}
func isAlphaNum(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
}
// ScraperResult est le résultat d'un job de scraping
type ScraperResult struct {
Source *models.Source
Articles []Article
Err error
}

View File

@ -0,0 +1,128 @@
package stocktwits
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/tradarr/backend/internal/scraper"
)
const apiBase = "https://api.stocktwits.com/api/2"
type StockTwits struct {
client *http.Client
}
func New() *StockTwits {
return &StockTwits{
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (s *StockTwits) Name() string { return "stocktwits" }
type apiResponse struct {
Response struct {
Status int `json:"status"`
Error string `json:"error,omitempty"`
} `json:"response"`
Messages []struct {
ID int `json:"id"`
Body string `json:"body"`
CreatedAt string `json:"created_at"`
User struct {
Username string `json:"username"`
} `json:"user"`
Entities struct {
Sentiment *struct {
Basic string `json:"basic"`
} `json:"sentiment"`
} `json:"entities"`
} `json:"messages"`
}
func (s *StockTwits) Scrape(ctx context.Context, symbols []string) ([]scraper.Article, error) {
var articles []scraper.Article
for i, symbol := range symbols {
// Délai entre les requêtes pour éviter le rate limiting
if i > 0 {
select {
case <-ctx.Done():
return articles, ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
msgs, err := s.fetchSymbol(ctx, symbol)
if err != nil {
fmt.Printf("stocktwits %s: %v\n", symbol, err)
continue
}
articles = append(articles, msgs...)
}
return articles, nil
}
func (s *StockTwits) fetchSymbol(ctx context.Context, symbol string) ([]scraper.Article, error) {
url := fmt.Sprintf("%s/streams/symbol/%s.json", apiBase, symbol)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("rate limited by StockTwits for %s", symbol)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("StockTwits returned HTTP %d for %s: %s", resp.StatusCode, symbol, string(body))
}
var data apiResponse
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("parse response for %s: %w", symbol, err)
}
// L'API StockTwits retourne un status dans le body même en HTTP 200
if data.Response.Status != 0 && data.Response.Status != 200 {
return nil, fmt.Errorf("StockTwits API error %d for %s: %s", data.Response.Status, symbol, data.Response.Error)
}
var articles []scraper.Article
for _, msg := range data.Messages {
if msg.Body == "" {
continue
}
sentiment := ""
if msg.Entities.Sentiment != nil {
sentiment = " [" + msg.Entities.Sentiment.Basic + "]"
}
title := fmt.Sprintf("$%s — @%s%s", symbol, msg.User.Username, sentiment)
publishedAt, _ := time.Parse(time.RFC3339, msg.CreatedAt)
msgURL := fmt.Sprintf("https://stocktwits.com/%s/message/%d", msg.User.Username, msg.ID)
articles = append(articles, scraper.Article{
Title: title,
Content: msg.Body,
URL: msgURL,
PublishedAt: &publishedAt,
Symbols: []string{symbol},
})
}
fmt.Printf("stocktwits %s: %d messages fetched\n", symbol, len(articles))
return articles, nil
}

View File

@ -0,0 +1,126 @@
package yahoofinance
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/tradarr/backend/internal/scraper"
)
type YahooFinance struct {
client *http.Client
}
func New() *YahooFinance {
return &YahooFinance{
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (y *YahooFinance) Name() string { return "stocktwits" } // garde le même type en DB
type rssFeed struct {
Channel struct {
Items []struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
PubDate string `xml:"pubDate"`
GUID string `xml:"guid"`
} `xml:"item"`
} `xml:"channel"`
}
func (y *YahooFinance) Scrape(ctx context.Context, symbols []string) ([]scraper.Article, error) {
var articles []scraper.Article
for i, symbol := range symbols {
if i > 0 {
select {
case <-ctx.Done():
return articles, ctx.Err()
case <-time.After(300 * time.Millisecond):
}
}
items, err := y.fetchSymbol(ctx, symbol)
if err != nil {
fmt.Printf("yahoofinance %s: %v\n", symbol, err)
continue
}
articles = append(articles, items...)
fmt.Printf("yahoofinance %s: %d articles fetched\n", symbol, len(items))
}
return articles, nil
}
func (y *YahooFinance) fetchSymbol(ctx context.Context, symbol string) ([]scraper.Article, error) {
url := fmt.Sprintf(
"https://feeds.finance.yahoo.com/rss/2.0/headline?s=%s&region=US&lang=en-US",
symbol,
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Tradarr/1.0)")
req.Header.Set("Accept", "application/rss+xml, application/xml, text/xml")
resp, err := y.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var feed rssFeed
if err := xml.NewDecoder(resp.Body).Decode(&feed); err != nil {
return nil, fmt.Errorf("parse RSS: %w", err)
}
var articles []scraper.Article
for _, item := range feed.Channel.Items {
title := strings.TrimSpace(item.Title)
link := strings.TrimSpace(item.Link)
if title == "" || link == "" {
continue
}
var publishedAt *time.Time
if item.PubDate != "" {
formats := []string{
time.RFC1123Z,
time.RFC1123,
"Mon, 02 Jan 2006 15:04:05 -0700",
}
for _, f := range formats {
if t, err := time.Parse(f, item.PubDate); err == nil {
publishedAt = &t
break
}
}
}
content := strings.TrimSpace(item.Description)
if content == "" {
content = title
}
articles = append(articles, scraper.Article{
Title: title,
Content: content,
URL: link,
PublishedAt: publishedAt,
Symbols: []string{symbol},
})
}
return articles, nil
}

53
docker-compose.yml Normal file
View File

@ -0,0 +1,53 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-tradarr}
POSTGRES_USER: ${POSTGRES_USER:-tradarr}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tradarr}"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: "host=postgres port=5432 user=${POSTGRES_USER:-tradarr} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB:-tradarr} sslmode=disable"
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY must be 32 bytes hex}
PORT: "8080"
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@tradarr.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme}
expose:
- "8080"
ollama:
image: ollama/ollama
restart: unless-stopped
volumes:
- ollama_data:/root/.ollama
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- backend
ports:
- "${FRONTEND_PORT:-80}:80"
volumes:
pgdata:
ollama_data:

16
frontend/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

15
frontend/index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f172a" />
<meta name="description" content="Agrégateur de news financières avec résumés IA" />
<title>Tradarr</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

43
frontend/nginx.conf Normal file
View File

@ -0,0 +1,43 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
# Résolveur DNS Docker — résolution à la requête, pas au démarrage
resolver 127.0.0.11 valid=10s ipv6=off;
# Proxy API vers backend
location /api/ {
set $backend http://backend:8080;
proxy_pass $backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
proxy_connect_timeout 10s;
proxy_send_timeout 3600s;
}
# Service Worker — ne pas mettre en cache
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}
# Assets statiques avec cache long
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# SPA — renvoyer index.html pour toutes les routes React
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}

9997
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
frontend/package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "tradarr",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.475.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.5",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.19.0",
"postcss": "^8.5.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^6.1.0",
"vite-plugin-pwa": "^0.21.1"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/>
<polyline points="16 7 22 7 22 13"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

52
frontend/src/api/admin.ts Normal file
View File

@ -0,0 +1,52 @@
import { api } from './client'
export interface AIProvider {
id: string; name: string; model: string; endpoint: string
is_active: boolean; has_key: boolean
}
export interface Source { id: string; name: string; type: string; enabled: boolean }
export interface ScrapeJob {
id: string; source_id: string; source_name: string; status: string
started_at: { Time: string; Valid: boolean } | null
finished_at: { Time: string; Valid: boolean } | null
articles_found: number; error_msg: string; created_at: string
}
export interface Setting { key: string; value: string }
export interface AdminUser { id: string; email: string; role: string; created_at: string }
export interface Credential { source_id: string; source_name: string; username: string; has_password: boolean }
export const adminApi = {
// Credentials
getCredentials: () => api.get<Credential[]>('/admin/credentials'),
updateCredentials: (data: { source_id: string; username: string; password?: string }) =>
api.put<void>('/admin/credentials', data),
// AI Providers
listProviders: () => api.get<AIProvider[]>('/admin/ai-providers'),
createProvider: (data: { name: string; api_key?: string; model?: string; endpoint?: string }) =>
api.post<AIProvider>('/admin/ai-providers', data),
updateProvider: (id: string, data: { name: string; api_key?: string; model?: string; endpoint?: string }) =>
api.put<void>(`/admin/ai-providers/${id}`, data),
activateProvider: (id: string) => api.post<void>(`/admin/ai-providers/${id}/activate`),
deleteProvider: (id: string) => api.delete<void>(`/admin/ai-providers/${id}`),
listModels: (id: string) => api.get<string[]>(`/admin/ai-providers/${id}/models`),
// Sources
listSources: () => api.get<Source[]>('/admin/sources'),
updateSource: (id: string, enabled: boolean) => api.put<void>(`/admin/sources/${id}`, { enabled }),
// Jobs
listJobs: () => api.get<ScrapeJob[]>('/admin/scrape-jobs'),
triggerJob: (source_id: string) => api.post<void>('/admin/scrape-jobs/trigger', { source_id }),
// Settings
listSettings: () => api.get<Setting[]>('/admin/settings'),
updateSettings: (settings: Setting[]) => api.put<void>('/admin/settings', { settings }),
getDefaultPrompt: () => api.get<{ prompt: string }>('/admin/settings/default-prompt'),
// Users
listUsers: () => api.get<AdminUser[]>('/admin/users'),
updateUser: (id: string, email: string, role: string) =>
api.put<AdminUser>(`/admin/users/${id}`, { email, role }),
deleteUser: (id: string) => api.delete<void>(`/admin/users/${id}`),
}

View File

@ -0,0 +1,25 @@
import { api } from './client'
export interface Article {
id: string
source_id: string
source_name: string
title: string
content: string
url: string
published_at: { Time: string; Valid: boolean } | null
created_at: string
symbols?: string[]
}
export const articlesApi = {
list: (params?: { symbol?: string; limit?: number; offset?: number }) => {
const q = new URLSearchParams()
if (params?.symbol) q.set('symbol', params.symbol)
if (params?.limit) q.set('limit', String(params.limit))
if (params?.offset) q.set('offset', String(params.offset))
const qs = q.toString()
return api.get<Article[]>(`/articles${qs ? `?${qs}` : ''}`)
},
get: (id: string) => api.get<Article>(`/articles/${id}`),
}

View File

@ -0,0 +1,9 @@
import { api } from './client'
export interface Asset { id: string; user_id: string; symbol: string; name: string; created_at: string }
export const assetsApi = {
list: () => api.get<Asset[]>('/me/assets'),
add: (symbol: string, name: string) => api.post<Asset>('/me/assets', { symbol, name }),
remove: (symbol: string) => api.delete<void>(`/me/assets/${symbol}`),
}

12
frontend/src/api/auth.ts Normal file
View File

@ -0,0 +1,12 @@
import { api } from './client'
export interface User { id: string; email: string; role: 'admin' | 'user' }
interface AuthResponse { token: string; user: User }
export const authApi = {
login: (email: string, password: string) =>
api.post<AuthResponse>('/auth/login', { email, password }),
register: (email: string, password: string) =>
api.post<AuthResponse>('/auth/register', { email, password }),
me: () => api.get<User>('/me'),
}

View File

@ -0,0 +1,33 @@
const BASE = '/api'
function getToken() {
return localStorage.getItem('token')
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken()
const res = await fetch(`${BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || res.statusText)
}
if (res.status === 204) return undefined as T
const json = await res.json()
return json.data !== undefined ? json.data : json
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
}

View File

@ -0,0 +1,14 @@
import { api } from './client'
export interface Summary {
id: string
user_id: string
content: string
ai_provider_id: string | null
generated_at: string
}
export const summariesApi = {
list: (limit = 10) => api.get<Summary[]>(`/summaries?limit=${limit}`),
generate: () => api.post<Summary>('/summaries/generate'),
}

View File

@ -0,0 +1,26 @@
import { Outlet, Navigate } from 'react-router-dom'
import { useAuth } from '@/lib/auth'
import { Sidebar } from './Sidebar'
import { MobileNav } from './MobileNav'
export function AppLayout() {
const { token } = useAuth()
if (!token) return <Navigate to="/login" replace />
return (
<div className="flex h-screen overflow-hidden">
{/* Sidebar desktop */}
<div className="hidden md:flex">
<Sidebar />
</div>
{/* Main content */}
<main className="flex-1 overflow-y-auto pb-16 md:pb-0">
<Outlet />
</main>
{/* Bottom nav mobile */}
<MobileNav />
</div>
)
}

View File

@ -0,0 +1,33 @@
import { NavLink } from 'react-router-dom'
import { LayoutDashboard, Newspaper, Star, Settings } from 'lucide-react'
import { cn } from '@/lib/cn'
const items = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/feed', icon: Newspaper, label: 'Actus' },
{ to: '/watchlist', icon: Star, label: 'Watchlist' },
{ to: '/admin', icon: Settings, label: 'Admin' },
]
export function MobileNav() {
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 flex border-t bg-card md:hidden">
{items.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
cn(
'flex flex-1 flex-col items-center gap-1 py-3 text-xs transition-colors',
isActive ? 'text-primary' : 'text-muted-foreground'
)
}
>
<Icon className="h-5 w-5" />
{label}
</NavLink>
))}
</nav>
)
}

View File

@ -0,0 +1,85 @@
import { NavLink } from 'react-router-dom'
import { LayoutDashboard, Newspaper, Star, Settings, Key, Cpu, Database, ClipboardList, Users, LogOut, TrendingUp } from 'lucide-react'
import { useAuth } from '@/lib/auth'
import { cn } from '@/lib/cn'
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/feed', icon: Newspaper, label: 'Actualités' },
{ to: '/watchlist', icon: Star, label: 'Watchlist' },
]
const adminItems = [
{ to: '/admin/ai', icon: Cpu, label: 'Fournisseurs IA' },
{ to: '/admin/credentials', icon: Key, label: 'Identifiants' },
{ to: '/admin/sources', icon: Database, label: 'Sources' },
{ to: '/admin/jobs', icon: ClipboardList, label: 'Jobs' },
{ to: '/admin/users', icon: Users, label: 'Utilisateurs' },
{ to: '/admin/settings', icon: Settings, label: 'Paramètres' },
]
function NavItem({ to, icon: Icon, label }: { to: string; icon: React.ElementType; label: string }) {
return (
<NavLink
to={to}
end={to === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)
}
>
<Icon className="h-4 w-4" />
{label}
</NavLink>
)
}
export function Sidebar() {
const { user, logout, isAdmin } = useAuth()
return (
<aside className="flex h-screen w-60 flex-col border-r bg-card px-3 py-4">
{/* Logo */}
<div className="mb-6 flex items-center gap-2 px-3">
<TrendingUp className="h-6 w-6 text-primary" />
<span className="text-lg font-bold">Tradarr</span>
</div>
{/* Navigation principale */}
<nav className="flex flex-col gap-1">
{navItems.map(item => <NavItem key={item.to} {...item} />)}
</nav>
{/* Section admin */}
{isAdmin && (
<>
<div className="mt-6 mb-2 px-3">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Administration</p>
</div>
<nav className="flex flex-col gap-1">
{adminItems.map(item => <NavItem key={item.to} {...item} />)}
</nav>
</>
)}
{/* User + logout */}
<div className="mt-auto border-t pt-4">
<div className="px-3 mb-2">
<p className="text-sm font-medium truncate">{user?.email}</p>
<p className="text-xs text-muted-foreground capitalize">{user?.role}</p>
</div>
<button
onClick={logout}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
<LogOut className="h-4 w-4" />
Déconnexion
</button>
</div>
</aside>
)
}

View File

@ -0,0 +1,26 @@
import { type HTMLAttributes } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/cn'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-destructive-foreground',
outline: 'text-foreground',
bullish: 'border-transparent bg-bullish/20 text-bullish',
bearish: 'border-transparent bg-bearish/20 text-bearish',
},
},
defaultVariants: { variant: 'default' },
}
)
export interface BadgeProps extends HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}

View File

@ -0,0 +1,35 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/cn'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: { variant: 'default', size: 'default' },
}
)
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
)
Button.displayName = 'Button'

View File

@ -0,0 +1,37 @@
import { forwardRef, type HTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)} {...props} />
)
)
Card.displayName = 'Card'
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
export const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
)
)
CardTitle.displayName = 'CardTitle'
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'

View File

@ -0,0 +1,17 @@
import { forwardRef, type InputHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {}
export const Input = forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
))
Input.displayName = 'Input'

View File

@ -0,0 +1,13 @@
import { forwardRef, type LabelHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
export const Label = forwardRef<HTMLLabelElement, LabelHTMLAttributes<HTMLLabelElement>>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...props}
/>
)
)
Label.displayName = 'Label'

View File

@ -0,0 +1,22 @@
import { forwardRef, type SelectHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
import { ChevronDown } from 'lucide-react'
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(({ className, children, ...props }, ref) => (
<div className="relative">
<select
ref={ref}
className={cn(
'flex h-9 w-full appearance-none rounded-md border border-input bg-transparent px-3 py-1 pr-8 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
{children}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div>
))
Select.displayName = 'Select'

View File

@ -0,0 +1,7 @@
import { cn } from '@/lib/cn'
export function Spinner({ className }: { className?: string }) {
return (
<div className={cn('h-5 w-5 animate-spin rounded-full border-2 border-current border-t-transparent', className)} />
)
}

39
frontend/src/index.css Normal file
View File

@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 222 47% 6%;
--foreground: 210 40% 95%;
--card: 222 47% 9%;
--card-foreground: 210 40% 95%;
--border: 217 33% 18%;
--input: 217 33% 18%;
--ring: 221 83% 53%;
--primary: 221 83% 53%;
--primary-foreground: 210 40% 98%;
--secondary: 217 33% 18%;
--secondary-foreground: 210 40% 95%;
--muted: 217 33% 14%;
--muted-foreground: 215 20% 55%;
--accent: 217 33% 18%;
--accent-foreground: 210 40% 95%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--radius: 0.5rem;
}
}
@layer base {
* { @apply border-border; }
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
/* Scrollbar style */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { @apply bg-muted; }
::-webkit-scrollbar-thumb { @apply bg-border rounded-full; }

51
frontend/src/lib/auth.tsx Normal file
View File

@ -0,0 +1,51 @@
import { createContext, useContext, useState, type ReactNode } from 'react'
interface User {
id: string
email: string
role: 'admin' | 'user'
}
interface AuthCtx {
user: User | null
token: string | null
login: (token: string, user: User) => void
logout: () => void
isAdmin: boolean
}
const AuthContext = createContext<AuthCtx | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() => localStorage.getItem('token'))
const [user, setUser] = useState<User | null>(() => {
const u = localStorage.getItem('user')
return u ? JSON.parse(u) : null
})
function login(t: string, u: User) {
localStorage.setItem('token', t)
localStorage.setItem('user', JSON.stringify(u))
setToken(t)
setUser(u)
}
function logout() {
localStorage.removeItem('token')
localStorage.removeItem('user')
setToken(null)
setUser(null)
}
return (
<AuthContext.Provider value={{ user, token, login, logout, isAdmin: user?.role === 'admin' }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}

6
frontend/src/lib/cn.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,38 @@
import { createBrowserRouter } from 'react-router-dom'
import { AppLayout } from '@/components/layout/AppLayout'
import { Login } from '@/pages/Login'
import { Dashboard } from '@/pages/Dashboard'
import { Feed } from '@/pages/Feed'
import { Watchlist } from '@/pages/Watchlist'
import { AdminLayout } from '@/pages/admin/AdminLayout'
import { AIProviders } from '@/pages/admin/AIProviders'
import { Credentials } from '@/pages/admin/Credentials'
import { Sources } from '@/pages/admin/Sources'
import { Jobs } from '@/pages/admin/Jobs'
import { AdminUsers } from '@/pages/admin/AdminUsers'
import { AdminSettings } from '@/pages/admin/AdminSettings'
export const router = createBrowserRouter([
{ path: '/login', element: <Login /> },
{
element: <AppLayout />,
children: [
{ path: '/', element: <Dashboard /> },
{ path: '/feed', element: <Feed /> },
{ path: '/watchlist', element: <Watchlist /> },
{
path: '/admin',
element: <AdminLayout />,
children: [
{ index: true, element: <AIProviders /> },
{ path: 'ai', element: <AIProviders /> },
{ path: 'credentials', element: <Credentials /> },
{ path: 'sources', element: <Sources /> },
{ path: 'jobs', element: <Jobs /> },
{ path: 'users', element: <AdminUsers /> },
{ path: 'settings', element: <AdminSettings /> },
],
},
],
},
])

14
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { AuthProvider } from '@/lib/auth'
import { router } from '@/lib/router'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</StrictMode>
)

View File

@ -0,0 +1,146 @@
import { useState, useEffect } from 'react'
import { TrendingUp, Clock, Sparkles } from 'lucide-react'
import { summariesApi, type Summary } from '@/api/summaries'
import { assetsApi, type Asset } from '@/api/assets'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
import { useAuth } from '@/lib/auth'
function SummaryContent({ content }: { content: string }) {
const lines = content.split('\n')
return (
<div className="space-y-2 text-sm leading-relaxed">
{lines.map((line, i) => {
if (line.startsWith('## ')) return <h2 key={i} className="text-base font-semibold mt-4 first:mt-0">{line.slice(3)}</h2>
if (line.startsWith('### ')) return <h3 key={i} className="font-medium mt-3">{line.slice(4)}</h3>
if (line.startsWith('- ')) return <li key={i} className="ml-4 text-muted-foreground">{line.slice(2)}</li>
if (line.startsWith('**') && line.endsWith('**')) return <p key={i} className="font-semibold">{line.slice(2, -2)}</p>
if (line.trim() === '') return <div key={i} className="h-1" />
return <p key={i} className="text-muted-foreground">{line}</p>
})}
</div>
)
}
export function Dashboard() {
const { user } = useAuth()
const [summaries, setSummaries] = useState<Summary[]>([])
const [assets, setAssets] = useState<Asset[]>([])
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [current, setCurrent] = useState<Summary | null>(null)
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try {
const [s, a] = await Promise.all([summariesApi.list(5), assetsApi.list()])
setSummaries(s ?? [])
setAssets(a ?? [])
setCurrent(s?.[0] ?? null)
} finally { setLoading(false) }
}
async function generate() {
setGenerating(true)
try {
const s = await summariesApi.generate()
setSummaries(prev => [s, ...prev])
setCurrent(s)
} catch (e) {
alert(e instanceof Error ? e.message : 'Erreur lors de la génération')
} finally { setGenerating(false) }
}
return (
<div className="p-4 md:p-6 space-y-6">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Bonjour 👋</h1>
<p className="text-muted-foreground text-sm">{user?.email}</p>
</div>
<Button onClick={generate} disabled={generating || assets.length === 0}>
{generating ? <><Spinner className="h-4 w-4" /> Génération</> : <><Sparkles className="h-4 w-4" /> Générer un résumé</>}
</Button>
</div>
{assets.length === 0 && !loading && (
<Card className="border-dashed">
<CardContent className="py-8 text-center">
<TrendingUp className="h-10 w-10 mx-auto text-muted-foreground mb-3" />
<p className="font-medium">Votre watchlist est vide</p>
<p className="text-sm text-muted-foreground mt-1">Ajoutez des symboles dans la section Watchlist pour obtenir des résumés personnalisés</p>
</CardContent>
</Card>
)}
{assets.length > 0 && (
<div className="flex flex-wrap gap-2">
{assets.map(a => (
<Badge key={a.id} variant="secondary" className="font-mono">{a.symbol}</Badge>
))}
</div>
)}
{/* Résumé actuel */}
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-4">
{loading ? (
<div className="flex justify-center py-20"><Spinner /></div>
) : current ? (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" /> Résumé IA
</CardTitle>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{new Date(current.generated_at).toLocaleString('fr-FR')}
</div>
</div>
</CardHeader>
<CardContent>
<SummaryContent content={current.content} />
</CardContent>
</Card>
) : (
<Card className="border-dashed">
<CardContent className="py-12 text-center text-muted-foreground">
<Sparkles className="h-8 w-8 mx-auto mb-3 opacity-50" />
<p>Aucun résumé disponible</p>
<p className="text-xs mt-1">Cliquez sur "Générer un résumé" pour commencer</p>
</CardContent>
</Card>
)}
</div>
{/* Historique résumés */}
{summaries.length > 1 && (
<div className="space-y-3">
<h2 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">Historique</h2>
{summaries.slice(1).map(s => (
<Card
key={s.id}
className={`cursor-pointer transition-colors hover:border-primary/50 ${current?.id === s.id ? 'border-primary/50' : ''}`}
onClick={() => setCurrent(s)}
>
<CardContent className="py-3">
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<Clock className="h-3 w-3" />
{new Date(s.generated_at).toLocaleString('fr-FR')}
</div>
<p className="text-sm line-clamp-3">{s.content.slice(0, 120)}</p>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
)
}

116
frontend/src/pages/Feed.tsx Normal file
View File

@ -0,0 +1,116 @@
import { useState, useEffect } from 'react'
import { ExternalLink, RefreshCw, Search } from 'lucide-react'
import { articlesApi, type Article } from '@/api/articles'
import { assetsApi, type Asset } from '@/api/assets'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
function fmtDate(a: Article) {
const d = a.published_at?.Valid ? new Date(a.published_at.Time) : new Date(a.created_at)
const now = Date.now()
const diff = now - d.getTime()
if (diff < 3600000) return `Il y a ${Math.floor(diff / 60000)} min`
if (diff < 86400000) return `Il y a ${Math.floor(diff / 3600000)} h`
return d.toLocaleDateString('fr-FR')
}
export function Feed() {
const [articles, setArticles] = useState<Article[]>([])
const [assets, setAssets] = useState<Asset[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [filterSymbol, setFilterSymbol] = useState('')
const [offset, setOffset] = useState(0)
const limit = 30
useEffect(() => {
assetsApi.list().then(a => setAssets(a ?? []))
}, [])
useEffect(() => { load(0) }, [filterSymbol])
async function load(newOffset = 0) {
setLoading(true)
try {
const data = await articlesApi.list({ symbol: filterSymbol || undefined, limit, offset: newOffset })
if (newOffset === 0) setArticles(data ?? [])
else setArticles(prev => [...prev, ...(data ?? [])])
setOffset(newOffset + limit)
} finally { setLoading(false) }
}
const filtered = search
? articles.filter(a => a.title.toLowerCase().includes(search.toLowerCase()) || a.source_name?.toLowerCase().includes(search.toLowerCase()))
: articles
return (
<div className="p-4 md:p-6 space-y-4">
<div className="flex flex-wrap items-center gap-4">
<h1 className="text-2xl font-bold flex-1">Actualités</h1>
<Button variant="outline" size="icon" onClick={() => load(0)}><RefreshCw className="h-4 w-4" /></Button>
</div>
{/* Filtres */}
<div className="flex flex-wrap gap-3">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input className="pl-8" placeholder="Rechercher…" value={search} onChange={e => setSearch(e.target.value)} />
</div>
<Select value={filterSymbol} onChange={e => setFilterSymbol(e.target.value)} className="w-40">
<option value="">Tous les symboles</option>
{assets.map(a => <option key={a.id} value={a.symbol}>{a.symbol}</option>)}
</Select>
</div>
{loading && articles.length === 0 ? (
<div className="flex justify-center py-20"><Spinner /></div>
) : (
<>
<div className="space-y-3">
{filtered.map(a => (
<Card key={a.id} className="hover:border-border/80 transition-colors">
<CardContent className="py-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<Badge variant="outline" className="text-xs">{a.source_name}</Badge>
<span className="text-xs text-muted-foreground">{fmtDate(a)}</span>
</div>
<h3 className="font-medium leading-snug mb-2">{a.title}</h3>
{a.content && (
<p className="text-sm text-muted-foreground line-clamp-2">{a.content}</p>
)}
</div>
<a
href={a.url}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
</CardContent>
</Card>
))}
{filtered.length === 0 && (
<Card><CardContent className="py-12 text-center text-muted-foreground">Aucun article</CardContent></Card>
)}
</div>
{filtered.length >= limit && (
<div className="flex justify-center pt-2">
<Button variant="outline" onClick={() => load(offset)} disabled={loading}>
{loading ? <Spinner className="h-4 w-4" /> : 'Charger plus'}
</Button>
</div>
)}
</>
)}
</div>
)
}

View File

@ -0,0 +1,63 @@
import { useState, type FormEvent } from 'react'
import { Navigate } from 'react-router-dom'
import { TrendingUp } from 'lucide-react'
import { useAuth } from '@/lib/auth'
import { authApi } from '@/api/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export function Login() {
const { token, login } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
if (token) return <Navigate to="/" replace />
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
const { token: t, user } = await authApi.login(email, password)
login(t, user)
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur de connexion')
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<div className="mb-2 flex justify-center">
<TrendingUp className="h-10 w-10 text-primary" />
</div>
<CardTitle className="text-2xl">Tradarr</CardTitle>
<p className="text-sm text-muted-foreground">Votre assistant trading IA</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoComplete="email" />
</div>
<div className="space-y-1">
<Label htmlFor="password">Mot de passe</Label>
<Input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Connexion…' : 'Se connecter'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,117 @@
import { useState, useEffect, type FormEvent } from 'react'
import { Plus, Trash2, TrendingUp } from 'lucide-react'
import { assetsApi, type Asset } from '@/api/assets'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
export function Watchlist() {
const [assets, setAssets] = useState<Asset[]>([])
const [loading, setLoading] = useState(true)
const [symbol, setSymbol] = useState('')
const [name, setName] = useState('')
const [adding, setAdding] = useState(false)
const [removing, setRemoving] = useState<string | null>(null)
const [error, setError] = useState('')
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try { setAssets(await assetsApi.list() ?? []) } finally { setLoading(false) }
}
async function add(e: FormEvent) {
e.preventDefault()
if (!symbol) return
setAdding(true); setError('')
try {
await assetsApi.add(symbol.toUpperCase(), name)
setSymbol(''); setName('')
await load()
} catch (err) { setError(err instanceof Error ? err.message : 'Erreur') } finally { setAdding(false) }
}
async function remove(sym: string) {
setRemoving(sym)
await assetsApi.remove(sym)
await load()
setRemoving(null)
}
return (
<div className="p-4 md:p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Watchlist</h1>
<p className="text-muted-foreground text-sm">Les symboles suivis seront utilisés pour personnaliser vos résumés IA</p>
</div>
{/* Formulaire d'ajout */}
<Card>
<CardHeader><CardTitle className="text-base">Ajouter un actif</CardTitle></CardHeader>
<CardContent>
<form onSubmit={add} className="flex flex-wrap gap-3 items-end">
<div className="space-y-1 flex-1 min-w-32">
<Label>Symbole</Label>
<Input
placeholder="AAPL, TSLA, BTC…"
value={symbol}
onChange={e => setSymbol(e.target.value.toUpperCase())}
className="font-mono"
required
/>
</div>
<div className="space-y-1 flex-1 min-w-40">
<Label>Nom <span className="text-muted-foreground">(optionnel)</span></Label>
<Input
placeholder="Apple Inc."
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<Button type="submit" disabled={adding || !symbol}>
{adding ? <Spinner className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
Ajouter
</Button>
</form>
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</CardContent>
</Card>
{/* Liste */}
{loading ? (
<div className="flex justify-center py-12"><Spinner /></div>
) : assets.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-12 text-center">
<TrendingUp className="h-10 w-10 mx-auto text-muted-foreground mb-3 opacity-50" />
<p className="text-muted-foreground">Aucun actif dans votre watchlist</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{assets.map(a => (
<Card key={a.id} className="flex items-center justify-between p-4">
<div className="flex items-center gap-3 min-w-0">
<Badge variant="secondary" className="font-mono shrink-0">{a.symbol}</Badge>
{a.name && <span className="text-sm text-muted-foreground truncate">{a.name}</span>}
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => remove(a.symbol)}
disabled={removing === a.symbol}
>
{removing === a.symbol ? <Spinner className="h-3 w-3" /> : <Trash2 className="h-3 w-3" />}
</Button>
</Card>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,192 @@
import { useState, useEffect } from 'react'
import { Plus, Trash2, CheckCircle, RefreshCw } from 'lucide-react'
import { adminApi, type AIProvider } from '@/api/admin'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
const PROVIDER_NAMES = ['openai', 'anthropic', 'gemini', 'ollama'] as const
export function AIProviders() {
const [providers, setProviders] = useState<AIProvider[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ name: 'openai', api_key: '', model: '', endpoint: '' })
const isOllama = form.name === 'ollama'
const [models, setModels] = useState<Record<string, string[]>>({})
const [loadingModels, setLoadingModels] = useState<string | null>(null)
const [editId, setEditId] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try { setProviders((await adminApi.listProviders()) ?? []) } finally { setLoading(false) }
}
async function loadModels(id: string) {
setLoadingModels(id)
try {
const m = await adminApi.listModels(id)
setModels(prev => ({ ...prev, [id]: m }))
} catch { /* silently ignore */ } finally { setLoadingModels(null) }
}
async function save() {
setSaving(true); setError('')
try {
if (editId) {
await adminApi.updateProvider(editId, form)
} else {
await adminApi.createProvider(form)
}
setShowForm(false); setEditId(null)
setForm({ name: 'openai', api_key: '', model: '', endpoint: '' })
await load()
} catch (e) { setError(e instanceof Error ? e.message : 'Erreur') } finally { setSaving(false) }
}
async function activate(id: string) {
await adminApi.activateProvider(id)
await load()
}
async function remove(id: string) {
if (!confirm('Supprimer ce fournisseur ?')) return
await adminApi.deleteProvider(id)
await load()
}
function startEdit(p: AIProvider) {
setEditId(p.id)
setForm({ name: p.name, api_key: '', model: p.model, endpoint: p.endpoint })
setShowForm(true)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Fournisseurs IA</h1>
<p className="text-muted-foreground text-sm">Configurez les fournisseurs IA et sélectionnez le modèle actif</p>
</div>
<Button onClick={() => { setShowForm(true); setEditId(null) }}>
<Plus className="h-4 w-4" /> Ajouter
</Button>
</div>
{showForm && (
<Card>
<CardHeader><CardTitle>{editId ? 'Modifier' : 'Nouveau fournisseur'}</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<Label>Fournisseur</Label>
<Select
value={form.name}
onChange={e => {
const name = e.target.value
setForm(f => ({
...f,
name,
endpoint: name === 'ollama' ? 'http://ollama:11434' : '',
api_key: '',
}))
}}
disabled={!!editId}
>
{PROVIDER_NAMES.map(n => <option key={n} value={n}>{n}</option>)}
</Select>
</div>
{!isOllama && (
<div className="space-y-1">
<Label>Clé API {editId && <span className="text-muted-foreground">(laisser vide pour conserver)</span>}</Label>
<Input type="password" placeholder="sk-..." value={form.api_key} onChange={e => setForm(f => ({ ...f, api_key: e.target.value }))} />
</div>
)}
<div className="space-y-1">
<Label>Modèle</Label>
<Input placeholder="gpt-4o-mini, claude-sonnet-4-6…" value={form.model} onChange={e => setForm(f => ({ ...f, model: e.target.value }))} />
</div>
{isOllama && (
<div className="space-y-1">
<Label>Endpoint Ollama</Label>
<Input value="http://ollama:11434" readOnly className="opacity-60 cursor-not-allowed" />
</div>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex gap-2">
<Button onClick={save} disabled={saving}>{saving ? <Spinner className="h-4 w-4" /> : 'Enregistrer'}</Button>
<Button variant="outline" onClick={() => { setShowForm(false); setEditId(null) }}>Annuler</Button>
</div>
</CardContent>
</Card>
)}
{loading ? (
<div className="flex justify-center py-12"><Spinner /></div>
) : (
<div className="space-y-3">
{providers.map(p => (
<Card key={p.id} className={p.is_active ? 'border-primary/50' : ''}>
<CardContent className="flex flex-wrap items-center gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold capitalize">{p.name}</span>
{p.is_active && <Badge variant="default">Actif</Badge>}
{!p.has_key && p.name !== 'ollama' && <Badge variant="outline" className="text-yellow-500 border-yellow-500">Sans clé</Badge>}
</div>
<div className="text-sm text-muted-foreground mt-1 flex gap-4 flex-wrap">
{p.model && <span>Modèle : <strong>{p.model}</strong></span>}
{p.endpoint && <span>Endpoint : {p.endpoint}</span>}
</div>
{/* Dropdown modèles disponibles */}
{models[p.id] && models[p.id].length > 0 && (
<div className="mt-2 space-y-1">
<Label className="text-xs">Choisir un modèle :</Label>
<Select
className="w-full max-w-xs"
value={p.model}
onChange={async e => {
await adminApi.updateProvider(p.id, { name: p.name, model: e.target.value, endpoint: p.endpoint })
await load()
}}
>
{models[p.id].map(m => <option key={m} value={m}>{m}</option>)}
</Select>
</div>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={() => loadModels(p.id)} disabled={loadingModels === p.id}>
{loadingModels === p.id ? <Spinner className="h-3 w-3" /> : <RefreshCw className="h-3 w-3" />}
Modèles
</Button>
<Button variant="outline" size="sm" onClick={() => startEdit(p)}>Modifier</Button>
{!p.is_active && (
<Button size="sm" onClick={() => activate(p.id)}>
<CheckCircle className="h-3 w-3" /> Activer
</Button>
)}
<Button variant="destructive" size="sm" onClick={() => remove(p.id)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</CardContent>
</Card>
))}
{providers.length === 0 && (
<Card><CardContent className="py-8 text-center text-muted-foreground">Aucun fournisseur configuré</CardContent></Card>
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,12 @@
import { Outlet, Navigate } from 'react-router-dom'
import { useAuth } from '@/lib/auth'
export function AdminLayout() {
const { isAdmin } = useAuth()
if (!isAdmin) return <Navigate to="/" replace />
return (
<div className="p-6">
<Outlet />
</div>
)
}

View File

@ -0,0 +1,131 @@
import { useState, useEffect } from 'react'
import { Save, RotateCcw } from 'lucide-react'
import { adminApi, type Setting } from '@/api/admin'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Spinner } from '@/components/ui/spinner'
const NUMERIC_SETTINGS: Record<string, { label: string; description: string }> = {
scrape_interval_minutes: { label: 'Intervalle de scraping (minutes)', description: 'Fréquence de récupération des actualités' },
articles_lookback_hours: { label: 'Fenêtre d\'analyse (heures)', description: 'Période couverte pour les résumés IA' },
summary_max_articles: { label: 'Articles max par résumé', description: 'Nombre maximum d\'articles envoyés à l\'IA' },
}
export function AdminSettings() {
const [settings, setSettings] = useState<Setting[]>([])
const [loading, setLoading] = useState(true)
const [values, setValues] = useState<Record<string, string>>({})
const [defaultPrompt, setDefaultPrompt] = useState('')
const [saving, setSaving] = useState(false)
const [savingPrompt, setSavingPrompt] = useState(false)
const [saved, setSaved] = useState(false)
const [savedPrompt, setSavedPrompt] = useState(false)
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try {
const [s, d] = await Promise.all([
adminApi.listSettings(),
adminApi.getDefaultPrompt(),
])
const settled = s ?? []
setSettings(settled)
setDefaultPrompt(d?.prompt ?? '')
const v: Record<string, string> = {}
for (const setting of settled) v[setting.key] = setting.value
setValues(v)
} finally { setLoading(false) }
}
async function saveNumeric() {
setSaving(true); setSaved(false)
const numericKeys = Object.keys(NUMERIC_SETTINGS)
await adminApi.updateSettings(
settings
.filter(s => numericKeys.includes(s.key))
.map(s => ({ key: s.key, value: values[s.key] ?? s.value }))
)
setSaving(false); setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
async function savePrompt() {
setSavingPrompt(true); setSavedPrompt(false)
await adminApi.updateSettings([{ key: 'ai_system_prompt', value: values['ai_system_prompt'] ?? '' }])
setSavingPrompt(false); setSavedPrompt(true)
setTimeout(() => setSavedPrompt(false), 2000)
}
function resetPrompt() {
setValues(v => ({ ...v, ai_system_prompt: defaultPrompt }))
}
if (loading) return <div className="flex justify-center py-20"><Spinner /></div>
const currentPrompt = values['ai_system_prompt'] ?? defaultPrompt
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Paramètres</h1>
<p className="text-muted-foreground text-sm">Configuration globale du service</p>
</div>
<Card>
<CardHeader><CardTitle>Paramètres généraux</CardTitle></CardHeader>
<CardContent className="space-y-6">
{settings.filter(s => NUMERIC_SETTINGS[s.key]).map(s => {
const meta = NUMERIC_SETTINGS[s.key]
return (
<div key={s.key} className="space-y-1">
<Label>{meta.label}</Label>
<p className="text-xs text-muted-foreground">{meta.description}</p>
<Input
value={values[s.key] ?? ''}
onChange={e => setValues(v => ({ ...v, [s.key]: e.target.value }))}
className="max-w-xs"
type="number"
min="1"
/>
</div>
)
})}
<Button onClick={saveNumeric} disabled={saving}>
{saving ? <Spinner className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{saved ? 'Enregistré !' : 'Enregistrer'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Contexte IA</CardTitle>
<p className="text-sm text-muted-foreground">
Instructions envoyées à l'IA avant les articles. Les actifs de ta watchlist et les articles sont ajoutés automatiquement après ce texte.
</p>
</CardHeader>
<CardContent className="space-y-4">
<textarea
className="w-full min-h-[280px] rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-y focus:outline-none focus:ring-2 focus:ring-ring"
value={currentPrompt}
onChange={e => setValues(v => ({ ...v, ai_system_prompt: e.target.value }))}
spellCheck={false}
/>
<div className="flex gap-2">
<Button onClick={savePrompt} disabled={savingPrompt}>
{savingPrompt ? <Spinner className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{savedPrompt ? 'Enregistré !' : 'Enregistrer le contexte'}
</Button>
<Button variant="outline" onClick={resetPrompt} title="Remettre le contexte par défaut">
<RotateCcw className="h-4 w-4" /> Réinitialiser
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,113 @@
import { useState, useEffect } from 'react'
import { Trash2, Pencil } from 'lucide-react'
import { adminApi, type AdminUser } from '@/api/admin'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
import { useAuth } from '@/lib/auth'
export function AdminUsers() {
const { user: me } = useAuth()
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [editId, setEditId] = useState<string | null>(null)
const [editForm, setEditForm] = useState({ email: '', role: 'user' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try { setUsers((await adminApi.listUsers()) ?? []) } finally { setLoading(false) }
}
function startEdit(u: AdminUser) {
setEditId(u.id); setEditForm({ email: u.email, role: u.role }); setError('')
}
async function save() {
if (!editId) return
setSaving(true); setError('')
try {
await adminApi.updateUser(editId, editForm.email, editForm.role)
setEditId(null); await load()
} catch (e) { setError(e instanceof Error ? e.message : 'Erreur') } finally { setSaving(false) }
}
async function remove(id: string) {
if (!confirm('Supprimer cet utilisateur ?')) return
await adminApi.deleteUser(id)
await load()
}
if (loading) return <div className="flex justify-center py-20"><Spinner /></div>
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Utilisateurs</h1>
<p className="text-muted-foreground text-sm">{users.length} utilisateur(s)</p>
</div>
<Card>
<CardHeader><CardTitle>Liste</CardTitle></CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b">
<tr className="text-left text-muted-foreground">
<th className="px-4 py-3">Email</th>
<th className="px-4 py-3">Rôle</th>
<th className="px-4 py-3">Inscrit le</th>
<th className="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
{editId === u.id ? (
<Input value={editForm.email} onChange={e => setEditForm(f => ({ ...f, email: e.target.value }))} className="h-7" />
) : (
<span className={u.id === me?.id ? 'font-medium' : ''}>{u.email}</span>
)}
</td>
<td className="px-4 py-3">
{editId === u.id ? (
<Select value={editForm.role} onChange={e => setEditForm(f => ({ ...f, role: e.target.value }))} className="h-7 w-28">
<option value="user">user</option>
<option value="admin">admin</option>
</Select>
) : (
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>{u.role}</Badge>
)}
</td>
<td className="px-4 py-3 text-muted-foreground">{new Date(u.created_at).toLocaleDateString('fr-FR')}</td>
<td className="px-4 py-3">
{editId === u.id ? (
<div className="flex gap-2">
<Button size="sm" onClick={save} disabled={saving}>{saving ? <Spinner className="h-3 w-3" /> : 'OK'}</Button>
<Button size="sm" variant="outline" onClick={() => setEditId(null)}>Annuler</Button>
{error && <span className="text-destructive text-xs">{error}</span>}
</div>
) : (
<div className="flex gap-2">
<Button variant="outline" size="icon" className="h-7 w-7" onClick={() => startEdit(u)}><Pencil className="h-3 w-3" /></Button>
{u.id !== me?.id && (
<Button variant="destructive" size="icon" className="h-7 w-7" onClick={() => remove(u.id)}><Trash2 className="h-3 w-3" /></Button>
)}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,114 @@
import { useState, useEffect } from 'react'
import { Eye, EyeOff, Save } from 'lucide-react'
import { adminApi, type Credential } from '@/api/admin'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Spinner } from '@/components/ui/spinner'
export function Credentials() {
const [creds, setCreds] = useState<Credential[]>([])
const [loading, setLoading] = useState(true)
const [forms, setForms] = useState<Record<string, { username: string; password: string; show: boolean }>>({})
const [saving, setSaving] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try {
const data = await adminApi.getCredentials()
setCreds(data ?? [])
const init: typeof forms = {}
for (const c of data ?? []) {
init[c.source_id] = { username: c.username, password: '', show: false }
}
setForms(init)
} finally { setLoading(false) }
}
function update(sourceId: string, field: string, value: string | boolean) {
setForms(f => ({ ...f, [sourceId]: { ...f[sourceId], [field]: value } }))
}
async function save(sourceId: string) {
setSaving(sourceId); setSuccess(null)
const f = forms[sourceId]
await adminApi.updateCredentials({
source_id: sourceId,
username: f.username,
...(f.password ? { password: f.password } : {}),
})
setSaving(null)
setSuccess(sourceId)
setTimeout(() => setSuccess(null), 2000)
await load()
}
if (loading) return <div className="flex justify-center py-20"><Spinner /></div>
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Identifiants de scraping</h1>
<p className="text-muted-foreground text-sm">Les mots de passe sont chiffrés en AES-256-GCM côté serveur</p>
</div>
{creds.length === 0 && (
<Card><CardContent className="py-8 text-center text-muted-foreground">Aucune source nécessitant des identifiants</CardContent></Card>
)}
{creds.map(cred => {
const f = forms[cred.source_id] ?? { username: '', password: '', show: false }
return (
<Card key={cred.source_id}>
<CardHeader>
<CardTitle className="flex items-center gap-2">{cred.source_name}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor={`user-${cred.source_id}`}>Identifiant / Email</Label>
<Input
id={`user-${cred.source_id}`}
value={f.username}
onChange={e => update(cred.source_id, 'username', e.target.value)}
placeholder="votre@email.com"
/>
</div>
<div className="space-y-1">
<Label htmlFor={`pwd-${cred.source_id}`}>
Mot de passe {cred.has_password && <span className="text-muted-foreground">(déjà défini laisser vide pour ne pas changer)</span>}
</Label>
<div className="relative">
<Input
id={`pwd-${cred.source_id}`}
type={f.show ? 'text' : 'password'}
value={f.password}
onChange={e => update(cred.source_id, 'password', e.target.value)}
placeholder={cred.has_password ? '••••••••' : 'Nouveau mot de passe'}
className="pr-10"
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => update(cred.source_id, 'show', !f.show)}
>
{f.show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
</div>
<Button onClick={() => save(cred.source_id)} disabled={saving === cred.source_id}>
{saving === cred.source_id ? <Spinner className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{success === cred.source_id ? 'Enregistré !' : 'Enregistrer'}
</Button>
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@ -0,0 +1,109 @@
import { useState, useEffect } from 'react'
import { Play, RefreshCw } from 'lucide-react'
import { adminApi, type ScrapeJob, type Source } from '@/api/admin'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Select } from '@/components/ui/select'
import { Spinner } from '@/components/ui/spinner'
function jobStatusVariant(status: string) {
if (status === 'done') return 'bullish'
if (status === 'error') return 'bearish'
if (status === 'running') return 'default'
return 'outline'
}
function fmtDate(t: { Time: string; Valid: boolean } | null) {
if (!t?.Valid) return '—'
return new Date(t.Time).toLocaleString('fr-FR')
}
export function Jobs() {
const [jobs, setJobs] = useState<ScrapeJob[]>([])
const [sources, setSources] = useState<Source[]>([])
const [loading, setLoading] = useState(true)
const [triggering, setTriggering] = useState(false)
const [selectedSource, setSelectedSource] = useState('')
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try {
const [j, s] = await Promise.all([adminApi.listJobs(), adminApi.listSources()])
setJobs(j ?? [])
setSources(s ?? [])
if (s?.[0]) setSelectedSource(s[0].id)
} finally { setLoading(false) }
}
async function trigger() {
if (!selectedSource) return
setTriggering(true)
await adminApi.triggerJob(selectedSource)
setTimeout(() => { load(); setTriggering(false) }, 1000)
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Jobs de scraping</h1>
<p className="text-muted-foreground text-sm">Historique et déclenchement manuel</p>
</div>
<div className="flex items-center gap-2">
<Select value={selectedSource} onChange={e => setSelectedSource(e.target.value)} className="w-40">
{sources.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
<Button onClick={trigger} disabled={triggering || !selectedSource}>
{triggering ? <Spinner className="h-4 w-4" /> : <Play className="h-4 w-4" />}
Lancer
</Button>
<Button variant="outline" size="icon" onClick={load}><RefreshCw className="h-4 w-4" /></Button>
</div>
</div>
{loading ? (
<div className="flex justify-center py-12"><Spinner /></div>
) : (
<Card>
<CardHeader><CardTitle>Historique</CardTitle></CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b">
<tr className="text-left text-muted-foreground">
<th className="px-4 py-3">Source</th>
<th className="px-4 py-3">Statut</th>
<th className="px-4 py-3">Début</th>
<th className="px-4 py-3">Fin</th>
<th className="px-4 py-3">Articles</th>
<th className="px-4 py-3">Erreur</th>
</tr>
</thead>
<tbody>
{jobs.map(j => (
<tr key={j.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 font-medium">{j.source_name}</td>
<td className="px-4 py-3">
<Badge variant={jobStatusVariant(j.status) as 'bullish' | 'bearish' | 'default' | 'outline'}>{j.status}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">{fmtDate(j.started_at)}</td>
<td className="px-4 py-3 text-muted-foreground">{fmtDate(j.finished_at)}</td>
<td className="px-4 py-3">{j.articles_found}</td>
<td className="px-4 py-3 text-destructive max-w-xs truncate">{j.error_msg || '—'}</td>
</tr>
))}
{jobs.length === 0 && (
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">Aucun job</td></tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -0,0 +1,56 @@
import { useState, useEffect } from 'react'
import { adminApi, type Source } from '@/api/admin'
import { Card, CardContent } from '@/components/ui/card'
import { Spinner } from '@/components/ui/spinner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
export function Sources() {
const [sources, setSources] = useState<Source[]>([])
const [loading, setLoading] = useState(true)
const [toggling, setToggling] = useState<string | null>(null)
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try { setSources((await adminApi.listSources()) ?? []) } finally { setLoading(false) }
}
async function toggle(s: Source) {
setToggling(s.id)
await adminApi.updateSource(s.id, !s.enabled)
await load()
setToggling(null)
}
if (loading) return <div className="flex justify-center py-20"><Spinner /></div>
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Sources de news</h1>
<p className="text-muted-foreground text-sm">Activez ou désactivez les sources de données</p>
</div>
<div className="space-y-3">
{sources.map(s => (
<Card key={s.id}>
<CardContent className="flex items-center justify-between py-4">
<div>
<span className="font-semibold">{s.name}</span>
<span className="ml-2 text-xs text-muted-foreground capitalize">({s.type})</span>
</div>
<div className="flex items-center gap-3">
<Badge variant={s.enabled ? 'default' : 'outline'}>
{s.enabled ? 'Activée' : 'Désactivée'}
</Badge>
<Button variant="outline" size="sm" onClick={() => toggle(s)} disabled={toggling === s.id}>
{toggling === s.id ? <Spinner className="h-3 w-3" /> : s.enabled ? 'Désactiver' : 'Activer'}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,32 @@
import type { Config } from 'tailwindcss'
export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
secondary: { DEFAULT: 'hsl(var(--secondary))', foreground: 'hsl(var(--secondary-foreground))' },
destructive: { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))' },
muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' },
accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))' },
card: { DEFAULT: 'hsl(var(--card))', foreground: 'hsl(var(--card-foreground))' },
bullish: '#22c55e',
bearish: '#ef4444',
neutral: '#94a3b8',
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
} satisfies Config

22
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}

View File

@ -0,0 +1 @@
{"root":["./src/main.tsx","./src/api/admin.ts","./src/api/articles.ts","./src/api/assets.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/summaries.ts","./src/components/layout/AppLayout.tsx","./src/components/layout/MobileNav.tsx","./src/components/layout/Sidebar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/spinner.tsx","./src/lib/auth.tsx","./src/lib/cn.ts","./src/lib/router.tsx","./src/pages/Dashboard.tsx","./src/pages/Feed.tsx","./src/pages/Login.tsx","./src/pages/Watchlist.tsx","./src/pages/admin/AIProviders.tsx","./src/pages/admin/AdminLayout.tsx","./src/pages/admin/AdminSettings.tsx","./src/pages/admin/AdminUsers.tsx","./src/pages/admin/Credentials.tsx","./src/pages/admin/Jobs.tsx","./src/pages/admin/Sources.tsx"],"version":"5.9.3"}

44
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,44 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
import path from 'path'
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icons/*.png'],
manifest: {
name: 'Tradarr',
short_name: 'Tradarr',
description: 'Agrégateur de news financières avec résumés IA',
theme_color: '#0f172a',
background_color: '#0f172a',
display: 'standalone',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
runtimeCaching: [
{
urlPattern: /^\/api\//,
handler: 'NetworkFirst',
options: { cacheName: 'api-cache', expiration: { maxEntries: 50, maxAgeSeconds: 300 } },
},
],
},
}),
],
resolve: {
alias: { '@': path.resolve(__dirname, 'src') },
},
server: {
proxy: {
'/api': { target: 'http://localhost:8080', changeOrigin: true },
},
},
})