Compare commits
2 Commits
cc2e94ab88
...
93668273ff
| Author | SHA1 | Date | |
|---|---|---|---|
| 93668273ff | |||
| f9b6d35c49 |
16
.claude/settings.local.json
Normal file
16
.claude/settings.local.json
Normal 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
16
.env.example
Normal 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
30
.gitignore
vendored
Normal 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
29
Makefile
Normal 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
29
backend/Dockerfile
Normal 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"]
|
||||
95
backend/cmd/server/main.go
Normal file
95
backend/cmd/server/main.go
Normal 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
55
backend/go.mod
Normal 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
166
backend/go.sum
Normal 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=
|
||||
79
backend/internal/ai/anthropic.go
Normal file
79
backend/internal/ai/anthropic.go
Normal 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
|
||||
}
|
||||
84
backend/internal/ai/gemini.go
Normal file
84
backend/internal/ai/gemini.go
Normal 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
|
||||
}
|
||||
95
backend/internal/ai/ollama.go
Normal file
95
backend/internal/ai/ollama.go
Normal 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
|
||||
}
|
||||
52
backend/internal/ai/openai.go
Normal file
52
backend/internal/ai/openai.go
Normal 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
|
||||
}
|
||||
160
backend/internal/ai/pipeline.go
Normal file
160
backend/internal/ai/pipeline.go
Normal 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()
|
||||
}
|
||||
27
backend/internal/ai/provider.go
Normal file
27
backend/internal/ai/provider.go
Normal 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)
|
||||
}
|
||||
}
|
||||
349
backend/internal/api/handlers/admin.go
Normal file
349
backend/internal/api/handlers/admin.go
Normal 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)
|
||||
}
|
||||
36
backend/internal/api/handlers/articles.go
Normal file
36
backend/internal/api/handlers/articles.go
Normal 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)
|
||||
}
|
||||
64
backend/internal/api/handlers/auth.go
Normal file
64
backend/internal/api/handlers/auth.go
Normal 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})
|
||||
}
|
||||
33
backend/internal/api/handlers/handler.go
Normal file
33
backend/internal/api/handlers/handler.go
Normal 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,
|
||||
}
|
||||
}
|
||||
33
backend/internal/api/handlers/summaries.go
Normal file
33
backend/internal/api/handlers/summaries.go
Normal 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)
|
||||
}
|
||||
56
backend/internal/api/handlers/user.go
Normal file
56
backend/internal/api/handlers/user.go
Normal 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)
|
||||
}
|
||||
73
backend/internal/api/router.go
Normal file
73
backend/internal/api/router.go
Normal 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
|
||||
}
|
||||
46
backend/internal/auth/jwt.go
Normal file
46
backend/internal/auth/jwt.go
Normal 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
|
||||
}
|
||||
37
backend/internal/auth/middleware.go
Normal file
37
backend/internal/auth/middleware.go
Normal 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()
|
||||
}
|
||||
}
|
||||
12
backend/internal/auth/passwords.go
Normal file
12
backend/internal/auth/passwords.go
Normal 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
|
||||
}
|
||||
53
backend/internal/config/config.go
Normal file
53
backend/internal/config/config.go
Normal 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
|
||||
}
|
||||
59
backend/internal/crypto/aes.go
Normal file
59
backend/internal/crypto/aes.go
Normal 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
|
||||
}
|
||||
43
backend/internal/database/db.go
Normal file
43
backend/internal/database/db.go
Normal 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
|
||||
}
|
||||
10
backend/internal/database/migrations/000001_init.down.sql
Normal file
10
backend/internal/database/migrations/000001_init.down.sql
Normal 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;
|
||||
106
backend/internal/database/migrations/000001_init.up.sql
Normal file
106
backend/internal/database/migrations/000001_init.up.sql
Normal 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');
|
||||
@ -0,0 +1 @@
|
||||
DELETE FROM settings WHERE key = 'ai_system_prompt';
|
||||
13
backend/internal/database/migrations/000002_ai_prompt.up.sql
Normal file
13
backend/internal/database/migrations/000002_ai_prompt.up.sql
Normal 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;
|
||||
48
backend/internal/httputil/response.go
Normal file
48
backend/internal/httputil/response.go
Normal 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()})
|
||||
}
|
||||
99
backend/internal/models/models.go
Normal file
99
backend/internal/models/models.go
Normal 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"`
|
||||
}
|
||||
538
backend/internal/models/repository.go
Normal file
538
backend/internal/models/repository.go
Normal 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
|
||||
}
|
||||
88
backend/internal/scheduler/scheduler.go
Normal file
88
backend/internal/scheduler/scheduler.go
Normal 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
|
||||
}
|
||||
206
backend/internal/scraper/bloomberg/bloomberg.go
Normal file
206
backend/internal/scraper/bloomberg/bloomberg.go
Normal 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
|
||||
}
|
||||
50
backend/internal/scraper/bloomberg/dynamic.go
Normal file
50
backend/internal/scraper/bloomberg/dynamic.go
Normal 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)
|
||||
}
|
||||
106
backend/internal/scraper/registry.go
Normal file
106
backend/internal/scraper/registry.go
Normal 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
|
||||
}
|
||||
75
backend/internal/scraper/scraper.go
Normal file
75
backend/internal/scraper/scraper.go
Normal 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
|
||||
}
|
||||
128
backend/internal/scraper/stocktwits/stocktwits.go
Normal file
128
backend/internal/scraper/stocktwits/stocktwits.go
Normal 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
|
||||
}
|
||||
126
backend/internal/scraper/yahoofinance/yahoofinance.go
Normal file
126
backend/internal/scraper/yahoofinance/yahoofinance.go
Normal 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®ion=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
53
docker-compose.yml
Normal 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
16
frontend/Dockerfile
Normal 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
15
frontend/index.html
Normal 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
43
frontend/nginx.conf
Normal 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
9997
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
frontend/package.json
Normal file
44
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal 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
52
frontend/src/api/admin.ts
Normal 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}`),
|
||||
}
|
||||
25
frontend/src/api/articles.ts
Normal file
25
frontend/src/api/articles.ts
Normal 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}`),
|
||||
}
|
||||
9
frontend/src/api/assets.ts
Normal file
9
frontend/src/api/assets.ts
Normal 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
12
frontend/src/api/auth.ts
Normal 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'),
|
||||
}
|
||||
33
frontend/src/api/client.ts
Normal file
33
frontend/src/api/client.ts
Normal 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' }),
|
||||
}
|
||||
14
frontend/src/api/summaries.ts
Normal file
14
frontend/src/api/summaries.ts
Normal 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'),
|
||||
}
|
||||
26
frontend/src/components/layout/AppLayout.tsx
Normal file
26
frontend/src/components/layout/AppLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/layout/MobileNav.tsx
Normal file
33
frontend/src/components/layout/MobileNav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
frontend/src/components/layout/Sidebar.tsx
Normal file
85
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
frontend/src/components/ui/badge.tsx
Normal file
26
frontend/src/components/ui/badge.tsx
Normal 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} />
|
||||
}
|
||||
35
frontend/src/components/ui/button.tsx
Normal file
35
frontend/src/components/ui/button.tsx
Normal 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'
|
||||
37
frontend/src/components/ui/card.tsx
Normal file
37
frontend/src/components/ui/card.tsx
Normal 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'
|
||||
17
frontend/src/components/ui/input.tsx
Normal file
17
frontend/src/components/ui/input.tsx
Normal 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'
|
||||
13
frontend/src/components/ui/label.tsx
Normal file
13
frontend/src/components/ui/label.tsx
Normal 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'
|
||||
22
frontend/src/components/ui/select.tsx
Normal file
22
frontend/src/components/ui/select.tsx
Normal 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'
|
||||
7
frontend/src/components/ui/spinner.tsx
Normal file
7
frontend/src/components/ui/spinner.tsx
Normal 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
39
frontend/src/index.css
Normal 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
51
frontend/src/lib/auth.tsx
Normal 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
6
frontend/src/lib/cn.ts
Normal 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))
|
||||
}
|
||||
38
frontend/src/lib/router.tsx
Normal file
38
frontend/src/lib/router.tsx
Normal 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
14
frontend/src/main.tsx
Normal 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>
|
||||
)
|
||||
146
frontend/src/pages/Dashboard.tsx
Normal file
146
frontend/src/pages/Dashboard.tsx
Normal 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
116
frontend/src/pages/Feed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
frontend/src/pages/Login.tsx
Normal file
63
frontend/src/pages/Login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
frontend/src/pages/Watchlist.tsx
Normal file
117
frontend/src/pages/Watchlist.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
192
frontend/src/pages/admin/AIProviders.tsx
Normal file
192
frontend/src/pages/admin/AIProviders.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
frontend/src/pages/admin/AdminLayout.tsx
Normal file
12
frontend/src/pages/admin/AdminLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
frontend/src/pages/admin/AdminSettings.tsx
Normal file
131
frontend/src/pages/admin/AdminSettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
frontend/src/pages/admin/AdminUsers.tsx
Normal file
113
frontend/src/pages/admin/AdminUsers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
frontend/src/pages/admin/Credentials.tsx
Normal file
114
frontend/src/pages/admin/Credentials.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
frontend/src/pages/admin/Jobs.tsx
Normal file
109
frontend/src/pages/admin/Jobs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
frontend/src/pages/admin/Sources.tsx
Normal file
56
frontend/src/pages/admin/Sources.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
frontend/tailwind.config.ts
Normal file
32
frontend/tailwind.config.ts
Normal 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
22
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal 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
44
frontend/vite.config.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user