feat: add frontend + backend + database to retrieve and compute news from Yahoo
This commit is contained in:
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
|
||||||
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